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: {