-
+
{!isMobile && (
-
-
+
+
)}
@@ -338,7 +342,7 @@ export default function SettingsPage() {
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
- isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
+ isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
activeTab === item.id
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
@@ -354,7 +358,7 @@ export default function SettingsPage() {
) : (
{item.label}
-
+
{item.description}
@@ -363,7 +367,7 @@ export default function SettingsPage() {
))}
{/* Divider */}
- {!isMobile &&
}
+ {!isMobile &&
}
{DANGER_ITEMS.map((item) => {
const colors = DANGER_COLORS[item.color]
@@ -379,7 +383,7 @@ export default function SettingsPage() {
onClick={handleClick}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
- isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
+ isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
colors.idle,
colors.hover,
@@ -402,7 +406,7 @@ export default function SettingsPage() {
) : (
{item.label}
-
+
{item.description}
diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx
index 124068539..6a4775eea 100644
--- a/apps/web/components/add-document/index.tsx
+++ b/apps/web/components/add-document/index.tsx
@@ -311,27 +311,54 @@ export function AddDocument({
{isMobile && (
-
-
-
+
+
+ Plan usage
+
+
+ {isLoadingUsage
? "…"
- : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`
- }
- percent={searchesPercent}
- active={hasPaidPlan}
- />
+ : `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
+
+
+
+
80
+ ? "#ef4444"
+ : hasPaidPlan
+ ? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
+ : "#0054AD",
+ }}
+ title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
+ />
+
+ {!isLoadingUsage && (
+
+ {formatUsageNumber(tokensUsed)} tokens ·{" "}
+ {formatUsageNumber(searchesUsed)} queries
+
+ )}
)}
diff --git a/apps/web/components/fullscreen-note-modal.tsx b/apps/web/components/fullscreen-note-modal.tsx
index 7762204ed..634987929 100644
--- a/apps/web/components/fullscreen-note-modal.tsx
+++ b/apps/web/components/fullscreen-note-modal.tsx
@@ -45,7 +45,11 @@ export function FullscreenNoteModal({
}, [isOpen, initialContent])
const displayName =
- user?.displayUsername || localStorageUsername || user?.name || ""
+ user?.displayUsername ||
+ localStorageUsername ||
+ user?.name ||
+ user?.email?.split("@")[0] ||
+ ""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const handleSave = useCallback(() => {
diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx
index 97e6a1abd..c1fc0311c 100644
--- a/apps/web/components/graph-layout-view.tsx
+++ b/apps/web/components/graph-layout-view.tsx
@@ -13,7 +13,11 @@ import { dmSansClassName } from "@/lib/fonts"
import { ShareModal } from "./share-modal"
import { shareParam } from "@/lib/search-params"
-export const GraphLayoutView = memo(function GraphLayoutView() {
+export const GraphLayoutView = memo(function GraphLayoutView({
+ onOpenDocument,
+}: {
+ onOpenDocument?: (documentId: string) => 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/header.tsx b/apps/web/components/header.tsx
index 0c4bc6c41..5a1fe531f 100644
--- a/apps/web/components/header.tsx
+++ b/apps/web/components/header.tsx
@@ -62,6 +62,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
user?.displayUsername ||
(isRestoring ? localStorageUsername : "") ||
user?.name ||
+ user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
return (
@@ -71,7 +72,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
{!isMobile && userName && (
diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
index 2491a82cf..ae2bf3271 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/apps/web/components/share-modal.tsx b/apps/web/components/share-modal.tsx
index 6ac942f43..cfe65d71f 100644
--- a/apps/web/components/share-modal.tsx
+++ b/apps/web/components/share-modal.tsx
@@ -284,7 +284,11 @@ export function ShareModal({
const localStorageUsername = useLocalStorageUsername()
const displayName =
- user?.displayUsername || localStorageUsername || user?.name || ""
+ user?.displayUsername ||
+ localStorageUsername ||
+ user?.name ||
+ user?.email?.split("@")[0] ||
+ ""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
const capturePreview = useCallback(async (): Promise => {
diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx
index 494f57a32..65ab6ea15 100644
--- a/apps/web/components/space-selector.tsx
+++ b/apps/web/components/space-selector.tsx
@@ -297,9 +297,15 @@ export function SpaceSelector({
=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 403375bc3..fcb2667ea 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 000000000..4631c02b7
--- /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 000000000..e8e3f9a17
--- /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 49e08c502..f14df8723 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 22d44f47f..48b8355a5 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 e8a1b1adc..e9276dcd0 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: {