From f5383b66715a12b88b25c2f2e47ca7e8ceeea9b2 Mon Sep 17 00:00:00 2001
From: Vedant Mahajan
Date: Thu, 4 Jun 2026 12:56:39 +0530
Subject: [PATCH 01/41] fix auth connect link (#1045)
---
apps/web/app/auth/connect/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx
index 37f61d3d..eade1f09 100644
--- a/apps/web/app/auth/connect/page.tsx
+++ b/apps/web/app/auth/connect/page.tsx
@@ -395,7 +395,7 @@ function AuthConnectContent() {
Date: Thu, 4 Jun 2026 01:28:18 -0700
Subject: [PATCH 02/41] ENG-712: Accept JSON uploads in Nova + fix file-size
display (#1047)
---
apps/web/components/add-document/file.tsx | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx
index beb31878..a766bcf0 100644
--- a/apps/web/components/add-document/file.tsx
+++ b/apps/web/components/add-document/file.tsx
@@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
import { toast } from "sonner"
export const FILE_ACCEPT =
- "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
+ "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,text/markdown,application/json"
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
@@ -46,10 +46,12 @@ function isAcceptedFile(file: File): boolean {
".txt",
".md",
".mdx",
+ ".json",
])
if (allowedExt.has(ext)) return true
if (file.type.startsWith("image/")) return true
if (file.type === "text/markdown") return true
+ if (file.type === "application/json") return true
return false
}
@@ -57,6 +59,18 @@ function fileQueueKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ const units = ["KB", "MB", "GB", "TB"]
+ let size = bytes / 1024
+ let i = 0
+ while (size >= 1024 && i < units.length - 1) {
+ size /= 1024
+ i++
+ }
+ return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`
+}
+
export function FileContent({
data,
onDataChange,
@@ -284,7 +298,7 @@ export function FileContent({
{item.file.name}
- {(item.file.size / 1024 / 1024).toFixed(2)} MB
+ {formatFileSize(item.file.size)}
{item.status === "error" && item.errorMessage ? (
From c606f76f6daecf62bfb00f9f4c1877eb7a6e0d5b Mon Sep 17 00:00:00 2001
From: Ishaan Gupta
Date: Fri, 5 Jun 2026 03:45:20 +0530
Subject: [PATCH 03/41] Refine Codex and OpenCode OAuth integration status
(#1048)
Co-authored-by: Mahesh Sanikommu
---
apps/web/app/auth/agent-connect/page.tsx | 1 +
apps/web/app/auth/connect/page.tsx | 3 +-
.../document-cards/plugin-preview.tsx | 8 -
.../document-modal/content/plugin-content.tsx | 18 -
apps/web/components/document-modal/index.tsx | 9 +-
.../document-modal/plugin-details.tsx | 83 ---
apps/web/components/integrations-view.tsx | 614 ++++++++++++++----
.../integrations/plugins-detail.tsx | 330 +++++++++-
apps/web/lib/plugin-catalog.ts | 19 +-
9 files changed, 814 insertions(+), 271 deletions(-)
create mode 100644 apps/web/app/auth/agent-connect/page.tsx
delete mode 100644 apps/web/components/document-modal/plugin-details.tsx
diff --git a/apps/web/app/auth/agent-connect/page.tsx b/apps/web/app/auth/agent-connect/page.tsx
new file mode 100644
index 00000000..86838d41
--- /dev/null
+++ b/apps/web/app/auth/agent-connect/page.tsx
@@ -0,0 +1 @@
+export { default } from "../connect/page"
diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx
index eade1f09..0f96e52c 100644
--- a/apps/web/app/auth/connect/page.tsx
+++ b/apps/web/app/auth/connect/page.tsx
@@ -99,7 +99,7 @@ const PLUGIN_INFO: Record = {
"Captures coding decisions and patterns automatically",
"Builds persistent user profile across projects",
],
- icon: "/images/plugins/codex.svg",
+ icon: "/images/plugins/codex.png",
},
}
@@ -199,6 +199,7 @@ function AuthConnectContent() {
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
+ redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString()
} catch (err) {
console.error("Failed to get API key:", err)
diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx
index e4b90419..bd40e76c 100644
--- a/apps/web/components/document-cards/plugin-preview.tsx
+++ b/apps/web/components/document-cards/plugin-preview.tsx
@@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
)}
{parsed.pluginLabel}
-
- {parsed.formatLabel}
-
- {parsed.identifierValue && (
-
- {parsed.identifierValue}
-
- )}
{parsed.pluginLabel}
-
- {parsed.formatLabel}
-
- {parsed.identifierLabel && parsed.identifierValue && (
-
- {parsed.identifierLabel}: {parsed.identifierValue}
-
- )}
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -270,13 +269,8 @@ export function DocumentModal({
],
)
- const hasPluginInsights =
- pluginDocument &&
- pluginDocument.kind !== "claude-code-doc" &&
- pluginDocument.kind !== "openclaw-session"
const hasDocumentInsights = Boolean(
- hasPluginInsights ||
- _document?.summary ||
+ _document?.summary ||
pluginDocument?.summary ||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
)
@@ -303,7 +297,6 @@ export function DocumentModal({
dmSansClassName(),
)}
>
- {hasPluginInsights && }
{_document && (_document.summary || pluginDocument?.summary) && (
-
- {label}
-
- {value}
-
- )
-}
-
-export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
- return (
-
-
-
- Details
-
-
- {parsed.pluginIconSrc && (
-
- )}
- {parsed.pluginLabel}
-
-
-
-
- {parsed.identifierLabel && parsed.identifierValue && (
-
- )}
- {parsed.clientLabel && parsed.clientValue && (
-
- )}
-
- {parsed.artifacts.length > 0 && (
-
-
- Outputs
-
-
- {parsed.artifacts.map((artifact, index) => (
-
- ))}
-
-
- )}
-
- )
-}
diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx
index 5a5a7161..a812cce7 100644
--- a/apps/web/components/integrations-view.tsx
+++ b/apps/web/components/integrations-view.tsx
@@ -23,18 +23,20 @@ import {
ArrowRight,
BookOpen,
Check,
- ChevronDown,
Loader,
+ Plus,
Search,
X,
Zap,
} from "lucide-react"
+import { formatRelativeTime } from "@/components/settings/sync-utils"
import { CHROME_EXTENSION_URL } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import Image from "next/image"
import { useViewMode } from "@/lib/view-mode-context"
import type { ViewParamValue } from "@/lib/search-params"
import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"
+import { addDocumentParam } from "@/lib/search-params"
import {
useCallback,
useEffect,
@@ -46,11 +48,11 @@ import {
import { AnimatePresence, motion } from "motion/react"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
-import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import {
PLUGIN_CATALOG,
FREE_TIER_PLUGIN_IDS,
isFreeTierPlugin,
+ normalizePluginClientId,
type InstallStep,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
@@ -64,6 +66,57 @@ interface ConnectedKey {
keyId: string
keyStart: string | null
pluginId: string
+ lastRequest?: string | null
+ createdAt?: string | null
+}
+
+function toIsoDate(value: string | Date | null | undefined): string | null {
+ if (!value) return null
+ const d = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(d.getTime())) return null
+ return d.toISOString()
+}
+
+function parsePluginAuthKeys(
+ apiKeys: ListedApiKey[],
+ keyPrefix: (key: ListedApiKey) => string | null,
+): { active: ConnectedKey[]; setup: ConnectedKey[] } {
+ const active: ConnectedKey[] = []
+ const setup: ConnectedKey[] = []
+ for (const key of apiKeys) {
+ if (key.enabled === false) continue
+ if (!key.metadata) continue
+ try {
+ const metadata =
+ typeof key.metadata === "string"
+ ? (JSON.parse(key.metadata) as {
+ sm_type?: string
+ sm_client?: string
+ })
+ : (key.metadata as { sm_type?: string; sm_client?: string })
+ if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue
+ const entry: ConnectedKey = {
+ keyId: key.id,
+ keyStart: keyPrefix(key),
+ pluginId: normalizePluginClientId(metadata.sm_client),
+ lastRequest: toIsoDate(key.lastRequest),
+ createdAt: toIsoDate(key.createdAt),
+ }
+ if (key.lastRequest) active.push(entry)
+ else setup.push(entry)
+ } catch {}
+ }
+ return { active, setup }
+}
+
+type ListedApiKey = {
+ id: string
+ name?: string | null
+ createdAt?: string | Date | null
+ enabled?: boolean
+ lastRequest?: string | Date | null
+ metadata: string | Record | null
+ start?: string | null
}
type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import"
@@ -185,7 +238,7 @@ const catParam = parseAsStringEnum([
const CATEGORY_LABEL: Record = {
all: "All",
- connected: "Connected",
+ connected: "Active",
plugins: "Plugins",
"knowledge-bases": "Knowledge bases",
"apps-extensions": "Apps & extensions",
@@ -423,7 +476,7 @@ function ProChip() {
Pro
@@ -487,56 +540,43 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
)
}
-function ConnectedPill({
- keys,
- onRevoke,
+function ActiveButton({
+ count,
+ lastActive,
+ onClick,
}: {
- keys: ConnectedKey[]
- onRevoke: (keyId: string) => void
+ count: number
+ lastActive?: string | null
+ onClick: () => void
}) {
return (
-
-
-
-
- e.stopPropagation()}
- className={cn(
- dmSans125ClassName(),
- "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
- )}
- >
-
- {keys.length > 1 ? `${keys.length} connections` : "Connection"}
-
-
- {keys.map((k) => (
-
-
- {k.keyStart ? `${k.keyStart}…` : "API key"}
-
- onRevoke(k.keyId)} />
-
- ))}
-
-
-
+
+ )
+}
+
+function FinishSetupButton({ onClick }: { onClick: () => void }) {
+ return (
+
+
+ Finish setup
+
)
}
@@ -545,8 +585,7 @@ function ConnectionsCountPill({ count }: { count: number }) {
@@ -594,7 +633,7 @@ function ItemCard({
-
+
{leftIndicator}
({ open: false, key: "", pluginId: null })
+ const [connectedPluginId, setConnectedPluginId] = useState(
+ null,
+ )
+ const [finishSetupPluginId, setFinishSetupPluginId] = useState(
+ null,
+ )
const { data: pluginsData } = useQuery({
queryFn: async () => {
@@ -1030,47 +1075,63 @@ export function IntegrationsView() {
enabled: hasProProduct,
})
- type ApiKey = {
- id: string
- metadata: Record | null
- start: string | null
- }
- const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
- queryKey: ["api-keys", org?.id],
- queryFn: async () => {
- if (!org?.id) return []
- const data = (await authClient.apiKey.list({
- fetchOptions: { query: { metadata: { organizationId: org.id } } },
- })) as unknown as ApiKey[]
- return data.filter((key) => key.metadata?.organizationId === org.id)
+ const { data: apiKeys = [], refetch: refetchKeys } = useQuery(
+ {
+ queryKey: ["api-keys", org?.id],
+ queryFn: async () => {
+ if (!org?.id) return []
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const res = await fetch(`${API_URL}/v3/auth/keys`, {
+ credentials: "include",
+ })
+ if (!res.ok) return []
+ const data = (await res.json()) as { keys?: ListedApiKey[] }
+ return data.keys ?? []
+ },
+ enabled: !!org?.id,
+ staleTime: 30 * 1000,
},
- enabled: !!org?.id,
- staleTime: 30 * 1000,
- })
+ )
- const connectedPlugins = useMemo(() => {
- const out: ConnectedKey[] = []
- for (const key of apiKeys) {
- if (!key.metadata) continue
- try {
- const metadata =
- typeof key.metadata === "string"
- ? (JSON.parse(key.metadata) as {
- sm_type?: string
- sm_client?: string
- })
- : (key.metadata as { sm_type?: string; sm_client?: string })
- if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
- out.push({
- keyId: key.id,
- keyStart: key.start ?? null,
- pluginId: metadata.sm_client,
- })
- }
- } catch {}
+ const keyPrefix = useCallback((key: ListedApiKey): string | null => {
+ return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
+ }, [])
+
+ const { active: activePlugins, setup: setupPlugins } = useMemo(
+ () => parsePluginAuthKeys(apiKeys, keyPrefix),
+ [apiKeys, keyPrefix],
+ )
+
+ const activePluginById = useMemo(() => {
+ const map = new Map()
+ for (const key of activePlugins) {
+ const existing = map.get(key.pluginId)
+ if (!existing) {
+ map.set(key.pluginId, key)
+ continue
+ }
+ const a = key.lastRequest ? new Date(key.lastRequest).getTime() : 0
+ const b = existing.lastRequest
+ ? new Date(existing.lastRequest).getTime()
+ : 0
+ if (a >= b) map.set(key.pluginId, key)
}
- return out
- }, [apiKeys])
+ return map
+ }, [activePlugins])
+
+ const activeCountByPlugin = useMemo(() => {
+ const map = new Map()
+ for (const key of activePlugins) {
+ map.set(key.pluginId, (map.get(key.pluginId) ?? 0) + 1)
+ }
+ return map
+ }, [activePlugins])
+
+ const setupPluginIds = useMemo(
+ () => new Set(setupPlugins.map((k) => k.pluginId)),
+ [setupPlugins],
+ )
const connectionsByProvider = useMemo(() => {
const out: Record = {
@@ -1188,6 +1249,7 @@ export function IntegrationsView() {
)
const [category, setCategory] = useQueryState("cat", catParam)
+ const [, setAddDoc] = useQueryState("add", addDocumentParam)
const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [search, setSearch] = useState("")
@@ -1218,14 +1280,14 @@ export function IntegrationsView() {
const isItemConnected = useCallback(
(item: Item): boolean => {
if (item.kind === "plugin") {
- return connectedPlugins.some((k) => k.pluginId === item.pluginId)
+ return activePluginById.has(item.pluginId)
}
if (item.kind === "connector") {
return connectionsByProvider[item.provider].length > 0
}
return false
},
- [connectedPlugins, connectionsByProvider],
+ [activePluginById, connectionsByProvider],
)
const counts = useMemo>(
@@ -1251,9 +1313,7 @@ export function IntegrationsView() {
}
}, [category, counts, setCategory])
- const claudeCodeConnected = connectedPlugins.some(
- (k) => k.pluginId === "claude_code",
- )
+ const claudeCodeConnected = activePluginById.has("claude_code")
const claudeCodeNeedsPro =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
@@ -1307,7 +1367,7 @@ export function IntegrationsView() {
),
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
ctaLabel: claudeCodeConnected
- ? "Connected"
+ ? "Active"
: claudeCodeNeedsPro
? "Upgrade"
: "Connect",
@@ -1362,13 +1422,58 @@ export function IntegrationsView() {
const renderRight = (item: Item): ReactNode => {
switch (item.kind) {
case "plugin": {
- const keys = connectedPlugins.filter(
- (k) => k.pluginId === item.pluginId,
- )
+ const activeKey = activePluginById.get(item.pluginId)
+ const activeCount = activeCountByPlugin.get(item.pluginId) ?? 0
const needsProUpgrade =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
- if (keys.length > 0) {
- return
+ if (activeKey) {
+ const busy = connectingPlugin === item.pluginId
+ return (
+
+
{
+ trackCard(item)
+ setConnectedPluginId(item.pluginId)
+ }}
+ />
+
+
+ )
+ }
+ if (setupPluginIds.has(item.pluginId)) {
+ return (
+ {
+ trackCard(item)
+ setFinishSetupPluginId(item.pluginId)
+ }}
+ />
+ )
}
if (needsProUpgrade) {
return (
@@ -1399,7 +1504,28 @@ export function IntegrationsView() {
case "connector": {
const count = connectionsByProvider[item.provider].length
const needsProUpgrade = !isAutumnLoading && !hasProProduct
- if (count > 0) return
+ if (count > 0) {
+ return (
+
+
+
+
+ )
+ }
if (needsProUpgrade) {
return (
@@ -1498,30 +1624,32 @@ export function IntegrationsView() {
/>
)
- const renderLeftIndicator = (item: Item): ReactNode => {
- if (item.kind === "plugin") {
- const isConnected = connectedPlugins.some(
- (k) => k.pluginId === item.pluginId,
- )
- return isConnected ? (
-
- ) : null
- }
- if (item.kind === "connector") {
- const count = connectionsByProvider[item.provider].length
- return count > 0 ? (
-
- ) : null
- }
+ const renderLeftIndicator = (_item: Item): ReactNode => {
return null
}
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
: undefined
+ const connectedDialogPlugin = connectedPluginId
+ ? PLUGIN_CATALOG[connectedPluginId]
+ : undefined
+ const connectedDialogKeys = connectedPluginId
+ ? activePlugins.filter((key) => key.pluginId === connectedPluginId)
+ : []
+ const connectedDialogNeedsPro =
+ !!connectedPluginId &&
+ !isAutumnLoading &&
+ !hasProProduct &&
+ !isFreeTierPlugin(connectedPluginId)
+ const finishSetupPlugin = finishSetupPluginId
+ ? PLUGIN_CATALOG[finishSetupPluginId]
+ : undefined
+ const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
const pluginSteps = dialogPlugin?.installSteps ?? []
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
- const setupSteps: InstallStep[] = stepsEmbedKey
+ const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
+ const setupSteps: InstallStep[] = skipGeneratedKeyStep
? pluginSteps
: [
{
@@ -1538,9 +1666,7 @@ export function IntegrationsView() {
return (
- {!q && category !== "connected" && (
-
- )}
+ {!q &&
}
- ) : q ? (
+ ) : q || category !== "all" ? (
{visibleItems.map((item) => renderItemCard(item))}
@@ -1699,6 +1825,246 @@ export function IntegrationsView() {
+
+
+
+
+
+
>
)
}
diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts
index f94a4e92..9f17d156 100644
--- a/apps/web/lib/plugin-catalog.ts
+++ b/apps/web/lib/plugin-catalog.ts
@@ -14,6 +14,8 @@ export interface PluginInfo {
tagline: string
icon: string
docsUrl?: string
+ githubUrl?: string
+ usesOAuth?: boolean
/** Steps shown after a key is minted. The literal `sm_...` is replaced
* with the freshly generated key when rendered. */
installSteps?: InstallStep[]
@@ -55,6 +57,7 @@ export const PLUGIN_CATALOG: Record
= {
tagline: "Persistent memory for the Codex CLI — free on every plan",
icon: "/images/plugins/codex.png",
docsUrl: "https://docs.supermemory.ai/integrations/codex",
+ githubUrl: "https://github.com/supermemoryai/codex-supermemory",
installSteps: [
{
title: "Save your API key",
@@ -77,20 +80,20 @@ export const PLUGIN_CATALOG: Record = {
tagline: "Long-term memory for your OpenCode sessions",
icon: "/images/plugins/opencode.svg",
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
+ githubUrl: "https://github.com/supermemoryai/opencode-supermemory",
+ usesOAuth: true,
installSteps: [
- {
- title: "Save your API key",
- description:
- "Add this to your shell profile. This key is shown only once — save it now.",
- code: 'export SUPERMEMORY_API_KEY="sm_..."',
- copyLabel: "API key",
- secret: true,
- },
{
title: "Install the plugin",
description: "Use --no-tui for non-interactive environments.",
code: "bunx opencode-supermemory@latest install",
},
+ {
+ title: "Authenticate OpenCode",
+ description:
+ "Run the browser auth flow from the machine where OpenCode runs:",
+ code: "bunx opencode-supermemory@latest login",
+ },
{
title: "Verify your config",
description:
From c5bf8ff1f75fc5de22d03ca4be2e03630daa9f77 Mon Sep 17 00:00:00 2001
From: Vedant Mahajan
Date: Fri, 5 Jun 2026 09:59:28 +0530
Subject: [PATCH 04/41] Fix plugin memory parser attribution (#1049)
---
apps/web/lib/plugin-catalog.ts | 1 +
apps/web/lib/plugin-document.ts | 239 ++++++++++++++++++++++++++------
apps/web/lib/plugin-space.ts | 13 +-
3 files changed, 208 insertions(+), 45 deletions(-)
diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts
index 9f17d156..0c69d57b 100644
--- a/apps/web/lib/plugin-catalog.ts
+++ b/apps/web/lib/plugin-catalog.ts
@@ -145,6 +145,7 @@ const SPACE_TO_CATALOG_ID: Record = {
codex: "codex",
opencode: "opencode",
openclaw: "openclaw",
+ hermes: "hermes",
}
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
diff --git a/apps/web/lib/plugin-document.ts b/apps/web/lib/plugin-document.ts
index c5f79db3..9ee34ef4 100644
--- a/apps/web/lib/plugin-document.ts
+++ b/apps/web/lib/plugin-document.ts
@@ -1,6 +1,11 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { detectPluginSource, pluginIconByLabel } from "@/lib/plugin-space"
+import {
+ detectPluginSource,
+ detectPluginSpace,
+ pluginIconByLabel,
+ type PluginSpaceInfo,
+} from "@/lib/plugin-space"
type DocumentsResponse = z.infer
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -8,10 +13,19 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
export type PluginDocumentKind =
| "codex-session"
| "codex-save"
+ | "plugin-session"
+ | "plugin-save"
| "amp-thread"
| "openclaw-session"
| "claude-code-doc"
+type PluginIdentity = {
+ pluginId: PluginSpaceInfo["pluginId"]
+ label: string
+ iconSrc: string | null
+ projectId?: string
+}
+
export interface PluginArtifact {
label: string
value: string
@@ -76,11 +90,108 @@ function formatClientName(value: string | null | undefined): string | null {
if (lower === "claude code") return "Claude Code"
if (lower === "opencode") return "OpenCode"
if (lower === "openclaw") return "OpenClaw"
+ if (lower === "hermes") return "Hermes"
if (lower === "amp") return "Amp"
return normalized.replace(/\b\w/g, (match) => match.toUpperCase())
}
+function pluginIdentityFromSource(
+ value: string | null | undefined,
+): PluginIdentity | null {
+ if (!value) return null
+
+ const normalized = value.trim().toLowerCase().replace(/_/g, "-")
+ if (!normalized) return null
+
+ switch (normalized) {
+ case "claude-code":
+ case "claude-code-plugin":
+ return {
+ pluginId: "claude-code",
+ label: "Claude Code",
+ iconSrc: "/images/plugins/claude-code.svg",
+ }
+ case "codex":
+ return {
+ pluginId: "codex",
+ label: "Codex",
+ iconSrc: "/images/plugins/codex.png",
+ }
+ case "openclaw":
+ return {
+ pluginId: "openclaw",
+ label: "OpenClaw",
+ iconSrc: "/images/plugins/openclaw.svg",
+ }
+ case "hermes":
+ return {
+ pluginId: "hermes",
+ label: "Hermes",
+ iconSrc: "/images/plugins/hermes.svg",
+ }
+ case "opencode":
+ return {
+ pluginId: "opencode",
+ label: "OpenCode",
+ iconSrc: "/images/plugins/opencode.svg",
+ }
+ case "amp":
+ return {
+ pluginId: "amp",
+ label: "Amp",
+ iconSrc: null,
+ }
+ default:
+ return null
+ }
+}
+
+function pluginIdentityFromSpace(
+ document: DocumentWithMemories,
+): PluginIdentity | null {
+ const documentWithTags = document as DocumentWithMemories & {
+ containerTags?: unknown
+ }
+ const containerTags = Array.isArray(documentWithTags.containerTags)
+ ? documentWithTags.containerTags.filter(
+ (tag): tag is string => typeof tag === "string" && !!tag,
+ )
+ : []
+ const memorySpaceTags = Array.isArray(document.memoryEntries)
+ ? document.memoryEntries
+ .map((entry) => entry?.spaceContainerTag)
+ .filter((tag): tag is string => typeof tag === "string" && !!tag)
+ : []
+
+ for (const tag of [...containerTags, ...memorySpaceTags]) {
+ const plugin = detectPluginSpace(tag)
+ if (plugin) return plugin
+ }
+
+ return null
+}
+
+function getDocumentPluginIdentity(
+ document: DocumentWithMemories,
+ metadata: Record,
+): PluginIdentity | null {
+ const sourceCandidates = [
+ typeof document.source === "string" ? document.source : null,
+ typeof metadata.sm_source === "string" ? metadata.sm_source : null,
+ typeof metadata.sm_internal_mcp_client_name === "string"
+ ? metadata.sm_internal_mcp_client_name
+ : null,
+ ]
+
+ for (const source of sourceCandidates) {
+ const plugin = pluginIdentityFromSource(source)
+ if (plugin) return plugin
+ }
+
+ return pluginIdentityFromSpace(document)
+}
+
function extractArtifacts(text: string): {
cleanText: string
artifacts: PluginArtifact[]
@@ -180,7 +291,12 @@ function takePreview(text: string, maxLength = 180): string {
return `${normalized.slice(0, maxLength - 1).trimEnd()}...`
}
-function parseSaveSections(content: string): ParsedPluginDocument | null {
+function parseSaveSections(
+ content: string,
+ plugin: PluginIdentity | null,
+): ParsedPluginDocument | null {
+ if (!plugin) return null
+
const match = content.match(/\[SAVE:([^\]]+)\]([\s\S]*?)\[\/SAVE\]/i)
if (!match) return null
@@ -238,12 +354,13 @@ function parseSaveSections(content: string): ParsedPluginDocument | null {
"Saved project note"
return {
- kind: "codex-save",
- pluginLabel: "Codex",
+ kind: plugin.pluginId === "codex" ? "codex-save" : "plugin-save",
+ pluginLabel: plugin.label,
+ pluginIconSrc: plugin.iconSrc ?? undefined,
formatLabel: "Saved note",
title: "Saved memory note",
preview: takePreview(
- sections[0]?.value ?? "Saved project knowledge from Codex",
+ sections[0]?.value ?? `Saved project knowledge from ${plugin.label}`,
140,
),
summary: takePreview(summary, 220),
@@ -259,9 +376,10 @@ function parseSaveSections(content: string): ParsedPluginDocument | null {
function parseSessionTranscript(
content: string,
config: {
- kind: "codex-session" | "amp-thread"
+ kind: "codex-session" | "amp-thread" | "plugin-session"
headerLabel: "Session" | "Amp thread"
pluginLabel: string
+ pluginIconSrc?: string | null
formatLabel: string
},
): ParsedPluginDocument | null {
@@ -289,6 +407,7 @@ function parseSessionTranscript(
return {
kind: config.kind,
pluginLabel: config.pluginLabel,
+ pluginIconSrc: config.pluginIconSrc ?? undefined,
formatLabel: config.formatLabel,
title: `${config.pluginLabel} conversation`,
preview: takePreview(previewSource, 140),
@@ -302,7 +421,17 @@ function parseSessionTranscript(
}
}
-function parseOpenClawTranscript(content: string): ParsedPluginDocument | null {
+function parseRoleBlockTranscript(
+ content: string,
+ plugin: PluginIdentity | null,
+): ParsedPluginDocument | null {
+ if (
+ !plugin ||
+ (plugin.pluginId !== "openclaw" && plugin.pluginId !== "hermes")
+ ) {
+ return null
+ }
+
const { messages, artifacts } = parseRoleBlockMessages(content)
if (messages.length === 0) return null
@@ -312,12 +441,14 @@ function parseOpenClawTranscript(content: string): ParsedPluginDocument | null {
"Conversation"
return {
- kind: "openclaw-session",
- pluginLabel: "OpenClaw",
+ kind:
+ plugin.pluginId === "openclaw" ? "openclaw-session" : "plugin-session",
+ pluginLabel: plugin.label,
+ pluginIconSrc: plugin.iconSrc ?? undefined,
formatLabel: "Conversation",
- title: "OpenClaw conversation",
+ title: `${plugin.label} conversation`,
preview: takePreview(previewSource, 140),
- summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from OpenClaw.`,
+ summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from ${plugin.label}.`,
artifacts,
messages,
sections: [],
@@ -417,7 +548,8 @@ export function claudeCodeTokenBadge(
document: DocumentWithMemories,
): string | null {
const meta = (document.metadata ?? {}) as Record
- if (getDocumentPluginSource(document, meta) !== "claude-code-plugin") {
+ const plugin = getDocumentPluginIdentity(document, meta)
+ if (plugin?.pluginId !== "claude-code") {
return null
}
const tokens = document.tokenCount
@@ -431,10 +563,26 @@ function parseClaudeCodeByMetadata(
): ParsedPluginDocument | null {
const docSource = getDocumentPluginSource(document, metadata)
let source = detectPluginSource(metadata, docSource)
+ const plugin = getDocumentPluginIdentity(document, metadata)
const rawContent =
typeof document.content === "string" ? document.content : ""
+ if (!source && plugin?.pluginId === "claude-code") {
+ const project =
+ typeof metadata.project === "string" && metadata.project.trim()
+ ? metadata.project.trim()
+ : plugin.projectId
+ source = {
+ pluginId: "claude-code",
+ label: "Claude Code",
+ iconSrc: "/images/plugins/claude-code.svg",
+ projectName: project,
+ formatLabel: "Session",
+ type: "session_turn",
+ }
+ }
+
if (!source) {
if (CLAUDE_CODE_CONTENT_RE.test(rawContent)) {
const md = metadata ?? {}
@@ -488,10 +636,7 @@ export function parsePluginDocument(
if (!document) return null
const metadata = (document.metadata ?? {}) as Record
-
- if (getDocumentPluginSource(document, metadata) === "claude-code-plugin") {
- return withIcon(parseClaudeCodeByMetadata(document, metadata))
- }
+ const plugin = getDocumentPluginIdentity(document, metadata)
const content = normalizeContent(
typeof document.content === "string" ? document.content : "",
@@ -504,39 +649,49 @@ export function parsePluginDocument(
)
if (content) {
- const codexSave = parseSaveSections(content)
- if (codexSave) {
+ const saveDoc = parseSaveSections(content, plugin)
+ if (saveDoc) {
if (clientName) {
- codexSave.clientLabel = "Client"
- codexSave.clientValue = clientName
+ saveDoc.clientLabel = "Client"
+ saveDoc.clientValue = clientName
}
- return withIcon(codexSave)
+ return withIcon(saveDoc)
}
- const codexSession = parseSessionTranscript(content, {
- kind: "codex-session",
- headerLabel: "Session",
- pluginLabel: "Codex",
- formatLabel: "Conversation",
- })
- if (codexSession) {
- if (clientName) {
- codexSession.clientLabel = "Client"
- codexSession.clientValue = clientName
- }
- return withIcon(codexSession)
+ if (plugin?.pluginId === "claude-code") {
+ return withIcon(parseClaudeCodeByMetadata(document, metadata))
}
- const ampThread = parseSessionTranscript(content, {
- kind: "amp-thread",
- headerLabel: "Amp thread",
- pluginLabel: "Amp",
- formatLabel: "Conversation",
- })
- if (ampThread) return withIcon(ampThread)
+ if (plugin?.pluginId === "codex") {
+ const codexSession = parseSessionTranscript(content, {
+ kind: "codex-session",
+ headerLabel: "Session",
+ pluginLabel: plugin.label,
+ pluginIconSrc: plugin.iconSrc,
+ formatLabel: "Conversation",
+ })
+ if (codexSession) {
+ if (clientName) {
+ codexSession.clientLabel = "Client"
+ codexSession.clientValue = clientName
+ }
+ return withIcon(codexSession)
+ }
+ }
- const openClawSession = parseOpenClawTranscript(content)
- if (openClawSession) return withIcon(openClawSession)
+ if (plugin?.pluginId === "amp") {
+ const ampThread = parseSessionTranscript(content, {
+ kind: "amp-thread",
+ headerLabel: "Amp thread",
+ pluginLabel: plugin.label,
+ pluginIconSrc: plugin.iconSrc,
+ formatLabel: "Conversation",
+ })
+ if (ampThread) return withIcon(ampThread)
+ }
+
+ const roleBlockSession = parseRoleBlockTranscript(content, plugin)
+ if (roleBlockSession) return withIcon(roleBlockSession)
}
return withIcon(parseClaudeCodeByMetadata(document, metadata))
diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts
index b42a2fdc..a4e4d7df 100644
--- a/apps/web/lib/plugin-space.ts
+++ b/apps/web/lib/plugin-space.ts
@@ -1,7 +1,7 @@
import { normalizePluginClientId } from "@/lib/plugin-catalog"
export type PluginSpaceInfo = {
- pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
+ pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp" | "hermes"
label: string
iconSrc: string | null
projectId?: string
@@ -45,6 +45,12 @@ const PLUGINS: PluginDef[] = [
iconSrc: null,
prefixes: ["amp"],
},
+ {
+ id: "hermes",
+ label: "Hermes",
+ iconSrc: "/images/plugins/hermes.svg",
+ prefixes: ["hermes"],
+ },
]
function parsePluginRest(rest: string): { projectId?: string } {
@@ -65,6 +71,7 @@ const PLUGIN_ICON_BY_LABEL: Record = {
OpenClaw: "/images/plugins/openclaw.svg",
OpenCode: "/images/plugins/opencode.svg",
Codex: "/images/plugins/codex.png",
+ Hermes: "/images/plugins/hermes.svg",
}
export function pluginIconByLabel(
@@ -112,8 +119,8 @@ export function detectPluginSource(
metadata && typeof metadata.sm_source === "string"
? metadata.sm_source
: null
- const source = documentSource ?? sourceFromMeta
- if (source !== "claude-code-plugin") return null
+ const source = (documentSource ?? sourceFromMeta)?.trim().toLowerCase()
+ if (source !== "claude-code-plugin" && source !== "claude-code") return null
const md = metadata ?? {}
const project =
From 361064f231c4a5325d3105db89bb38d5b3422c5f Mon Sep 17 00:00:00 2001
From: Siddhesh Gawade
Date: Fri, 5 Jun 2026 17:26:28 +0530
Subject: [PATCH 05/41] fix(pipecat-sdk): handle null profile object safely
(#1041)
---
.../src/supermemory_pipecat/service.py | 11 +-
.../tests/test_empty_profile.py | 123 ++++++++++++++++++
2 files changed, 130 insertions(+), 4 deletions(-)
create mode 100644 packages/pipecat-sdk-python/tests/test_empty_profile.py
diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py
index 2aef866b..eb9d5fb6 100644
--- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py
+++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py
@@ -146,14 +146,17 @@ class SupermemoryPipecatService(FrameProcessor):
response = await self._supermemory_client.profile(**kwargs)
+ profile = getattr(response, "profile", None)
+ search_results_response = getattr(response, "search_results", None)
+
search_results = []
- if response.search_results and response.search_results.results:
- search_results = response.search_results.results
+ if search_results_response and search_results_response.results:
+ search_results = search_results_response.results
return {
"profile": {
- "static": response.profile.static,
- "dynamic": response.profile.dynamic,
+ "static": profile.static if profile is not None else [],
+ "dynamic": profile.dynamic if profile is not None else [],
},
"search_results": search_results,
}
diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py
new file mode 100644
index 00000000..ec3ccd26
--- /dev/null
+++ b/packages/pipecat-sdk-python/tests/test_empty_profile.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+import sys
+import types
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+
+def _install_test_stubs() -> None:
+ if "loguru" not in sys.modules:
+ loguru_module = types.ModuleType("loguru")
+
+ class _Logger:
+ def warning(self, *_args, **_kwargs):
+ return None
+
+ def error(self, *_args, **_kwargs):
+ return None
+
+ loguru_module.logger = _Logger()
+ sys.modules["loguru"] = loguru_module
+
+ if "pydantic" not in sys.modules:
+ pydantic_module = types.ModuleType("pydantic")
+
+ class BaseModel:
+ def __init__(self, **kwargs):
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ def Field(*, default=None, **_kwargs):
+ return default
+
+ pydantic_module.BaseModel = BaseModel
+ pydantic_module.Field = Field
+ sys.modules["pydantic"] = pydantic_module
+
+ if "pipecat" not in sys.modules:
+ pipecat_module = types.ModuleType("pipecat")
+ sys.modules["pipecat"] = pipecat_module
+
+ frames_module = types.ModuleType("pipecat.frames.frames")
+
+ class Frame: # pragma: no cover - import stub
+ pass
+
+ class InputAudioRawFrame: # pragma: no cover - import stub
+ pass
+
+ class LLMContextFrame: # pragma: no cover - import stub
+ pass
+
+ class LLMMessagesFrame: # pragma: no cover - import stub
+ pass
+
+ frames_module.Frame = Frame
+ frames_module.InputAudioRawFrame = InputAudioRawFrame
+ frames_module.LLMContextFrame = LLMContextFrame
+ frames_module.LLMMessagesFrame = LLMMessagesFrame
+
+ llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context")
+
+ class LLMContext: # pragma: no cover - import stub
+ pass
+
+ llm_context_module.LLMContext = LLMContext
+
+ openai_context_module = types.ModuleType(
+ "pipecat.processors.aggregators.openai_llm_context"
+ )
+
+ class OpenAILLMContextFrame: # pragma: no cover - import stub
+ pass
+
+ openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame
+
+ frame_processor_module = types.ModuleType("pipecat.processors.frame_processor")
+
+ class FrameDirection: # pragma: no cover - import stub
+ pass
+
+ class FrameProcessor:
+ def __init__(self, *args, **kwargs):
+ return None
+
+ frame_processor_module.FrameDirection = FrameDirection
+ frame_processor_module.FrameProcessor = FrameProcessor
+
+ sys.modules["pipecat.frames.frames"] = frames_module
+ sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module
+ sys.modules[
+ "pipecat.processors.aggregators.openai_llm_context"
+ ] = openai_context_module
+ sys.modules["pipecat.processors.frame_processor"] = frame_processor_module
+
+
+_install_test_stubs()
+
+from supermemory_pipecat.service import SupermemoryPipecatService
+
+
+class _MockSupermemoryClient:
+ def __init__(self, response):
+ self.profile = AsyncMock(return_value=response)
+
+
+class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase):
+ async def test_retrieve_memories_handles_null_profile(self) -> None:
+ service = SupermemoryPipecatService(api_key="mock_key", user_id="new_user_123")
+
+ response = SimpleNamespace(profile=None, search_results=None)
+ service._supermemory_client = _MockSupermemoryClient(response)
+
+ result = await service._retrieve_memories("Hello world")
+
+ self.assertEqual(
+ result,
+ {
+ "profile": {"static": [], "dynamic": []},
+ "search_results": [],
+ },
+ )
\ No newline at end of file
From 9ad351feb091780aafe328473c5f39907de2d24b Mon Sep 17 00:00:00 2001
From: Mubashir R <112580905+Mubashirrrr@users.noreply.github.com>
Date: Fri, 5 Jun 2026 16:58:36 +0500
Subject: [PATCH 06/41] fix: view_range [start, -1] drops the last line in the
Claude memory tool (#1043)
Co-authored-by: Claude Opus 4.8
---
packages/tools/src/claude-memory.test.ts | 85 ++++++++++++++++++++++++
packages/tools/src/claude-memory.ts | 8 ++-
2 files changed, 92 insertions(+), 1 deletion(-)
create mode 100644 packages/tools/src/claude-memory.test.ts
diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts
new file mode 100644
index 00000000..80aea36d
--- /dev/null
+++ b/packages/tools/src/claude-memory.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path
+// can be exercised deterministically without any network access. We only need
+// `search.execute` to return a single document with known multi-line content.
+const searchExecute = vi.fn()
+
+vi.mock("supermemory", () => {
+ return {
+ default: class MockSupermemory {
+ search = { execute: searchExecute }
+ add = vi.fn()
+ memories = { forget: vi.fn() }
+ },
+ }
+})
+
+import { ClaudeMemoryTool } from "./claude-memory"
+
+const FILE_PATH = "/memories/notes.txt"
+// 5 distinct lines so an off-by-one at either end is observable.
+const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5"
+
+function mockDocument(content: string) {
+ // `readFile` matches by `documentId === normalizePathToCustomId(path)`.
+ // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
+ searchExecute.mockResolvedValue({
+ results: [{ documentId: "memories_notes_txt", content }],
+ })
+}
+
+describe("ClaudeMemoryTool view_range", () => {
+ let tool: ClaudeMemoryTool
+
+ beforeEach(() => {
+ searchExecute.mockReset()
+ mockDocument(FILE_CONTENT)
+ tool = new ClaudeMemoryTool("test-api-key")
+ })
+
+ it("returns the final line when end is the -1 'to end of file' sentinel", async () => {
+ // Anthropic's text-editor / memory tool convention: an end of -1 means
+ // "read to the end of the file". The whole file must come back.
+ const result = await tool.handleCommand({
+ command: "view",
+ path: FILE_PATH,
+ view_range: [1, -1],
+ })
+
+ expect(result.success).toBe(true)
+ // Regression guard: the last line must not be dropped.
+ expect(result.content).toContain("line5")
+ // And every line should be present, in order.
+ for (const line of ["line1", "line2", "line3", "line4", "line5"]) {
+ expect(result.content).toContain(line)
+ }
+ })
+
+ it("reads from a start line to the end when end is -1", async () => {
+ const result = await tool.handleCommand({
+ command: "view",
+ path: FILE_PATH,
+ view_range: [3, -1],
+ })
+
+ expect(result.success).toBe(true)
+ expect(result.content).toContain("line3")
+ expect(result.content).toContain("line5")
+ expect(result.content).not.toContain("line2")
+ })
+
+ it("still honors explicit positive ranges", async () => {
+ const result = await tool.handleCommand({
+ command: "view",
+ path: FILE_PATH,
+ view_range: [2, 4],
+ })
+
+ expect(result.success).toBe(true)
+ expect(result.content).toContain("line2")
+ expect(result.content).toContain("line4")
+ expect(result.content).not.toContain("line1")
+ expect(result.content).not.toContain("line5")
+ })
+})
diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts
index b65c8ff9..bbb60547 100644
--- a/packages/tools/src/claude-memory.ts
+++ b/packages/tools/src/claude-memory.ts
@@ -283,7 +283,13 @@ export class ClaudeMemoryTool {
if (viewRange) {
const lines = content.split("\n")
const [startLine, endLine] = viewRange
- const selectedLines = lines.slice(startLine - 1, endLine)
+ // `endLine === -1` is the documented sentinel for "read to the end
+ // of the file" (same convention as Anthropic's text-editor tool).
+ // Passing it straight to Array.slice would be interpreted as a
+ // from-the-end index and silently drop the final line, so map any
+ // negative end to the array length.
+ const sliceEnd = endLine < 0 ? lines.length : endLine
+ const selectedLines = lines.slice(startLine - 1, sliceEnd)
// Format with line numbers
const numberedLines = selectedLines.map(
From ad5734cdfcf86a598a0e0e9e7830c45cf1672873 Mon Sep 17 00:00:00 2001
From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com>
Date: Fri, 5 Jun 2026 15:00:18 +0000
Subject: [PATCH 07/41] feat(web): Current/All space filter + redesigned chat
history sheet (#1050)
Add a Current/All scope toggle (default Current) to Nova chat history; All fetches threads across every space via the new scope=all API, with each row showing its space.
Redesign the sheet to fit the design system: cap width at 380px (was ~92vw), add search, date-grouped rows (Today/Yesterday/Previous 7/30 days/Older), hover-reveal delete, header space chip, solid primary New Conversation button.
Part of ENG-728: https://linear.app/supermemory/issue/ENG-728
---
apps/web/components/chat/index.tsx | 257 ++++++++++++++++++++---------
1 file changed, 182 insertions(+), 75 deletions(-)
diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx
index 80e933bb..73d3cc09 100644
--- a/apps/web/components/chat/index.tsx
+++ b/apps/web/components/chat/index.tsx
@@ -23,6 +23,7 @@ import {
ChevronDownIcon,
HistoryIcon,
Plus,
+ Search,
SquarePenIcon,
Trash2,
XIcon,
@@ -177,8 +178,21 @@ export function ChatSidebar({
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
const [heightOffset, setHeightOffset] = useState(95)
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
+ const [historyScope, setHistoryScope] = useState<"current" | "all">("current")
+ const [historySearch, setHistorySearch] = useState("")
const [threads, setThreads] = useState<
- Array<{ id: string; title: string; createdAt: string; updatedAt: string }>
+ Array<{
+ id: string
+ title: string
+ createdAt: string
+ updatedAt: string
+ space?: {
+ containerTag: string
+ name: string
+ emoji?: string
+ isDefault: boolean
+ }
+ }>
>([])
const [isLoadingThreads, setIsLoadingThreads] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState(
@@ -626,8 +640,10 @@ export function ChatSidebar({
const fetchThreads = useCallback(async () => {
setIsLoadingThreads(true)
try {
+ const params = new URLSearchParams({ projectId: chatProject })
+ if (historyScope === "all") params.set("scope", "all")
const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${chatProject}`,
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`,
{ credentials: "include" },
)
if (response.ok) {
@@ -639,7 +655,7 @@ export function ChatSidebar({
} finally {
setIsLoadingThreads(false)
}
- }, [chatProject])
+ }, [chatProject, historyScope])
useEffect(() => {
if (!isHistoryOpen) return
@@ -1057,6 +1073,36 @@ export function ChatSidebar({
!isStackedInput || isResponding || messages.length > 0
const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT
+ const threadGroups = useMemo(() => {
+ const q = historySearch.trim().toLowerCase()
+ const filtered = q
+ ? threads.filter((t) => (t.title || "").toLowerCase().includes(q))
+ : threads
+ const now = new Date()
+ const startOfToday = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ ).getTime()
+ const day = 86_400_000
+ const buckets: Array<{ label: string; items: typeof threads }> = [
+ { label: "Today", items: [] },
+ { label: "Yesterday", items: [] },
+ { label: "Previous 7 days", items: [] },
+ { label: "Previous 30 days", items: [] },
+ { label: "Older", items: [] },
+ ]
+ for (const t of filtered) {
+ const ts = new Date(t.updatedAt).getTime()
+ if (ts >= startOfToday) buckets[0].items.push(t)
+ else if (ts >= startOfToday - day) buckets[1].items.push(t)
+ else if (ts >= startOfToday - 7 * day) buckets[2].items.push(t)
+ else if (ts >= startOfToday - 30 * day) buckets[3].items.push(t)
+ else buckets[4].items.push(t)
+ }
+ return buckets.filter((b) => b.items.length > 0)
+ }, [threads, historySearch])
+
const chatHistorySheet = (
button]:text-[#FAFAFA]",
dmSansClassName(),
)}
>
-
- Chat History
-
- Space: {chatSpaceLabel}
+
+
+ Chat History
+
+ {historyScope === "all" ? "All spaces" : chatSpaceLabel}
+
+
+
+ {historyScope === "all"
+ ? "All conversations across your spaces"
+ : `Conversations in ${chatSpaceLabel}`}
+
+
+
+ setHistorySearch(e.target.value)}
+ placeholder="Search conversations…"
+ className="h-9 w-full rounded-lg border border-[#161F2C] bg-[#0F141B] pr-3 pl-9 text-sm text-white placeholder:text-[#737373] focus:border-[#267BF1]/50 focus:outline-none"
+ />
+
+
+ {(["current", "all"] as const).map((scope) => (
+
+ ))}
+
+
-
-
+
+
{isLoadingThreads ? (
@@ -1091,79 +1174,103 @@ export function ChatSidebar({
No conversations yet
+ ) : threadGroups.length === 0 ? (
+
+ No conversations match “{historySearch.trim()}”
+
) : (
-
- {threads.map((thread) => {
- const isActive = thread.id === currentChatId
- return (
-
)}
-
+
{
handleNewChat()
setIsHistoryOpen(false)
From 053baa5029bf7e02bb32bc129ab254aae3ef7b8f Mon Sep 17 00:00:00 2001
From: Dhravya Shah
Date: Fri, 5 Jun 2026 15:48:41 -0700
Subject: [PATCH 08/41] Improve memory graph (#973)
Co-authored-by: Ishaan Gupta
---
.../src/app/api/container-tags/route.ts | 45 ++
.../src/app/api/graph/route.ts | 38 +-
apps/memory-graph-playground/src/app/page.tsx | 376 +++++++++++--
apps/web/app/(app)/page.tsx | 2 +-
apps/web/components/graph-layout-view.tsx | 5 +-
.../memory-graph/hooks/use-graph-api.ts | 49 +-
.../memory-graph/memory-graph-wrapper.tsx | 19 +-
.../src/__tests__/edge-logic.test.ts | 8 +-
.../src/__tests__/graph-data-utils.test.ts | 146 +++++-
.../src/__tests__/renderer-utils.test.ts | 37 +-
.../src/__tests__/simulation.test.ts | 19 +-
.../src/__tests__/version-chain.test.ts | 46 ++
.../src/__tests__/viewport.test.ts | 16 +
packages/memory-graph/src/canvas/renderer.ts | 433 +++++++++++++--
.../memory-graph/src/canvas/simulation.ts | 44 +-
.../memory-graph/src/canvas/version-chain.ts | 23 +-
packages/memory-graph/src/canvas/viewport.ts | 98 ++--
.../memory-graph/src/components/legend.tsx | 249 ++++++++-
.../src/components/memory-graph.tsx | 157 ++++--
.../src/components/node-hover-popover.tsx | 13 +-
packages/memory-graph/src/constants.ts | 15 +-
.../memory-graph/src/hooks/use-graph-data.ts | 493 ++++++++++++++++--
packages/memory-graph/src/types.ts | 2 +
packages/memory-graph/src/utils/hash.ts | 7 +
24 files changed, 2034 insertions(+), 306 deletions(-)
create mode 100644 apps/memory-graph-playground/src/app/api/container-tags/route.ts
create mode 100644 packages/memory-graph/src/utils/hash.ts
diff --git a/apps/memory-graph-playground/src/app/api/container-tags/route.ts b/apps/memory-graph-playground/src/app/api/container-tags/route.ts
new file mode 100644
index 00000000..324e3bc5
--- /dev/null
+++ b/apps/memory-graph-playground/src/app/api/container-tags/route.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server"
+
+const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
+
+export async function POST(request: Request) {
+ try {
+ const { apiKey } = await request.json()
+
+ if (!apiKey) {
+ return NextResponse.json(
+ { error: "API key is required" },
+ { status: 400 },
+ )
+ }
+
+ const containerTagsUrl = new URL(
+ "/v3/container-tags/list",
+ SUPERMEMORY_API_BASE_URL,
+ )
+
+ const response = await fetch(containerTagsUrl, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ return NextResponse.json(
+ { error: errorData.message || `API error: ${response.status}` },
+ { status: response.status },
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Container tags API error:", error)
+ return NextResponse.json(
+ { error: "Failed to fetch container tags" },
+ { status: 500 },
+ )
+ }
+}
diff --git a/apps/memory-graph-playground/src/app/api/graph/route.ts b/apps/memory-graph-playground/src/app/api/graph/route.ts
index c722c625..67b62d34 100644
--- a/apps/memory-graph-playground/src/app/api/graph/route.ts
+++ b/apps/memory-graph-playground/src/app/api/graph/route.ts
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"
+const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
+
export async function POST(request: Request) {
try {
const body = await request.json()
@@ -9,6 +11,7 @@ export async function POST(request: Request) {
limit = 500,
sort = "createdAt",
order = "desc",
+ containerTags,
} = body
if (!apiKey) {
@@ -18,23 +21,28 @@ export async function POST(request: Request) {
)
}
- const response = await fetch(
- "https://api.supermemory.ai/v3/documents/documents",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${apiKey}`,
- },
- body: JSON.stringify({
- page,
- limit,
- sort,
- order,
- }),
- },
+ const graphUrl = new URL(
+ "/v3/documents/documents",
+ SUPERMEMORY_API_BASE_URL,
)
+ const response = await fetch(graphUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ page,
+ limit,
+ sort,
+ order,
+ ...(Array.isArray(containerTags) && containerTags.length > 0
+ ? { containerTags }
+ : {}),
+ }),
+ })
+
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(
diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx
index 68a6f945..8131ef69 100644
--- a/apps/memory-graph-playground/src/app/page.tsx
+++ b/apps/memory-graph-playground/src/app/page.tsx
@@ -1,16 +1,52 @@
"use client"
-import { useState, useCallback, useMemo } from "react"
+import { useState, useCallback, useEffect, useMemo } from "react"
import {
MemoryGraph,
- type DocumentWithMemories,
type GraphApiDocument,
type GraphApiMemory,
+ type GraphThemeColors,
+ type MemoryRelation,
} from "@supermemory/memory-graph"
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
+interface PlaygroundApiMemory {
+ id: string
+ memory?: string | null
+ content?: string | null
+ isStatic?: boolean
+ spaceId?: string | null
+ isLatest?: boolean
+ isForgotten?: boolean
+ forgetAfter?: string | null
+ forgetReason?: string | null
+ version?: number
+ parentMemoryId?: string | null
+ rootMemoryId?: string | null
+ createdAt: string
+ updatedAt: string
+ relation?: MemoryRelation | null
+ updatesMemoryId?: string | null
+ nextVersionId?: string | null
+ memoryRelations?: Record | null
+ spaceContainerTag?: string | null
+}
+
+interface PlaygroundApiDocument {
+ id: string
+ title: string | null
+ summary?: string | null
+ documentType?: string
+ type?: string
+ containerTags?: string[]
+ createdAt: string
+ updatedAt: string
+ memories?: PlaygroundApiMemory[]
+ memoryEntries?: PlaygroundApiMemory[]
+}
+
interface DocumentsResponse {
- documents: DocumentWithMemories[]
+ documents: PlaygroundApiDocument[]
pagination: {
currentPage: number
limit: number
@@ -19,42 +55,82 @@ interface DocumentsResponse {
}
}
+interface ContainerTagOption {
+ id: string
+ name?: string | null
+ containerTag: string
+ documentCount?: number
+ memoryCount?: number
+ lastActivityAt?: string | null
+}
+
+type GraphVariant = "consumer" | "console"
+type LoadBehavior = "zoom" | "manual" | "background"
+
+const PAGE_SIZE = 100
+const BACKGROUND_LOAD_DELAY_MS = 900
+const CONSUMER_GRAPH_COLORS = {
+ bg: "transparent",
+ edgeDerives: "#9ca3af",
+} satisfies Partial
+
/** Convert the external API format to the internal graph format */
-function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
- return docs.map((doc) => ({
- id: doc.id,
- title: doc.title,
- summary: doc.summary ?? null,
- documentType: doc.documentType,
- createdAt: doc.createdAt,
- updatedAt: doc.updatedAt,
- memories: doc.memories.map(
- (mem): GraphApiMemory => ({
- id: mem.id,
- memory: mem.content,
- isStatic: mem.isStatic ?? false,
- spaceId: mem.spaceId ?? "",
- isLatest: mem.isLatest ?? true,
- isForgotten: mem.isForgotten ?? false,
- forgetAfter: mem.forgetAfter ?? null,
- forgetReason: mem.forgetReason ?? null,
- version: mem.version ?? 1,
- parentMemoryId: mem.parentMemoryId ?? null,
- rootMemoryId: mem.rootMemoryId ?? null,
- createdAt: mem.createdAt,
- updatedAt: mem.updatedAt,
- }),
- ),
- }))
+function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
+ return docs.map((doc) => {
+ const memories = doc.memories ?? doc.memoryEntries ?? []
+
+ return {
+ id: doc.id,
+ title: doc.title,
+ summary: doc.summary ?? null,
+ documentType: doc.documentType ?? doc.type ?? "unknown",
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ memories: memories.map(
+ (mem): GraphApiMemory => ({
+ id: mem.id,
+ memory: mem.memory ?? mem.content ?? "",
+ isStatic: mem.isStatic ?? false,
+ spaceId: mem.spaceId ?? "",
+ isLatest: mem.isLatest ?? true,
+ isForgotten: mem.isForgotten ?? false,
+ forgetAfter: mem.forgetAfter ?? null,
+ forgetReason: mem.forgetReason ?? null,
+ version: mem.version ?? 1,
+ parentMemoryId: mem.parentMemoryId ?? null,
+ rootMemoryId: mem.rootMemoryId ?? null,
+ createdAt: mem.createdAt,
+ updatedAt: mem.updatedAt,
+ relation: mem.relation ?? null,
+ updatesMemoryId: mem.updatesMemoryId ?? null,
+ nextVersionId: mem.nextVersionId ?? null,
+ memoryRelations: mem.memoryRelations ?? null,
+ spaceContainerTag: mem.spaceContainerTag ?? null,
+ }),
+ ),
+ }
+ })
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
- const [documents, setDocuments] = useState([])
+ const [containerTag, setContainerTag] = useState("")
+ const [containerTags, setContainerTags] = useState([])
+ const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
+ const [containerTagsError, setContainerTagsError] = useState(
+ null,
+ )
+ const [documents, setDocuments] = useState([])
const [isLoading, setIsLoading] = useState(false)
+ const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState(null)
const [showGraph, setShowGraph] = useState(false)
const [stressTestCount, setStressTestCount] = useState(0)
+ const [graphVariant, setGraphVariant] = useState("consumer")
+ const [loadBehavior, setLoadBehavior] = useState("zoom")
+ const [pagination, setPagination] = useState<
+ DocumentsResponse["pagination"] | null
+ >(null)
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
@@ -64,13 +140,47 @@ export default function Home() {
documents: GraphApiDocument[]
} | null>(null)
- const PAGE_SIZE = 500
+ const selectedContainerTags = useMemo(() => {
+ const trimmed = containerTag.trim()
+ return trimmed ? [trimmed] : undefined
+ }, [containerTag])
+
+ const fetchContainerTags = useCallback(async () => {
+ if (!apiKey || isLoadingContainerTags) return
+
+ setIsLoadingContainerTags(true)
+ setContainerTagsError(null)
+
+ try {
+ const response = await fetch("/api/container-tags", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || "Failed to fetch container tags")
+ }
+
+ const data = (await response.json()) as ContainerTagOption[]
+ setContainerTags(data)
+ } catch (err) {
+ setContainerTagsError(
+ err instanceof Error ? err : new Error("Unknown error"),
+ )
+ } finally {
+ setIsLoadingContainerTags(false)
+ }
+ }, [apiKey, isLoadingContainerTags])
const fetchDocuments = useCallback(
async (page: number, append = false) => {
if (!apiKey) return
- if (page === 1) {
+ if (append) {
+ setIsLoadingMore(true)
+ } else {
setIsLoading(true)
}
setError(null)
@@ -87,6 +197,7 @@ export default function Home() {
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
+ containerTags: selectedContainerTags,
}),
})
@@ -103,6 +214,7 @@ export default function Home() {
setDocuments(data.documents)
}
+ setPagination(data.pagination)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
@@ -110,19 +222,27 @@ export default function Home() {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
+ setIsLoadingMore(false)
}
},
- [apiKey],
+ [apiKey, selectedContainerTags],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
+ setPagination(null)
+ void fetchContainerTags()
fetchDocuments(1)
}
}
+ const handleLoadMoreDocuments = useCallback(() => {
+ if (!pagination || pagination.currentPage >= pagination.totalPages) return
+ fetchDocuments(pagination.currentPage + 1, true)
+ }, [fetchDocuments, pagination])
+
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
@@ -131,6 +251,7 @@ export default function Home() {
})
setMockData({ documents: data.documents })
setDocuments([])
+ setPagination(null)
setStressTestCount(count)
setShowGraph(true)
setError(null)
@@ -157,7 +278,67 @@ export default function Home() {
return toGraphDocuments(documents)
}, [documents, mockData])
+ const availableContainerTags = useMemo(() => {
+ const options = new Map()
+ for (const tag of containerTags) {
+ if (tag.containerTag) options.set(tag.containerTag, tag)
+ }
+ for (const doc of documents) {
+ for (const tag of doc.containerTags ?? []) {
+ if (tag && !options.has(tag)) {
+ options.set(tag, { id: tag, containerTag: tag, name: tag })
+ }
+ }
+ const memories = doc.memories ?? doc.memoryEntries ?? []
+ for (const mem of memories) {
+ const tag = mem.spaceContainerTag
+ if (tag && !options.has(tag)) {
+ options.set(tag, { id: tag, containerTag: tag, name: tag })
+ }
+ }
+ }
+ return [...options.values()]
+ }, [containerTags, documents])
+
const displayCount = mockData ? stressTestCount : documents.length
+ const hasMore =
+ !mockData &&
+ pagination != null &&
+ pagination.currentPage < pagination.totalPages
+ const totalCount = mockData
+ ? stressTestCount
+ : (pagination?.totalItems ?? documents.length)
+ const maxNodes = mockData ? 1000 : undefined
+ const graphHandlesLoadMore = loadBehavior === "zoom"
+
+ useEffect(() => {
+ if (
+ loadBehavior !== "background" ||
+ !showGraph ||
+ mockData ||
+ !hasMore ||
+ isLoading ||
+ isLoadingMore ||
+ error
+ ) {
+ return
+ }
+
+ const timer = window.setTimeout(
+ handleLoadMoreDocuments,
+ BACKGROUND_LOAD_DELAY_MS,
+ )
+ return () => window.clearTimeout(timer)
+ }, [
+ error,
+ handleLoadMoreDocuments,
+ hasMore,
+ isLoading,
+ isLoadingMore,
+ loadBehavior,
+ mockData,
+ showGraph,
+ ])
return (
+
Mode:
+
+ {(["consumer", "console"] as const).map((variant) => (
+ setGraphVariant(variant)}
+ className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
+ graphVariant === variant
+ ? "bg-blue-600 text-white"
+ : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
+ }`}
+ aria-pressed={graphVariant === variant}
+ >
+ {variant}
+
+ ))}
+
+
+
Load:
+
+ {(["zoom", "manual", "background"] as const).map((behavior) => (
+ setLoadBehavior(behavior)}
+ className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
+ loadBehavior === behavior
+ ? "bg-emerald-600 text-white"
+ : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
+ }`}
+ aria-pressed={loadBehavior === behavior}
+ >
+ {behavior}
+
+ ))}
+
+ {loadBehavior === "manual" && (
+
+ {isLoadingMore
+ ? "Loading..."
+ : hasMore
+ ? "Load next"
+ : "All loaded"}
+
+ )}
+ {loadBehavior === "background" && !mockData && hasMore && (
+
+ Auto paging
+
+ )}
+
{/* Stress test buttons */}
Stress Test:
{[50, 100, 200, 500].map((count) => (
@@ -300,13 +593,24 @@ export default function Home() {
0}
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}
+ totalCount={totalCount}
+ colors={
+ graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
+ }
>
diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx
index 17433857..6d336fa3 100644
--- a/apps/web/app/(app)/page.tsx
+++ b/apps/web/app/(app)/page.tsx
@@ -684,7 +684,7 @@ export default function NewPage() {
onBack={() => void setViewMode("integrations")}
/>
) : viewMode === "graph" ? (
-
+
) : viewMode === "list" ? (
diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx
index c1fc0311..0e9f5d67 100644
--- a/apps/web/components/graph-layout-view.tsx
+++ b/apps/web/components/graph-layout-view.tsx
@@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({
}, [setIsShareModalOpen])
return (
-
+
{/* Full-width graph */}
-
+
diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts
index 7991f600..30b47672 100644
--- a/apps/web/components/memory-graph/hooks/use-graph-api.ts
+++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts
@@ -1,7 +1,7 @@
"use client"
import { useInfiniteQuery } from "@tanstack/react-query"
-import { useMemo } from "react"
+import { useEffect, useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphApiDocument,
@@ -9,12 +9,13 @@ import type {
MemoryRelation,
} from "@supermemory/memory-graph"
-const PAGE_SIZE = 100
+const PAGE_SIZE = 500
interface UseGraphApiOptions {
containerTags?: string[]
documentIds?: string[]
enabled?: boolean
+ maxNodes?: number
}
interface ApiMemoryEntry {
@@ -59,6 +60,13 @@ interface ApiDocumentsResponse {
}
}
+function getGraphNodeCount(documents: ApiDocument[]): number {
+ return documents.reduce(
+ (total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
+ 0,
+ )
+}
+
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
@@ -108,7 +116,7 @@ function toGraphDocument(
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
- const { containerTags, documentIds, enabled = true } = options
+ const { containerTags, documentIds, enabled = true, maxNodes } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
@@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
containerTags,
[],
filteredDocumentIds,
+ maxNodes,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
@@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
return response.data as unknown as ApiDocumentsResponse
},
- getNextPageParam: (lastPage) => {
+ getNextPageParam: (lastPage, allPages) => {
+ if (hasDocumentIds) return undefined
+ if (maxNodes != null) {
+ const loadedNodes = allPages.reduce(
+ (total, page) => total + getGraphNodeCount(page.documents ?? []),
+ 0,
+ )
+ if (loadedNodes >= maxNodes) return undefined
+ }
+
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
},
@@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
enabled,
})
+ const loadedNodeCount = useMemo(() => {
+ if (!data?.pages) return 0
+ return data.pages.reduce(
+ (total, page) => total + getGraphNodeCount(page.documents ?? []),
+ 0,
+ )
+ }, [data])
+
+ useEffect(() => {
+ if (!enabled || hasDocumentIds) return
+ if (!hasNextPage || isFetchingNextPage) return
+ if (maxNodes != null && loadedNodeCount >= maxNodes) return
+ fetchNextPage()
+ }, [
+ enabled,
+ hasDocumentIds,
+ hasNextPage,
+ isFetchingNextPage,
+ loadedNodeCount,
+ maxNodes,
+ fetchNextPage,
+ ])
+
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>
diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
index 0b2ca826..f594004f 100644
--- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx
+++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
@@ -1,6 +1,5 @@
"use client"
-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"
@@ -34,20 +33,6 @@ export function MemoryGraph({
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
- const containerRef = useRef
(null)
-
- useEffect(() => {
- const el = containerRef.current
- if (!el) return
- const ro = new ResizeObserver(() => {
- setContainerSize({ width: el.clientWidth, height: el.clientHeight })
- })
- ro.observe(el)
- setContainerSize({ width: el.clientWidth, height: el.clientHeight })
- return () => ro.disconnect()
- }, [])
-
const {
documents,
isLoading: apiIsLoading,
@@ -59,11 +44,11 @@ export function MemoryGraph({
} = useGraphApi({
containerTags,
documentIds,
- enabled: containerSize.width > 0 && containerSize.height > 0,
+ maxNodes,
})
return (
-
+
{
+ it("extends edges have lower opacity than derives edges (visible but quiet)", () => {
const ext = getEdgeVisualProps("extends")
const der = getEdgeVisualProps("derives")
- expect(ext.opacity).toBeGreaterThan(der.opacity)
+ expect(ext.opacity).toBeLessThan(der.opacity)
})
- it("updates edges have higher opacity than derives edges (version chains are prominent)", () => {
+ it("updates edges are more prominent than quiet relation edges", () => {
const upd = getEdgeVisualProps("updates")
const der = getEdgeVisualProps("derives")
+ const ext = getEdgeVisualProps("extends")
expect(upd.opacity).toBeGreaterThan(der.opacity)
+ expect(upd.opacity).toBeGreaterThan(ext.opacity)
})
it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => {
diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts
index 9c643925..4f728f97 100644
--- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts
+++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts
@@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest"
import {
getMemoryBorderColor,
getEdgeVisualProps,
+ getMemoryOrbitOffset,
+ computeClusterAssignments,
+ getAppendPosition,
+ getNodeBounds,
} from "../hooks/use-graph-data"
import { DEFAULT_COLORS } from "../constants"
-import type { GraphApiMemory } from "../types"
+import type { GraphApiDocument, GraphApiMemory, GraphNode } from "../types"
function makeMemory(overrides: Partial = {}): GraphApiMemory {
return {
@@ -25,6 +29,28 @@ function makeMemory(overrides: Partial = {}): GraphApiMemory {
}
}
+function makeNode(id: string, x: number, y: number, size = 50): GraphNode {
+ return {
+ id,
+ type: "document",
+ x,
+ y,
+ size,
+ borderColor: "#fff",
+ isHovered: false,
+ isDragging: false,
+ data: {
+ id,
+ title: id,
+ summary: null,
+ type: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ memories: [],
+ },
+ }
+}
+
describe("getMemoryBorderColor", () => {
const colors = DEFAULT_COLORS
@@ -67,14 +93,14 @@ describe("getEdgeVisualProps", () => {
it("returns correct props for updates edges", () => {
const props = getEdgeVisualProps("updates")
- expect(props.opacity).toBeCloseTo(0.7)
- expect(props.thickness).toBeCloseTo(2)
+ expect(props.opacity).toBeCloseTo(0.48)
+ expect(props.thickness).toBeCloseTo(1.45)
})
it("returns correct props for extends edges", () => {
const props = getEdgeVisualProps("extends")
- expect(props.opacity).toBeCloseTo(0.55)
- expect(props.thickness).toBeCloseTo(1.5)
+ expect(props.opacity).toBeCloseTo(0.16)
+ expect(props.thickness).toBeCloseTo(0.8)
})
it("returns default props for unknown edge types", () => {
@@ -83,3 +109,113 @@ describe("getEdgeVisualProps", () => {
expect(props.thickness).toBeCloseTo(1.2)
})
})
+
+describe("cluster assignments", () => {
+ it("keeps memories from the same document in the same visual cluster", () => {
+ const assignments = computeClusterAssignments([
+ makeDocument("doc-a", [
+ makeMemory({ id: "a1" }),
+ makeMemory({ id: "a2" }),
+ ]),
+ ])
+
+ expect(assignments.get("a1")?.key).toBe(assignments.get("a2")?.key)
+ expect(assignments.get("a1")?.color).toMatch(/^#[0-9A-Fa-f]{6}$/)
+ })
+
+ it("merges cross-document relation clusters", () => {
+ const assignments = computeClusterAssignments([
+ makeDocument("doc-a", [makeMemory({ id: "a1" })]),
+ makeDocument("doc-b", [
+ makeMemory({ id: "b1", memoryRelations: { a1: "extends" } }),
+ ]),
+ ])
+
+ expect(assignments.get("a1")?.key).toBe(assignments.get("b1")?.key)
+ })
+})
+
+describe("memory orbit placement", () => {
+ it("pushes high-index memories onto wider rings", () => {
+ const early = getMemoryOrbitOffset(0, 80, "mem-0")
+ const late = getMemoryOrbitOffset(50, 80, "mem-50")
+
+ expect(late.radius).toBeGreaterThan(early.radius)
+ })
+
+ it("is deterministic for the same memory", () => {
+ const first = getMemoryOrbitOffset(12, 40, "mem-12")
+ const second = getMemoryOrbitOffset(12, 40, "mem-12")
+
+ expect(second).toEqual(first)
+ })
+})
+
+describe("append placement helpers", () => {
+ it("computes bounds including node radius", () => {
+ const bounds = getNodeBounds([
+ makeNode("a", 100, 100, 50),
+ makeNode("b", 300, 220, 40),
+ ])
+
+ expect(bounds).toEqual({
+ minX: 75,
+ minY: 75,
+ maxX: 320,
+ maxY: 240,
+ centerX: 197.5,
+ centerY: 157.5,
+ })
+ })
+
+ it("places appended nodes outside existing graph bounds", () => {
+ const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)]
+ const bounds = getNodeBounds(existing)
+ const pos = getAppendPosition(existing, 0, 1000, 800)
+
+ if (!bounds) throw new Error("Expected bounds")
+ const outsideBounds =
+ pos.x < bounds.minX ||
+ pos.x > bounds.maxX ||
+ pos.y < bounds.minY ||
+ pos.y > bounds.maxY
+ expect(outsideBounds).toBe(true)
+ })
+
+ it("distributes append positions across multiple surrounding areas", () => {
+ const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)]
+ const bounds = getNodeBounds(existing)
+ if (!bounds) throw new Error("Expected bounds")
+
+ const areas = new Set(
+ Array.from({ length: 8 }, (_, index) => {
+ const pos = getAppendPosition(existing, index, 1000, 800)
+ if (pos.x < bounds.minX) return "left"
+ if (pos.x > bounds.maxX) return "right"
+ if (pos.y < bounds.minY) return "top"
+ return "bottom"
+ }),
+ )
+
+ expect(areas.size).toBeGreaterThan(2)
+ })
+
+ it("uses the canvas center when no existing nodes are available", () => {
+ expect(getAppendPosition([], 0, 1000, 800)).toEqual({ x: 500, y: 400 })
+ })
+})
+
+function makeDocument(
+ id: string,
+ memories: GraphApiMemory[],
+): GraphApiDocument {
+ return {
+ id,
+ title: id,
+ summary: null,
+ documentType: "text",
+ createdAt: "2024-01-01",
+ updatedAt: "2024-01-01",
+ memories,
+ }
+}
diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts
index 481256a2..02630037 100644
--- a/packages/memory-graph/src/__tests__/renderer-utils.test.ts
+++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest"
-import { lightenColor } from "../canvas/renderer"
+import {
+ getRelationEdgeStride,
+ lightenColor,
+ mixHexColors,
+ shouldDrawRelationEdge,
+} from "../canvas/renderer"
describe("lightenColor", () => {
test("lightens a dark hex color", () => {
@@ -55,3 +60,33 @@ describe("lightenColor", () => {
expect(result).toBe("#2f3338")
})
})
+
+describe("mixHexColors", () => {
+ test("mixes two hex colors", () => {
+ expect(mixHexColors("#000000", "#ffffff", 0.5)).toBe("#808080")
+ })
+
+ test("returns the base color for unsupported color formats", () => {
+ expect(mixHexColors("rgb(0,0,0)", "#ffffff", 0.5)).toBe("rgb(0,0,0)")
+ })
+})
+
+describe("relation edge level-of-detail helpers", () => {
+ test("keeps all relation edges at normal zoom", () => {
+ expect(getRelationEdgeStride(5000, 0.5)).toBe(1)
+ })
+
+ test("samples dense relation edges at low zoom", () => {
+ expect(getRelationEdgeStride(1040, 0.1)).toBe(4)
+ })
+
+ test("always draws structural derives edges", () => {
+ expect(shouldDrawRelationEdge("edge-1", "derives", 10)).toBe(true)
+ })
+
+ test("deterministically samples non-structural relation edges", () => {
+ const first = shouldDrawRelationEdge("rel-a-b", "updates", 4)
+ const second = shouldDrawRelationEdge("rel-a-b", "updates", 4)
+ expect(second).toBe(first)
+ })
+})
diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts
index 61d46752..cd0035a2 100644
--- a/packages/memory-graph/src/__tests__/simulation.test.ts
+++ b/packages/memory-graph/src/__tests__/simulation.test.ts
@@ -61,8 +61,10 @@ describe("ForceSimulation", () => {
sim.init(nodes, [])
// After init with pre-ticks, nodes at same position should have moved apart
- const dx = nodes[0]!.x - nodes[1]!.x
- const dy = nodes[0]!.y - nodes[1]!.y
+ const [first, second] = nodes
+ if (!first || !second) throw new Error("Expected two nodes")
+ const dx = first.x - second.x
+ const dy = first.y - second.y
const dist = Math.sqrt(dx * dx + dy * dy)
expect(dist).toBeGreaterThan(0)
sim.destroy()
@@ -74,7 +76,9 @@ describe("ForceSimulation", () => {
sim.init(nodes, [])
// Update with same nodes but different positions
- nodes[0]!.x = 50
+ const [first] = nodes
+ if (!first) throw new Error("Expected node")
+ first.x = 50
expect(() => sim.update(nodes, [])).not.toThrow()
expect(sim.isActive()).toBe(true)
sim.destroy()
@@ -97,6 +101,15 @@ describe("ForceSimulation", () => {
sim.destroy()
})
+ it("stop immediately deactivates the simulation", () => {
+ const sim = new ForceSimulation()
+ const nodes = [makeNode("a", 0, 0)]
+ sim.init(nodes, [])
+ sim.stop()
+ expect(sim.isActive()).toBe(false)
+ sim.destroy()
+ })
+
it("handles empty nodes array", () => {
const sim = new ForceSimulation()
expect(() => sim.init([], [])).not.toThrow()
diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts
index eb1bf029..1ce75c39 100644
--- a/packages/memory-graph/src/__tests__/version-chain.test.ts
+++ b/packages/memory-graph/src/__tests__/version-chain.test.ts
@@ -67,6 +67,52 @@ describe("VersionChainIndex", () => {
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
+ expect(chain?.map((e) => e.version)).toEqual([1, 2, 3])
+ })
+
+ it("infers display versions when backend repeats v1 across an update chain", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1, isLatest: false }),
+ makeMem({
+ id: "m2",
+ parentMemoryId: "m1",
+ rootMemoryId: "m1",
+ version: 1,
+ memoryRelations: { m1: "updates" },
+ }),
+ ])
+ idx.rebuild([doc])
+
+ const chain = idx.getChain("m2")
+ expect(chain).not.toBeNull()
+ expect(chain?.map((e) => e.id)).toEqual(["m1", "m2"])
+ expect(chain?.map((e) => e.version)).toEqual([1, 2])
+ })
+
+ it("only infers broken version entries and preserves valid backend versions", () => {
+ const idx = new VersionChainIndex()
+ const doc = makeDoc("d1", [
+ makeMem({ id: "m1", version: 1 }),
+ makeMem({
+ id: "m2",
+ parentMemoryId: "m1",
+ rootMemoryId: "m1",
+ version: 5,
+ }),
+ makeMem({
+ id: "m3",
+ parentMemoryId: "m2",
+ rootMemoryId: "m1",
+ version: 5,
+ }),
+ ])
+ idx.rebuild([doc])
+
+ const chain = idx.getChain("m3")
+ expect(chain).not.toBeNull()
+ expect(chain?.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
+ expect(chain?.map((e) => e.version)).toEqual([1, 5, 6])
})
it("getChain from middle element returns full chain (backward + forward)", () => {
diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts
index aee0b14b..b2164e25 100644
--- a/packages/memory-graph/src/__tests__/viewport.test.ts
+++ b/packages/memory-graph/src/__tests__/viewport.test.ts
@@ -122,6 +122,22 @@ describe("ViewportState", () => {
expect(vp.zoom).toBeCloseTo(0.1)
})
+ it("can lower the minimum zoom to fit a large loaded graph", () => {
+ const vp = new ViewportState(0, 0, 0.5)
+ const nodes = [
+ makeNode("a", 0, 0),
+ makeNode("b", 10_000, 0),
+ makeNode("c", 0, 10_000),
+ makeNode("d", 10_000, 10_000),
+ ]
+
+ vp.setMinZoomForNodes(nodes, 800, 600)
+ vp.zoomImmediate(0.01, 0, 0)
+
+ expect(vp.zoom).toBeLessThan(0.1)
+ expect(vp.zoom).toBeGreaterThan(0.005)
+ })
+
it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => {
const vp = new ViewportState(0, 0, 2)
// Try to zoom way up: 2 * 100 = 200, should clamp to 5
diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts
index a3e1be30..17c73f7b 100644
--- a/packages/memory-graph/src/canvas/renderer.ts
+++ b/packages/memory-graph/src/canvas/renderer.ts
@@ -7,6 +7,7 @@ import type {
} from "../types"
import type { ViewportState } from "./viewport"
import { drawDocIcon, roundRect } from "./document-icons"
+import { hashString } from "../utils/hash"
export interface RenderState {
selectedNodeId: string | null
@@ -17,6 +18,13 @@ export interface RenderState {
// Module-level reusable batch map – cleared each frame instead of reallocating
const edgeBatches = new Map()
+const RELATION_LOD_ZOOM = 0.5
+const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
+const RELATION_LOD_DENSE_COUNT = 180
+const DERIVES_LOD_ZOOM = 0.38
+const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200
+const DENSE_POINT_THRESHOLD = 25000
+const DENSE_POINT_ZOOM = 0.42
function nodeMatchesDocumentHighlights(
node: GraphNode,
@@ -30,13 +38,21 @@ function nodeMatchesDocumentHighlights(
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor(
items: T[],
+): Map {
+ return groupByComputedColor(items, (item) => item.color)
+}
+
+function groupByComputedColor(
+ items: T[],
+ getColor: (item: T) => string,
): Map {
const map = new Map()
for (const item of items) {
- let batch = map.get(item.color)
+ const color = getColor(item)
+ let batch = map.get(color)
if (!batch) {
batch = []
- map.set(item.color, batch)
+ map.set(color, batch)
}
batch.push(item)
}
@@ -70,9 +86,98 @@ function edgeStyle(
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
if (edge.edgeType === "updates")
- return { color: colors.edgeUpdates, width: 2, opacity: 0.7 }
+ return { color: colors.edgeUpdates, width: 1.45, opacity: 0.48 }
// "extends" and any unknown edge types
- return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 }
+ return { color: colors.edgeExtends, width: 0.8, opacity: 0.16 }
+}
+
+export function getRelationEdgeStride(
+ relationEdgeCount: number,
+ zoom: number,
+): number {
+ if (
+ zoom >= RELATION_LOD_ZOOM ||
+ relationEdgeCount <= RELATION_LOD_MAX_BACKGROUND_EDGES
+ ) {
+ return 1
+ }
+ return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES)
+}
+
+function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number {
+ if (
+ zoom >= DERIVES_LOD_ZOOM ||
+ derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES
+ ) {
+ return 1
+ }
+ return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES)
+}
+
+export function shouldDrawRelationEdge(
+ edgeId: string,
+ edgeType: string,
+ stride: number,
+): boolean {
+ if (edgeType === "derives" || stride <= 1) return true
+ return hashString(edgeId) % stride === 0
+}
+
+function shouldDrawSampledEdge(edgeId: string, stride: number): boolean {
+ return stride <= 1 || hashString(edgeId) % stride === 0
+}
+
+function applyRelationLevelOfDetail(
+ style: { color: string; width: number; opacity: number },
+ edgeType: string,
+ relationEdgeCount: number,
+ zoom: number,
+ hasFocus: boolean,
+ hasActiveHover: boolean,
+) {
+ if (edgeType === "derives") return { style, glow: true }
+ if (hasFocus || hasActiveHover) {
+ const isUpdate = edgeType === "updates"
+ const minOpacity = hasActiveHover ? 0.9 : 0.76
+ const minWidth = hasActiveHover ? 2.35 : 1.8
+ return {
+ style: isUpdate
+ ? {
+ ...style,
+ width: Math.max(style.width, minWidth),
+ opacity: Math.max(style.opacity, minOpacity),
+ }
+ : style,
+ glow: isUpdate,
+ }
+ }
+ if (
+ zoom >= RELATION_LOD_ZOOM ||
+ relationEdgeCount <= RELATION_LOD_DENSE_COUNT
+ ) {
+ return { style, glow: edgeType === "updates" }
+ }
+
+ const densityFactor = Math.min(
+ 1,
+ RELATION_LOD_DENSE_COUNT / relationEdgeCount,
+ )
+ const zoomFactor = clampNumber(zoom / RELATION_LOD_ZOOM, 0.25, 1)
+ const opacityFactor = clampNumber(densityFactor * zoomFactor, 0.06, 0.24)
+ const widthFactor = clampNumber(zoomFactor * 0.65, 0.22, 0.7)
+
+ return {
+ style: {
+ ...style,
+ width: Math.max(0.45, style.width * widthFactor),
+ opacity: style.opacity * opacityFactor,
+ },
+ glow: false,
+ }
+}
+
+function clampNumber(value: number, min: number, max: number): number {
+ return value < min ? min : value > max ? max : value
}
function batchKey(style: {
@@ -92,6 +197,7 @@ interface PreparedEdge {
style: { color: string; width: number; opacity: number }
edgeType: string
arrowSize: number
+ glow: boolean
}
function drawEdges(
@@ -106,13 +212,36 @@ function drawEdges(
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
+ const relationEdgeCount = edges.reduce(
+ (count, edge) => count + (edge.edgeType === "derives" ? 0 : 1),
+ 0,
+ )
+ const derivesEdgeCount = edges.length - relationEdgeCount
+ const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom)
+ const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom)
const prepared: PreparedEdge[] = []
for (const edge of edges) {
- // Zoom-based edge culling for extends edges at very low zoom
- if (edge.edgeType === "extends") {
- if (viewport.zoom < 0.08) continue
+ const edgeType = edge.edgeType ?? "derives"
+ const srcId = typeof edge.source === "string" ? edge.source : edge.source.id
+ const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id
+ const hoverConnected =
+ state.hoveredNodeId != null &&
+ (srcId === state.hoveredNodeId || tgtId === state.hoveredNodeId)
+ const selectedConnected =
+ state.selectedNodeId != null &&
+ (srcId === state.selectedNodeId || tgtId === state.selectedNodeId)
+ const activeConnected = hoverConnected || selectedConnected
+ const shouldAlwaysDrawActiveUpdate =
+ edgeType === "updates" && activeConnected
+ const edgeStride = edgeType === "derives" ? derivesStride : relationStride
+ if (
+ !shouldAlwaysDrawActiveUpdate &&
+ !hasDim &&
+ !shouldDrawSampledEdge(edge.id, edgeStride)
+ ) {
+ continue
}
const src =
@@ -121,7 +250,7 @@ function drawEdges(
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
- if (edge.edgeType === "derives") {
+ if (edgeType === "derives") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
@@ -149,12 +278,39 @@ function drawEdges(
let connected = true
if (hasDim) {
- const srcId =
- typeof edge.source === "string" ? edge.source : edge.source.id
- const tgtId =
- typeof edge.target === "string" ? edge.target : edge.target.id
- connected =
- srcId === state.selectedNodeId || tgtId === state.selectedNodeId
+ connected = selectedConnected
+ }
+ if (
+ !shouldAlwaysDrawActiveUpdate &&
+ hasDim &&
+ !connected &&
+ !shouldDrawSampledEdge(edge.id, edgeStride)
+ ) {
+ continue
+ }
+
+ const edgeDetail = applyRelationLevelOfDetail(
+ edgeStyle(edge, colors),
+ edgeType,
+ relationEdgeCount,
+ viewport.zoom,
+ hasDim && connected,
+ edgeType === "updates" && hoverConnected,
+ )
+ let style = edgeDetail.style
+ let glow = edgeDetail.glow
+ if (edgeType === "derives" && derivesStride > 1 && !activeConnected) {
+ const zoomFactor = clampNumber(
+ viewport.zoom / DERIVES_LOD_ZOOM,
+ 0.08,
+ 0.32,
+ )
+ style = {
+ ...style,
+ width: Math.max(0.35, style.width * 0.45),
+ opacity: style.opacity * zoomFactor,
+ }
+ glow = false
}
prepared.push({
@@ -163,10 +319,16 @@ function drawEdges(
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
- style: edgeStyle(edge, colors),
- edgeType: edge.edgeType ?? "derives",
+ style,
+ edgeType,
arrowSize:
- edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0,
+ edgeType === "updates"
+ ? Math.max(
+ shouldAlwaysDrawActiveUpdate ? 8 : 6,
+ (shouldAlwaysDrawActiveUpdate ? 11 : 8) * viewport.zoom,
+ )
+ : 0,
+ glow,
})
}
@@ -174,7 +336,7 @@ function drawEdges(
edgeBatches.clear()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
- const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}`
+ const key = `${e.edgeType}|${batchKey(e.style)}|${e.glow ? "g" : "f"}${dimKey}`
let batch = edgeBatches.get(key)
if (!batch) {
batch = []
@@ -190,8 +352,9 @@ function drawEdges(
const isDimmed = key.endsWith("|d")
const batchEdgeType = first.edgeType
- // Draw glow pass behind all edge types for luminous aesthetic
- if (!isDimmed) {
+ // Draw glow pass behind structural/revision edges. Cross-cluster
+ // extends edges stay flat so dense graphs do not become a mesh.
+ if (!isDimmed && first.glow && batchEdgeType !== "extends") {
const glowAlpha =
batchEdgeType === "updates"
? first.style.opacity * 0.4
@@ -204,7 +367,6 @@ function drawEdges(
ctx.globalAlpha = glowAlpha
ctx.strokeStyle = first.style.color
ctx.lineWidth = glowWidth
- if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
@@ -221,9 +383,6 @@ function drawEdges(
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width
- // Extends edges use dashed lines
- if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
-
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
@@ -231,8 +390,6 @@ function drawEdges(
}
ctx.stroke()
- if (batchEdgeType === "extends") ctx.setLineDash([])
-
// Arrowheads for updates edges
if (batchEdgeType === "updates") {
ctx.globalAlpha = isDimmed
@@ -281,12 +438,26 @@ function drawNodes(
colors: GraphThemeColors,
): void {
const margin = 60
+ const densePointMode =
+ nodes.length > DENSE_POINT_THRESHOLD &&
+ viewport.zoom < DENSE_POINT_ZOOM &&
+ !state.selectedNodeId &&
+ state.highlightIds.size === 0
+ const pointDots: {
+ x: number
+ y: number
+ r: number
+ color: string
+ }[] = []
const memDots: {
x: number
y: number
r: number
color: string
+ fillColor: string
+ haloColor: string
dimmed: boolean
+ updateChain: boolean
}[] = []
const docDots: { x: number; y: number; s: number }[] = []
@@ -316,6 +487,14 @@ function drawNodes(
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
+ } else if (densePointMode) {
+ pointDots.push({
+ x: screen.x,
+ y: screen.y,
+ r: Math.max(1.1, screenSize * 0.42),
+ color:
+ node.clusterColor || node.borderColor || colors.memStrokeDefault,
+ })
} else {
const md = node.data as MemoryNodeData
memDots.push({
@@ -323,7 +502,10 @@ function drawNodes(
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || colors.memStrokeDefault,
+ fillColor: getMemoryNodeFillColor(node, colors, false),
+ haloColor: node.clusterColor || node.borderColor || colors.glowColor,
dimmed: md.isLatest === false,
+ updateChain: isMemoryInUpdateChain(md),
})
}
continue
@@ -383,6 +565,19 @@ function drawNodes(
: 1
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
+ if (pointDots.length > 0) {
+ ctx.globalAlpha = dimAlpha * 0.78
+ for (const [color, batch] of groupByColor(pointDots)) {
+ ctx.fillStyle = color
+ ctx.beginPath()
+ for (const d of batch) {
+ ctx.moveTo(d.x + d.r, d.y)
+ ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
+ }
+ ctx.fill()
+ }
+ }
+
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
@@ -403,7 +598,10 @@ function drawNodes(
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
- for (const [color, batch] of groupByColor(normalDots)) {
+ for (const [color, batch] of groupByComputedColor(
+ normalDots,
+ (d) => d.haloColor,
+ )) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
@@ -415,13 +613,18 @@ function drawNodes(
// Filled dot
ctx.globalAlpha = dimAlpha * hlBatchMult
- ctx.fillStyle = colors.memFill
- ctx.beginPath()
- for (const d of normalDots) {
- ctx.moveTo(d.x + d.r, d.y)
- ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
+ for (const [fillColor, batch] of groupByComputedColor(
+ normalDots,
+ (d) => d.fillColor,
+ )) {
+ ctx.fillStyle = fillColor
+ ctx.beginPath()
+ for (const d of batch) {
+ ctx.moveTo(d.x + d.r, d.y)
+ ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
+ }
+ ctx.fill()
}
- ctx.fill()
// Colored border
ctx.lineWidth = 1.5
@@ -434,18 +637,37 @@ function drawNodes(
}
ctx.stroke()
}
+
+ const updateDots = normalDots.filter((d) => d.updateChain)
+ if (updateDots.length > 0) {
+ ctx.globalAlpha = dimAlpha * hlBatchMult * 0.85
+ ctx.strokeStyle = colors.edgeUpdates
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ for (const d of updateDots) {
+ const r = d.r * 1.65
+ ctx.moveTo(d.x + r, d.y)
+ ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
+ }
+ ctx.stroke()
+ }
}
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
- ctx.fillStyle = colors.memFill
- ctx.beginPath()
- for (const d of dimmedDots) {
- ctx.moveTo(d.x + d.r, d.y)
- ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
+ for (const [fillColor, batch] of groupByComputedColor(
+ dimmedDots,
+ (d) => d.fillColor,
+ )) {
+ ctx.fillStyle = fillColor
+ ctx.beginPath()
+ for (const d of batch) {
+ ctx.moveTo(d.x + d.r, d.y)
+ ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
+ }
+ ctx.fill()
}
- ctx.fill()
ctx.lineWidth = 1
for (const [color, batch] of groupByColor(dimmedDots)) {
@@ -457,6 +679,20 @@ function drawNodes(
}
ctx.stroke()
}
+
+ const updateDots = dimmedDots.filter((d) => d.updateChain)
+ if (updateDots.length > 0) {
+ ctx.globalAlpha = dimAlpha * hlBatchMult * 0.55
+ ctx.strokeStyle = colors.edgeUpdates
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ for (const d of updateDots) {
+ const r = d.r * 1.65
+ ctx.moveTo(d.x + r, d.y)
+ ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
+ }
+ ctx.stroke()
+ }
}
}
@@ -476,11 +712,12 @@ function drawDocumentNode(
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
+ const clusterColor = node.clusterColor ?? colors.docStroke
// Drop shadow for selected/hovered nodes
if (isSelected || isHovered) {
ctx.save()
- ctx.shadowColor = colors.accent
+ ctx.shadowColor = isSelected ? colors.accent : clusterColor
ctx.shadowBlur = isSelected ? 16 : 10
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
@@ -493,12 +730,16 @@ function drawDocumentNode(
sx + half,
sy + half,
)
- grad.addColorStop(0, colors.docFill)
- grad.addColorStop(1, lightenColor(colors.docFill, 0.08))
+ grad.addColorStop(0, mixHexColors(colors.docFill, clusterColor, 0.1))
+ grad.addColorStop(1, mixHexColors(colors.docFill, clusterColor, 0.22))
ctx.fillStyle = grad
ctx.strokeStyle =
- isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke
+ isSelected || isHighlighted
+ ? colors.accent
+ : isHovered
+ ? clusterColor
+ : node.borderColor || clusterColor
ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
@@ -511,14 +752,14 @@ function drawDocumentNode(
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
- ctx.fillStyle = colors.docInnerFill
+ ctx.fillStyle = mixHexColors(colors.docInnerFill, clusterColor, 0.08)
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
- drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor)
+ drawDocIcon(ctx, sx, sy, iconSize, docType || "text", clusterColor)
}
function drawMemoryNode(
@@ -535,13 +776,14 @@ function drawMemoryNode(
const memData = node.data as MemoryNodeData
const isSuperseded = memData.isLatest === false
const isForgotten = memData.isForgotten
+ const isUpdateChain = isMemoryInUpdateChain(memData)
const radius = size * 0.5
// Dim superseded (non-latest) memory nodes with strikethrough effect
if (isSuperseded && !isSelected && !isHovered) {
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = prevAlpha * 0.5
- ctx.fillStyle = colors.memFill
+ ctx.fillStyle = getMemoryNodeFillColor(node, colors, false)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
ctx.strokeStyle = node.borderColor || colors.memStrokeDefault
@@ -559,6 +801,8 @@ function drawMemoryNode(
ctx.lineWidth = 1.5
ctx.stroke()
+ drawUpdateMarker(ctx, sx, sy, radius, colors, 0.85)
+
ctx.globalAlpha = prevAlpha
return
}
@@ -573,7 +817,7 @@ function drawMemoryNode(
ctx.shadowOffsetY = 0
}
- ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill
+ ctx.fillStyle = getMemoryNodeFillColor(node, colors, isHovered)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
@@ -582,6 +826,17 @@ function drawMemoryNode(
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
+ if (isUpdateChain) {
+ drawUpdateMarker(
+ ctx,
+ sx,
+ sy,
+ radius,
+ colors,
+ isSelected || isHovered ? 1 : 0.86,
+ )
+ }
+
if (isSelected || isHovered) {
ctx.restore()
}
@@ -604,6 +859,60 @@ function drawMemoryNode(
}
}
+function isMemoryInUpdateChain(memData: MemoryNodeData): boolean {
+ if (memData.isLatest === false || memData.parentMemoryId) return true
+ if (!memData.memoryRelations) return false
+ return Object.values(memData.memoryRelations).some(
+ (relation) => relation === "updates",
+ )
+}
+
+function drawUpdateMarker(
+ ctx: CanvasRenderingContext2D,
+ sx: number,
+ sy: number,
+ radius: number,
+ colors: GraphThemeColors,
+ alpha: number,
+) {
+ const markerR = Math.max(3.5, radius * 0.22)
+ const cx = sx + radius * 0.48
+ const cy = sy - radius * 0.48
+
+ ctx.save()
+ ctx.globalAlpha *= alpha
+ ctx.fillStyle = colors.popoverBg
+ ctx.strokeStyle = colors.edgeUpdates
+ ctx.lineWidth = Math.max(1.2, radius * 0.08)
+ ctx.beginPath()
+ ctx.arc(cx, cy, markerR, 0, Math.PI * 2)
+ ctx.fill()
+ ctx.stroke()
+
+ ctx.strokeStyle = colors.edgeUpdates
+ ctx.lineCap = "round"
+ ctx.lineJoin = "round"
+ ctx.lineWidth = Math.max(1.2, radius * 0.07)
+ ctx.beginPath()
+ ctx.moveTo(cx - markerR * 0.45, cy)
+ ctx.lineTo(cx + markerR * 0.12, cy)
+ ctx.lineTo(cx - markerR * 0.06, cy - markerR * 0.2)
+ ctx.moveTo(cx + markerR * 0.12, cy)
+ ctx.lineTo(cx - markerR * 0.06, cy + markerR * 0.2)
+ ctx.stroke()
+ ctx.restore()
+}
+
+function getMemoryNodeFillColor(
+ node: GraphNode,
+ colors: GraphThemeColors,
+ isHovered: boolean,
+): string {
+ const base = isHovered ? colors.memFillHover : colors.memFill
+ if (!node.clusterColor) return base
+ return mixHexColors(base, node.clusterColor, isHovered ? 0.42 : 0.32)
+}
+
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
@@ -678,3 +987,33 @@ export function lightenColor(hex: string, amount: number): string {
_lightenCache = { input: hex, amount, result }
return result
}
+
+export function mixHexColors(
+ base: string,
+ overlay: string,
+ amount: number,
+): string {
+ const baseRgb = parseHexColor(base)
+ const overlayRgb = parseHexColor(overlay)
+ if (!baseRgb || !overlayRgb) return base
+
+ const t = clampNumber(amount, 0, 1)
+ const r = Math.round(baseRgb.r + (overlayRgb.r - baseRgb.r) * t)
+ const g = Math.round(baseRgb.g + (overlayRgb.g - baseRgb.g) * t)
+ const b = Math.round(baseRgb.b + (overlayRgb.b - baseRgb.b) * t)
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
+}
+
+function parseHexColor(hex: string) {
+ const raw = hex.startsWith("#") ? hex.slice(1) : hex
+ if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null
+ return {
+ r: Number.parseInt(raw.slice(0, 2), 16),
+ g: Number.parseInt(raw.slice(2, 4), 16),
+ b: Number.parseInt(raw.slice(4, 6), 16),
+ }
+}
+
+function toHex(value: number): string {
+ return value.toString(16).padStart(2, "0")
+}
diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts
index 44271793..b04cee27 100644
--- a/packages/memory-graph/src/canvas/simulation.ts
+++ b/packages/memory-graph/src/canvas/simulation.ts
@@ -1,7 +1,9 @@
import * as d3 from "d3-force"
-import type { GraphEdge, GraphNode } from "../types"
+import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
+export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
+
export class ForceSimulation {
private sim: d3.Simulation | null = null
@@ -27,7 +29,7 @@ export class ForceSimulation {
.id((d) => d.id)
.distance((link) =>
link.edgeType === "derives"
- ? FORCE_CONFIG.docMemoryDistance
+ ? getDocMemoryDistance(link)
: FORCE_CONFIG.linkDistance,
)
.strength((link) => {
@@ -61,8 +63,17 @@ export class ForceSimulation {
this.sim.stop()
this.sim.alpha(1)
- for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick()
- this.sim.alphaTarget(0).restart()
+ const preSettleTicks =
+ nodes.length > DENSE_GRAPH_STATIC_THRESHOLD
+ ? FORCE_CONFIG.densePreSettleTicks
+ : FORCE_CONFIG.preSettleTicks
+ for (let i = 0; i < preSettleTicks; i++) this.sim.tick()
+
+ if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
+ this.stop()
+ } else {
+ this.sim.alphaTarget(0).restart()
+ }
} catch (e) {
console.error("ForceSimulation.init failed:", e)
this.destroy()
@@ -85,6 +96,10 @@ export class ForceSimulation {
this.sim?.alphaTarget(0)
}
+ stop(): void {
+ this.sim?.alpha(0).alphaTarget(0).stop()
+ }
+
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
}
@@ -96,3 +111,24 @@ export class ForceSimulation {
}
}
}
+
+function getDocMemoryDistance(link: GraphEdge): number {
+ const source = resolveNode(link.source)
+ const target = resolveNode(link.target)
+ const docNode =
+ source?.type === "document"
+ ? source
+ : target?.type === "document"
+ ? target
+ : null
+ const memoryCount =
+ docNode != null ? (docNode.data as DocumentNodeData).memories.length : 1
+ const distance =
+ FORCE_CONFIG.docMemoryDistance +
+ Math.sqrt(Math.max(1, memoryCount)) * FORCE_CONFIG.docMemoryDistanceScale
+ return Math.min(FORCE_CONFIG.docMemoryDistanceMax, distance)
+}
+
+function resolveNode(endpoint: string | GraphNode): GraphNode | null {
+ return typeof endpoint === "string" ? null : endpoint
+}
diff --git a/packages/memory-graph/src/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts
index 6c692fa7..754bce23 100644
--- a/packages/memory-graph/src/canvas/version-chain.ts
+++ b/packages/memory-graph/src/canvas/version-chain.ts
@@ -80,13 +80,22 @@ export class VersionChainIndex {
// A single-entry chain (standalone v1 with no children) is not useful
if (all.length <= 1) return null
- const chain: ChainEntry[] = all.map((m) => ({
- id: m.id,
- version: m.version,
- memory: m.memory,
- isForgotten: m.isForgotten,
- isLatest: m.isLatest,
- }))
+ let lastVersion = 0
+ const chain: ChainEntry[] = all.map((m) => {
+ const version =
+ Number.isFinite(m.version) && m.version > lastVersion
+ ? m.version
+ : lastVersion + 1
+ lastVersion = version
+
+ return {
+ id: m.id,
+ version,
+ memory: m.memory,
+ isForgotten: m.isForgotten,
+ isLatest: m.isLatest,
+ }
+ })
for (const entry of chain) {
this.cache.set(entry.id, chain)
diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts
index b741097b..c33a1681 100644
--- a/packages/memory-graph/src/canvas/viewport.ts
+++ b/packages/memory-graph/src/canvas/viewport.ts
@@ -16,8 +16,10 @@ export class ViewportState {
private targetPanY: number | null = null
private readonly panLerp = 0.12
- private static readonly MIN_ZOOM = 0.1
+ private static readonly DEFAULT_MIN_ZOOM = 0.1
+ private static readonly ABSOLUTE_MIN_ZOOM = 0.005
private static readonly MAX_ZOOM = 5.0
+ private minZoom = ViewportState.DEFAULT_MIN_ZOOM
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX
@@ -54,22 +56,14 @@ export class ViewportState {
zoomImmediate(delta: number, anchorX: number, anchorY: number): void {
const world = this.screenToWorld(anchorX, anchorY)
- this.zoom = clamp(
- this.zoom * delta,
- ViewportState.MIN_ZOOM,
- ViewportState.MAX_ZOOM,
- )
+ this.zoom = clamp(this.zoom * delta, this.minZoom, ViewportState.MAX_ZOOM)
this.targetZoom = this.zoom
this.panX = anchorX - world.x * this.zoom
this.panY = anchorY - world.y * this.zoom
}
zoomTo(target: number, anchorX: number, anchorY: number): void {
- this.targetZoom = clamp(
- target,
- ViewportState.MIN_ZOOM,
- ViewportState.MAX_ZOOM,
- )
+ this.targetZoom = clamp(target, this.minZoom, ViewportState.MAX_ZOOM)
this.zoomAnchorX = anchorX
this.zoomAnchorY = anchorY
}
@@ -79,38 +73,40 @@ export class ViewportState {
width: number,
height: number,
): void {
- if (nodes.length === 0) return
+ const fit = computeFit(nodes, width, height)
+ if (!fit) return
- let minX = Number.POSITIVE_INFINITY
- let maxX = Number.NEGATIVE_INFINITY
- let minY = Number.POSITIVE_INFINITY
- let maxY = Number.NEGATIVE_INFINITY
+ const { cx, cy, fitZoom } = fit
- for (const n of nodes) {
- minX = Math.min(minX, n.x - n.size)
- maxX = Math.max(maxX, n.x + n.size)
- minY = Math.min(minY, n.y - n.size)
- maxY = Math.max(maxY, n.y + n.size)
- }
-
- const pad = 0.1
- const cw = (maxX - minX) * (1 + pad * 2)
- const ch = (maxY - minY) * (1 + pad * 2)
- const cx = (minX + maxX) / 2
- const cy = (minY + maxY) / 2
-
- const fitZoom = Math.min(width / cw, height / ch, 1)
- this.targetZoom = clamp(
- fitZoom,
- ViewportState.MIN_ZOOM,
- ViewportState.MAX_ZOOM,
- )
+ this.targetZoom = clamp(fitZoom, this.minZoom, ViewportState.MAX_ZOOM)
this.zoomAnchorX = width / 2
this.zoomAnchorY = height / 2
this.targetPanX = width / 2 - cx * this.targetZoom
this.targetPanY = height / 2 - cy * this.targetZoom
}
+ setMinZoomForNodes(
+ nodes: Array<{ x: number; y: number; size: number }>,
+ width: number,
+ height: number,
+ ): void {
+ const fit = computeFit(nodes, width, height)
+ const nextMinZoom = fit
+ ? Math.min(ViewportState.DEFAULT_MIN_ZOOM, fit.fitZoom)
+ : ViewportState.DEFAULT_MIN_ZOOM
+ this.minZoom = clamp(
+ nextMinZoom,
+ ViewportState.ABSOLUTE_MIN_ZOOM,
+ ViewportState.DEFAULT_MIN_ZOOM,
+ )
+ this.zoom = clamp(this.zoom, this.minZoom, ViewportState.MAX_ZOOM)
+ this.targetZoom = clamp(
+ this.targetZoom,
+ this.minZoom,
+ ViewportState.MAX_ZOOM,
+ )
+ }
+
centerOn(
worldX: number,
worldY: number,
@@ -163,6 +159,38 @@ export class ViewportState {
}
}
+function computeFit(
+ nodes: Array<{ x: number; y: number; size: number }>,
+ width: number,
+ height: number,
+): { cx: number; cy: number; fitZoom: number } | null {
+ if (nodes.length === 0 || width <= 0 || height <= 0) return null
+
+ let minX = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let minY = Number.POSITIVE_INFINITY
+ let maxY = Number.NEGATIVE_INFINITY
+
+ for (const n of nodes) {
+ minX = Math.min(minX, n.x - n.size)
+ maxX = Math.max(maxX, n.x + n.size)
+ minY = Math.min(minY, n.y - n.size)
+ maxY = Math.max(maxY, n.y + n.size)
+ }
+
+ const pad = 0.1
+ const cw = Math.max((maxX - minX) * (1 + pad * 2), 1)
+ const ch = Math.max((maxY - minY) * (1 + pad * 2), 1)
+ const cx = (minX + maxX) / 2
+ const cy = (minY + maxY) / 2
+
+ return {
+ cx,
+ cy,
+ fitZoom: Math.min(width / cw, height / ch, 1),
+ }
+}
+
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v
}
diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx
index bd6b2f3a..32c520af 100644
--- a/packages/memory-graph/src/components/legend.tsx
+++ b/packages/memory-graph/src/components/legend.tsx
@@ -6,6 +6,7 @@ interface LegendProps {
edges?: GraphEdge[]
isLoading?: boolean
colors: GraphThemeColors
+ hoveredNode?: string | null
compact?: boolean
maxHeight?: number
}
@@ -40,32 +41,100 @@ function HexagonIcon({
function LineIcon({
color,
dashed = false,
+ arrow = false,
}: {
color: string
dashed?: boolean
+ arrow?: boolean
}) {
return (
-
-
+ {arrow && (
+
+ )}
+
+ )
+}
+
+function ClusterSwatches() {
+ const swatches = ["#58C7E8", "#E7BC52", "#74D680", "#D47B75", "#A789E8"]
+ return (
+
+ {swatches.map((color) => (
+
+ ))}
)
}
+function UpdateMarkerIcon({ colors }: { colors: GraphThemeColors }) {
+ return (
+
+ )
+}
+
function ChevronDownIcon({ color }: { color: string }) {
return (