diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx
index 9be8924f..3849f7c5 100644
--- a/apps/web/app/(app)/page.tsx
+++ b/apps/web/app/(app)/page.tsx
@@ -402,6 +402,18 @@ export default function NewPage() {
[setDocId],
)
+ // Separate from handleOpenDocument because the graph view only has a document ID,
+ // not the full document object. The modal will fetch the document via the docId
+ // query param, so there may be a brief loading state (unlike handleOpenDocument
+ // which pre-populates via setSelectedDocument).
+ const handleOpenDocumentById = useCallback(
+ (documentId: string) => {
+ analytics.documentModalOpened({ document_id: documentId })
+ setDocId(documentId)
+ },
+ [setDocId],
+ )
+
const handleQuickNoteSave = useCallback(
(content: string) => {
if (content.trim()) {
@@ -630,7 +642,7 @@ export default function NewPage() {
/>
) : viewMode === "graph" ? (
-
+
) : viewMode === "list" ? (
void
+}) {
const { effectiveContainerTags } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
@@ -41,6 +45,7 @@ export const GraphLayoutView = memo(function GraphLayoutView() {
highlightsVisible
maxNodes={undefined}
canvasRef={canvasRef}
+ onOpenDocument={onOpenDocument}
/>
diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
index 2491a82c..ae2bf327 100644
--- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx
+++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
+import type { GraphThemeColors } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
@@ -19,6 +20,7 @@ export interface MemoryGraphWrapperProps {
onSlideshowNodeChange?: (nodeId: string | null) => void
onSlideshowStop?: () => void
canvasRef?: React.RefObject
+ onOpenDocument?: (documentId: string) => void
}
export function MemoryGraph({
@@ -75,7 +77,7 @@ export function MemoryGraph({
{
bg: "transparent",
edgeDerives: "#9ca3af",
- } as any
+ } satisfies Partial
}
{...rest}
>
diff --git a/bun.lock b/bun.lock
index 19eb8fb7..bda09023 100644
--- a/bun.lock
+++ b/bun.lock
@@ -300,10 +300,13 @@
"d3-force": "^3.0.0",
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
+ "happy-dom": "^20.9.0",
"typescript": "^5.9.3",
"vite": "^7.2.1",
"vitest": "^3.2.4",
@@ -397,6 +400,8 @@
"@a2a-js/sdk": ["@a2a-js/sdk@0.2.5", "", { "dependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "body-parser": "^2.2.0", "cors": "^2.8.5", "express": "^4.21.2", "uuid": "^11.1.0" } }, "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g=="],
+ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.88", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.70", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-boKql8OkCiBCEK6ETc6BCwHxAzWhleiAkSvZbxU/UjmusZXyfTktk+KAQOu+y6ct5R6vu3E1Iqfgpk6GiBy1ZQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
@@ -1871,6 +1876,12 @@
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.20", "", {}, "sha512-Djnq7ujPWcRAKyDpwqL4JDe6ZTN9AWAqE2wLstBlsEu4OnO7Im0p8KsHzLU7TPIvLQgNKpkn9EmgBH6xs8yjfA=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
"@tiptap/core": ["@tiptap/core@3.20.0", "", { "peerDependencies": { "@tiptap/pm": "^3.20.0" } }, "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="],
@@ -1955,6 +1966,8 @@
"@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="],
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -2145,6 +2158,10 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
@@ -2293,6 +2310,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
"arkregex": ["arkregex@0.0.3", "", { "dependencies": { "@ark/util": "0.55.0" } }, "sha512-bU21QJOJEFJK+BPNgv+5bVXkvRxyAvgnon75D92newgHxkBJTgiFwQxusyViYyJkETsddPlHyspshDQcCzmkNg=="],
"arktype": ["arktype@2.1.27", "", { "dependencies": { "@ark/schema": "0.55.0", "@ark/util": "0.55.0", "arkregex": "0.0.3" } }, "sha512-enctOHxI4SULBv/TDtCVi5M8oLd4J5SVlPUblXDzSsOYQNMzmVbUosGBnJuZDKmFlN5Ie0/QVEuTE+Z5X1UhsQ=="],
@@ -2603,6 +2622,8 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"cssfilter": ["cssfilter@0.0.10", "", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="],
@@ -2785,6 +2806,8 @@
"docs-test": ["docs-test@workspace:packages/docs-test"],
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -2857,7 +2880,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
- "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
@@ -3165,6 +3188,8 @@
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
+ "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
+
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -3281,7 +3306,7 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
- "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"index-array-by": ["index-array-by@1.4.2", "", {}, "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw=="],
@@ -3627,6 +3652,8 @@
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
@@ -3805,6 +3832,8 @@
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -4119,6 +4148,8 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@@ -4285,6 +4316,8 @@
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -4575,6 +4608,8 @@
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
"strip-json-comments": ["strip-json-comments@5.0.2", "", {}, "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
@@ -4909,6 +4944,8 @@
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"when": ["when@3.7.7", "", {}, "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw=="],
@@ -5493,6 +5530,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -5527,6 +5568,8 @@
"agents/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
+ "aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
+
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"ai-gateway-provider/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
@@ -5629,6 +5672,8 @@
"docs-test/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
@@ -5709,12 +5754,12 @@
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
- "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
-
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ink/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
+ "ink/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
+
"ink/react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
"ink/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@@ -5737,6 +5782,8 @@
"local-pkg/quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
+ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mdast-util-frontmatter/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -5811,6 +5858,12 @@
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+ "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
+ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
@@ -6609,6 +6662,8 @@
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+ "html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"ink/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"ink/react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json
index 403375bc..fcb2667e 100644
--- a/packages/memory-graph/package.json
+++ b/packages/memory-graph/package.json
@@ -1,6 +1,6 @@
{
"name": "@supermemory/memory-graph",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
"type": "module",
"main": "./src/index.tsx",
@@ -52,10 +52,13 @@
"d3-force": "^3.0.0"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
+ "happy-dom": "^20.9.0",
"typescript": "^5.9.3",
"vite": "^7.2.1",
"vitest": "^3.2.4"
diff --git a/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx b/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx
new file mode 100644
index 00000000..4631c02b
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/node-hover-popover.test.tsx
@@ -0,0 +1,419 @@
+/**
+ * Tests for UI changes introduced on the
+ * vorflux/graph-popover-scrollable-view-full branch.
+ *
+ * The NodeHoverPopover component uses hooks (useMemo, useState, useCallback,
+ * useEffect) internally, and the bun workspace's dual-React-instance layout
+ * prevents rendering hook-bearing components in vitest's happy-dom environment.
+ * These tests therefore verify behaviour through:
+ *
+ * (a) Direct source-code assertions — reading the component source and
+ * asserting on specific string/AST patterns that encode the design
+ * choices (style properties, constant values, truncation limits, etc.).
+ *
+ * (b) Pure-logic unit tests — re-implementing and testing the pure functions
+ * (truncate, documentId derivation, render-guard booleans) without
+ * mounting the component.
+ *
+ * This mirrors the pattern already used by all 107 existing tests in this
+ * package (edge-logic, graph-data-utils, viewport, etc.) — none of them
+ * mount React components either.
+ */
+
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, it, expect } from "vitest"
+
+// ---------------------------------------------------------------------------
+// Source text — loaded once; all assertions operate on this string.
+// ---------------------------------------------------------------------------
+const SRC_PATH = resolve(__dirname, "../components/node-hover-popover.tsx")
+const src = readFileSync(SRC_PATH, "utf-8")
+
+// ---------------------------------------------------------------------------
+// Pure re-implementation of the `truncate` helper (matches the source exactly)
+// ---------------------------------------------------------------------------
+function truncate(s: string, max: number): string {
+ return s.length > max ? `${s.substring(0, max)}...` : s
+}
+
+// ---------------------------------------------------------------------------
+// 1. Layout constants
+// ---------------------------------------------------------------------------
+describe("Layout constants", () => {
+ it("SHORTCUTS_W is 160 (widened from 100)", () => {
+ expect(src).toContain("const SHORTCUTS_W = 160")
+ })
+
+ it("CARD_W is 280 (unchanged)", () => {
+ expect(src).toContain("const CARD_W = 280")
+ })
+
+ it("TOTAL_W formula references both constants", () => {
+ expect(src).toContain("const TOTAL_W = CARD_W + 12 + SHORTCUTS_W")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 2. Content area — scrollable, full text (no truncation)
+// ---------------------------------------------------------------------------
+describe("Content area — scrollable and not truncated", () => {
+ it("contentPadStyle has maxHeight: 100", () => {
+ expect(src).toContain("maxHeight: 100,")
+ })
+
+ it("contentPadStyle has overflowY: 'auto'", () => {
+ // The property must appear inside the contentPadStyle block
+ const contentPadIdx = src.indexOf("const contentPadStyle")
+ const nextConst = src.indexOf("\n\t\tconst ", contentPadIdx + 1)
+ const block = src.slice(contentPadIdx, nextConst)
+ expect(block).toContain('overflowY: "auto"')
+ })
+
+ it("contentPadStyle has flex: '1 1 auto' (grows to fill card height)", () => {
+ const contentPadIdx = src.indexOf("const contentPadStyle")
+ const nextConst = src.indexOf("\n\t\tconst ", contentPadIdx + 1)
+ const block = src.slice(contentPadIdx, nextConst)
+ expect(block).toContain('flex: "1 1 auto"')
+ })
+
+ it("content paragraph renders {content} directly — no truncate() call on content", () => {
+ // Old code: truncate(content, 100)
+ // New code: {content || "No content"}
+ expect(src).not.toMatch(/truncate\(content,\s*100\)/)
+ expect(src).toContain('{content || "No content"}')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 3. KeyBadge — has dark background and border (icon badge style)
+// ---------------------------------------------------------------------------
+describe("KeyBadge styles — has background and border", () => {
+ it("KeyBadge style object contains backgroundColor", () => {
+ // Locate the KeyBadge function body
+ const start = src.indexOf("function KeyBadge(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain("backgroundColor: colors.controlBg")
+ })
+
+ it("KeyBadge style object contains a border property", () => {
+ const start = src.indexOf("function KeyBadge(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toMatch(/\bborder\s*:/)
+ expect(fnBody).toContain("colors.controlBorder")
+ })
+
+ it("KeyBadge still has the expected style properties", () => {
+ const start = src.indexOf("function KeyBadge(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain('display: "inline-flex"')
+ expect(fnBody).toContain("width: 16")
+ expect(fnBody).toContain("height: 16")
+ expect(fnBody).toContain("borderRadius: 4")
+ expect(fnBody).toContain("color: colors.popoverTextMuted")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 4. Shortcuts panel — no background or border
+// ---------------------------------------------------------------------------
+describe("Shortcuts panel styles — background and border removed", () => {
+ it("shortcutsPanelStyle does not contain backgroundColor", () => {
+ const start = src.indexOf("const shortcutsPanelStyle")
+ const end = src.indexOf("\n\t\treturn (", start)
+ const block = src.slice(start, end)
+ expect(block).not.toContain("backgroundColor")
+ })
+
+ it("shortcutsPanelStyle does not contain a border property", () => {
+ const start = src.indexOf("const shortcutsPanelStyle")
+ const end = src.indexOf("\n\t\treturn (", start)
+ const block = src.slice(start, end)
+ expect(block).not.toMatch(/\bborder\s*:/)
+ })
+
+ it("shortcutsPanelStyle does not contain borderRadius", () => {
+ const start = src.indexOf("const shortcutsPanelStyle")
+ const end = src.indexOf("\n\t\treturn (", start)
+ const block = src.slice(start, end)
+ expect(block).not.toContain("borderRadius")
+ })
+
+ it("shortcutsPanelStyle still has layout properties", () => {
+ const start = src.indexOf("const shortcutsPanelStyle")
+ const end = src.indexOf("\n\t\treturn (", start)
+ const block = src.slice(start, end)
+ expect(block).toContain('display: "flex"')
+ expect(block).toContain('flexDirection: "column"')
+ expect(block).toContain("gap: 6")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 5. EyeIcon SVG component
+// ---------------------------------------------------------------------------
+describe("EyeIcon SVG component", () => {
+ it("EyeIcon function is defined in the source", () => {
+ expect(src).toContain("function EyeIcon(")
+ })
+
+ it("EyeIcon SVG has aria-hidden='true'", () => {
+ const start = src.indexOf("function EyeIcon(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain('aria-hidden="true"')
+ })
+
+ it("EyeIcon SVG viewBox is '0 0 24 24'", () => {
+ const start = src.indexOf("function EyeIcon(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain('viewBox="0 0 24 24"')
+ })
+
+ it("EyeIcon SVG contains a element (outer eye shape)", () => {
+ const start = src.indexOf("function EyeIcon(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain(" element (pupil)", () => {
+ const start = src.indexOf("function EyeIcon(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain(" {
+ const start = src.indexOf("function EyeIcon(")
+ const end = src.indexOf("\nfunction ", start + 1)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain("stroke={color}")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 6. "View document" button — presence guards and callback wiring
+// ---------------------------------------------------------------------------
+describe("'View document' button — render guard in JSX", () => {
+ it("button is gated on both onOpenDocument AND documentId", () => {
+ // The render guard must be: {onOpenDocument && documentId && (…)}
+ expect(src).toContain("{onOpenDocument && documentId && (")
+ })
+
+ it("button onClick calls onOpenDocument(documentId)", () => {
+ expect(src).toContain("onClick={() => onOpenDocument(documentId)}")
+ })
+
+ it("button label text is 'View document'", () => {
+ expect(src).toContain('label="View document"')
+ })
+
+ it("button icon is the EyeIcon component (not a string shortcut key)", () => {
+ expect(src).toContain("icon={ }")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 7. documentId derivation logic
+// ---------------------------------------------------------------------------
+describe("documentId derivation — memory vs document node", () => {
+ it("source derives documentId from data.documentId for memory nodes", () => {
+ expect(src).toContain(
+ "const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id",
+ )
+ })
+
+ it("pure logic: memory node uses data.documentId as documentId", () => {
+ // Re-implement the expression from source: isMemory ? data.documentId : node.id
+ const isMemory = true
+ const nodeId = "mem-1"
+ const dataDocumentId = "parent-doc-7"
+ const documentId = isMemory ? dataDocumentId : nodeId
+ expect(documentId).toBe("parent-doc-7")
+ })
+
+ it("pure logic: document node uses node.id as documentId", () => {
+ const isMemory = false
+ const nodeId = "doc-42"
+ const dataDocumentId = undefined
+ const documentId = isMemory ? dataDocumentId : nodeId
+ expect(documentId).toBe("doc-42")
+ })
+
+ it("pure logic: documentId is falsy (undefined) when memory node has no documentId", () => {
+ // The render guard is `onOpenDocument && documentId`. When documentId is
+ // undefined the whole expression is falsy regardless of onOpenDocument.
+ const documentId: string | undefined = undefined
+ expect(Boolean(documentId)).toBe(false)
+ })
+
+ it("pure logic: onOpenDocument being undefined makes the guard falsy", () => {
+ // Boolean(undefined && anything) === false
+ const onOpenDocument: ((id: string) => void) | undefined = undefined
+ expect(Boolean(onOpenDocument)).toBe(false)
+ })
+
+ it("pure logic: both truthy values make the guard truthy", () => {
+ // Both sides non-empty → guard passes
+ const documentId: string | undefined = "doc-1"
+ // Use a runtime-resolved value so TS can't narrow to a never-undefined type
+ const handler = [(_id: string) => {}][0] as
+ | ((id: string) => void)
+ | undefined
+ expect(Boolean(handler && documentId)).toBe(true)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 8. VersionTimeline truncation limit — 120 chars (was 60)
+// ---------------------------------------------------------------------------
+describe("VersionTimeline truncation limit", () => {
+ it("source calls truncate(entry.memory, 120) — not 60", () => {
+ expect(src).toContain("truncate(entry.memory, 120)")
+ expect(src).not.toContain("truncate(entry.memory, 60)")
+ })
+
+ it("truncate(s, 120): string of 150 chars is cut to 120 + '...'", () => {
+ const s = "A".repeat(150)
+ expect(truncate(s, 120)).toBe(`${"A".repeat(120)}...`)
+ })
+
+ it("truncate(s, 120): string of exactly 120 chars is returned unchanged", () => {
+ const s = "B".repeat(120)
+ expect(truncate(s, 120)).toBe(s)
+ })
+
+ it("truncate(s, 120): string of 61 chars is NOT truncated (old 60-char limit gone)", () => {
+ const s = "C".repeat(61)
+ const result = truncate(s, 120)
+ expect(result).toBe(s)
+ // Also confirm the old limit would have truncated it
+ expect(truncate(s, 60)).toBe(`${"C".repeat(60)}...`)
+ })
+
+ it("truncate(s, 120): empty string returns empty string", () => {
+ expect(truncate("", 120)).toBe("")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 9. VersionTimeline maxHeight increase — 160 (was 120)
+// ---------------------------------------------------------------------------
+describe("VersionTimeline container maxHeight", () => {
+ it("VersionTimeline containerStyle has maxHeight: 160 (was 120)", () => {
+ // Locate VersionTimeline function body
+ const start = src.indexOf("function VersionTimeline(")
+ const end = src.indexOf("\nexport const NodeHoverPopover", start)
+ const fnBody = src.slice(start, end)
+ expect(fnBody).toContain("maxHeight: 160")
+ expect(fnBody).not.toContain("maxHeight: 120")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 10. onOpenDocument prop — declared in interface and destructured
+// ---------------------------------------------------------------------------
+describe("onOpenDocument prop wiring", () => {
+ it("is declared in NodeHoverPopoverProps interface", () => {
+ const start = src.indexOf("export interface NodeHoverPopoverProps")
+ const end = src.indexOf("}", start)
+ const block = src.slice(start, end)
+ expect(block).toContain("onOpenDocument?: (documentId: string) => void")
+ })
+
+ it("is destructured in the component function parameters", () => {
+ // The destructuring must appear inside the memo() call
+ const memoStart = src.indexOf("export const NodeHoverPopover = memo")
+ const fnBodyStart = src.indexOf("{", memoStart)
+ const firstReturn = src.indexOf("return (", fnBodyStart)
+ const paramBlock = src.slice(fnBodyStart, firstReturn)
+ expect(paramBlock).toContain("onOpenDocument,")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 11. NavButton icon type — accepts ReactNode (not just string)
+// ---------------------------------------------------------------------------
+describe("NavButton icon prop type", () => {
+ it("NavButton icon prop type is React.ReactNode (not string)", () => {
+ // The NavButton params are: function NavButton({ icon, label, ... }: { icon: React.ReactNode ... })
+ // The type annotation block follows the }: pattern.
+ const start = src.indexOf("function NavButton(")
+ // Find the }: { which starts the type annotation
+ const typeAnnotationStart = src.indexOf("}: {", start)
+ const typeAnnotationEnd = src.indexOf("}) {", typeAnnotationStart)
+ const typeBlock = src.slice(typeAnnotationStart, typeAnnotationEnd)
+ expect(typeBlock).toContain("icon: React.ReactNode")
+ expect(typeBlock).not.toContain("icon: string")
+ })
+
+ it("NavButton always wraps icon in KeyBadge", () => {
+ // All icons (string or ReactNode) are wrapped in KeyBadge for consistent badge styling
+ expect(src).toContain("{icon} ")
+ // No conditional dispatch — all icons go through KeyBadge
+ expect(src).not.toContain('typeof icon === "string"')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 12. handleOpenDocument wrapper in memory-graph.tsx
+// Verifies the dismiss-then-open pattern: setSelectedNode(null) and
+// setHoveredNode(null) are called before onOpenDocument?.(documentId)
+// ---------------------------------------------------------------------------
+
+const MG_SRC_PATH = resolve(__dirname, "../components/memory-graph.tsx")
+const mgSrc = readFileSync(MG_SRC_PATH, "utf-8")
+
+describe("handleOpenDocument wrapper in MemoryGraph", () => {
+ // Locate the handleOpenDocument block once for all sub-tests
+ const wrapperStart = mgSrc.indexOf("const handleOpenDocument = useCallback(")
+ const wrapperEnd = mgSrc.indexOf("\t)", wrapperStart) + 2
+ const wrapperBlock = mgSrc.slice(wrapperStart, wrapperEnd)
+
+ it("handleOpenDocument wrapper is defined in memory-graph.tsx", () => {
+ expect(wrapperBlock.length).toBeGreaterThan(0)
+ expect(wrapperBlock).toContain("handleOpenDocument")
+ })
+
+ it("calls setSelectedNode(null) to dismiss the selected node", () => {
+ expect(wrapperBlock).toContain("setSelectedNode(null)")
+ })
+
+ it("calls setHoveredNode(null) to dismiss the hovered node", () => {
+ expect(wrapperBlock).toContain("setHoveredNode(null)")
+ })
+
+ it("calls onOpenDocument?.(documentId) — optional chaining preserves no-op when undefined", () => {
+ expect(wrapperBlock).toContain("onOpenDocument?.(documentId)")
+ })
+
+ it("setSelectedNode(null) and setHoveredNode(null) appear before onOpenDocument?.(documentId)", () => {
+ const selectedIdx = wrapperBlock.indexOf("setSelectedNode(null)")
+ const hoveredIdx = wrapperBlock.indexOf("setHoveredNode(null)")
+ const openDocIdx = wrapperBlock.indexOf("onOpenDocument?.(documentId)")
+ expect(selectedIdx).toBeGreaterThanOrEqual(0)
+ expect(hoveredIdx).toBeGreaterThanOrEqual(0)
+ expect(openDocIdx).toBeGreaterThanOrEqual(0)
+ // Both dismiss calls must appear before the open-document call
+ expect(selectedIdx).toBeLessThan(openDocIdx)
+ expect(hoveredIdx).toBeLessThan(openDocIdx)
+ })
+
+ it("handleOpenDocument is only forwarded to NodeHoverPopover when onOpenDocument is provided", () => {
+ // The prop is passed as: onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
+ // This prevents forwarding a handler when the parent didn't pass a callback.
+ expect(mgSrc).toContain(
+ "onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}",
+ )
+ })
+
+ it("handleOpenDocument is memoised with useCallback (dependency: onOpenDocument)", () => {
+ expect(wrapperBlock).toContain("useCallback(")
+ expect(wrapperBlock).toContain("[onOpenDocument]")
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/setup.ts b/packages/memory-graph/src/__tests__/setup.ts
new file mode 100644
index 00000000..e8e3f9a1
--- /dev/null
+++ b/packages/memory-graph/src/__tests__/setup.ts
@@ -0,0 +1,32 @@
+/**
+ * Vitest setup: patch React 19 shared internals to ensure a single hooks
+ * dispatcher across react and react-dom instances in bun workspace monorepo.
+ *
+ * In bun workspaces with vitest, the CJS require("react") inside react-dom
+ * CJS can resolve to a different ESM module instance than the ESM import React
+ * in test/component source files. React 19 stores the hooks dispatcher on
+ * __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H
+ * If react-dom uses a different object than the component's react import,
+ * hooks see null as the dispatcher and throw "Invalid hook call".
+ */
+import React from "react"
+import ReactDOM from "react-dom"
+
+// biome-ignore lint/suspicious/noExplicitAny: React internals are untyped by design
+const R = React as any
+// biome-ignore lint/suspicious/noExplicitAny: ReactDOM internals are untyped by design
+const RD = ReactDOM as any
+
+const INTERNALS_KEY =
+ "__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE"
+
+const testInternals = R[INTERNALS_KEY]
+const domInternals = RD[INTERNALS_KEY]
+
+if (testInternals && domInternals && testInternals !== domInternals) {
+ // Copy all keys from the test file's React internals object onto the one
+ // react-dom loaded (or vice versa — we just need them to be the same object).
+ Object.assign(domInternals, testInternals)
+ // Also replace the reference so future updates to testInternals propagate
+ RD[INTERNALS_KEY] = testInternals
+}
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx
index 49e08c50..f14df872 100644
--- a/packages/memory-graph/src/components/memory-graph.tsx
+++ b/packages/memory-graph/src/components/memory-graph.tsx
@@ -29,6 +29,7 @@ export function MemoryGraph({
canvasRef: externalCanvasRef,
colors: colorOverrides,
totalCount,
+ onOpenDocument,
}: MemoryGraphProps) {
const resolvedColors = useGraphTheme(colorOverrides)
const colors = useMemo(
@@ -282,6 +283,18 @@ export function MemoryGraph({
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
}, [containerSize.width, containerSize.height])
+ // Wrap onOpenDocument to dismiss the popover before opening the modal.
+ // Without this, the popover (z-index: 100) stays mounted on top of the
+ // document modal (z-50), obscuring it and intercepting clicks.
+ const handleOpenDocument = useCallback(
+ (documentId: string) => {
+ setSelectedNode(null)
+ setHoveredNode(null)
+ onOpenDocument?.(documentId)
+ },
+ [onOpenDocument],
+ )
+
// Keyboard shortcuts — using useEffect with keydown listener
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -473,7 +486,6 @@ export function MemoryGraph({
const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange)
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
- // biome-ignore lint/correctness/useExhaustiveDependencies: reads from refs to avoid resetting interval on resize/node changes
useEffect(() => {
if (!isSlideshowActive || nodes.length === 0) {
if (!isSlideshowActive) {
@@ -498,7 +510,8 @@ export function MemoryGraph({
idx = 0
}
lastIdx = idx
- const n = currentNodes[idx]!
+ const n = currentNodes[idx]
+ if (!n) return
setSelectedNode(n.id)
const sz = containerSizeRef.current
viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height)
@@ -658,6 +671,7 @@ export function MemoryGraph({
onNavigatePrev={navigatePrev}
onNavigateUp={navigateUp}
onSelectNode={handleNodeClick}
+ onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
screenX={activePopoverPosition.screenX}
screenY={activePopoverPosition.screenY}
versionChain={activeVersionChain}
diff --git a/packages/memory-graph/src/components/node-hover-popover.tsx b/packages/memory-graph/src/components/node-hover-popover.tsx
index 22d44f47..48b8355a 100644
--- a/packages/memory-graph/src/components/node-hover-popover.tsx
+++ b/packages/memory-graph/src/components/node-hover-popover.tsx
@@ -20,6 +20,7 @@ export interface NodeHoverPopoverProps {
onNavigateUp?: () => void
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
+ onOpenDocument?: (documentId: string) => void
}
function useCopyToClipboard(timeout = 2000) {
@@ -65,22 +66,43 @@ function KeyBadge({
borderRadius: 4,
fontSize: 10,
fontWeight: 500,
- backgroundColor: colors.controlBg,
- border: `1px solid ${colors.controlBorder}`,
color: colors.popoverTextMuted,
lineHeight: 1,
+ backgroundColor: colors.controlBg,
+ padding: 2,
+ border: `1px solid ${colors.controlBorder}`,
+ boxShadow: "0 1px 2px rgba(0,0,0,0.12)",
}
return {children}
}
+function EyeIcon({ color }: { color: string }) {
+ return (
+
+
+
+
+ )
+}
+
function NavButton({
icon,
label,
onClick,
colors,
}: {
- icon: string
+ icon: React.ReactNode
label: string
onClick?: () => void
colors: GraphThemeColors
@@ -216,7 +238,7 @@ function VersionTimeline({
display: "flex",
flexDirection: "column",
gap: 0,
- maxHeight: 120,
+ maxHeight: 160,
overflowY: "auto",
}
@@ -271,7 +293,7 @@ function VersionTimeline({
type="button"
>
v{entry.version}
- {truncate(entry.memory, 60)}
+ {truncate(entry.memory, 120)}
)
})}
@@ -293,9 +315,10 @@ export const NodeHoverPopover = memo(
onNavigateUp,
onNavigateDown,
onSelectNode,
+ onOpenDocument,
}) {
const CARD_W = 280
- const SHORTCUTS_W = 100
+ const SHORTCUTS_W = 160
const GAP = 24
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
@@ -318,7 +341,7 @@ export const NodeHoverPopover = memo(
const hasForgetInfo =
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
- const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135
+ const CARD_H = hasChain ? 230 : hasForgetInfo ? 190 : 170
const TOTAL_H = CARD_H
const { popoverX, popoverY, connectorPath } = useMemo(() => {
@@ -383,6 +406,9 @@ export const NodeHoverPopover = memo(
const docData = !isMemory ? (data as DocumentNodeData) : null
+ // For document nodes, node.id IS the document ID
+ const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id
+
const overlayStyle: React.CSSProperties = {
pointerEvents: "none",
position: "absolute",
@@ -426,6 +452,9 @@ export const NodeHoverPopover = memo(
const contentPadStyle: React.CSSProperties = {
padding: 12,
+ maxHeight: 100,
+ overflowY: "auto",
+ flex: "1 1 auto",
}
const contentTextStyle: React.CSSProperties = {
@@ -469,9 +498,6 @@ export const NodeHoverPopover = memo(
paddingRight: 12,
paddingTop: 8,
paddingBottom: 8,
- borderRadius: 12,
- border: `1px solid ${colors.popoverBorder}`,
- backgroundColor: colors.popoverBg,
}
return (
@@ -497,9 +523,7 @@ export const NodeHoverPopover = memo(
/>
) : (
-
- {truncate(content, 100) || "No content"}
-
+
{content || "No content"}
)}
@@ -602,6 +626,14 @@ export const NodeHoverPopover = memo(
+ {onOpenDocument && documentId && (
+ }
+ label="View document"
+ onClick={() => onOpenDocument(documentId)}
+ />
+ )}
{isMemory && (
void
/** Canvas ref for external access (e.g. screenshot export) */
canvasRef?: React.RefObject
- /** Custom theme colors - if not provided, reads from CSS variables */
- colors?: GraphThemeColors
+ /** Custom theme colors (partial) - merged with CSS variable / default values */
+ colors?: Partial
/** Total count for loading indicator */
totalCount?: number
+ /** Callback when user wants to view full document content */
+ onOpenDocument?: (documentId: string) => void
}
export interface ChainEntry {
diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts
index e8a1b1ad..e9276dcd 100644
--- a/packages/memory-graph/vite.config.ts
+++ b/packages/memory-graph/vite.config.ts
@@ -8,10 +8,23 @@ export default defineConfig({
alias: {
"@": resolve(__dirname, "./src"),
},
+ // Deduplicate React so the local package and @testing-library/react
+ // share a single React instance. Without this vitest throws
+ // "Invalid hook call" because react-dom internally resolves a different
+ // React copy than the one the component was compiled against.
+ dedupe: ["react", "react-dom"],
},
test: {
- include: ["src/**/*.test.ts"],
- environment: "node",
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
+ environment: "happy-dom",
+ setupFiles: ["src/__tests__/setup.ts"],
+ deps: {
+ optimizer: {
+ web: {
+ include: ["react", "react-dom", "@testing-library/react"],
+ },
+ },
+ },
},
build: {
lib: {