supermemory/packages/memory-graph/src/components/node-hover-popover.tsx
MaheshtheDev b0e5766a65
Some checks failed
Publish Memory Graph / publish (push) Has been cancelled
feat: make graph popover content scrollable and add View full button (#920)
## Summary

Addresses user feedback about the graph view: content in node popovers was hard-truncated at 100 characters with no way to read the full text or navigate to the document.

### Changes

**Scrollable popover content (no more truncation)**
- Removed the `truncate(content, 100)` call -- full content is now rendered in the popover
- Made the content area scrollable with `maxHeight: 100px` and `overflowY: auto`
- Increased version timeline entry truncation from 60 to 120 characters
- Increased version timeline container height from 120px to 160px

**"View document" in shortcuts panel**
- Added an eye icon + "View document" entry to the shortcuts panel (alongside existing arrow navigation)
- Clicking opens the Document Modal for the corresponding document
- Works for both document nodes (opens that document) and memory nodes (opens the parent document)
- Popover is automatically dismissed before the modal opens (prevents z-index overlap)
- Wired `onOpenDocument` callback through the full component chain: `page.tsx` -> `GraphLayoutView` -> `MemoryGraph` wrapper -> `MemoryGraphBase` -> `NodeHoverPopover`

**Shortcuts panel visual cleanup**
- Icon badges (`KeyBadge`) retain dark background and border for a keyboard-key badge look
- All icons (including the new EyeIcon) are uniformly wrapped in `KeyBadge` for consistent styling
- Removed background, border, and border-radius from the shortcuts panel container itself
- Widened shortcuts panel from 100px to 160px so navigation labels are fully visible

**Type safety improvement**
- Changed `MemoryGraphProps.colors` from `GraphThemeColors` to `Partial<GraphThemeColors>` to match actual usage (callers pass partial overrides that get merged with defaults)
- Replaced `as Partial<typeof import(...)>` cast with proper `satisfies Partial<GraphThemeColors>` using a direct type import

### Files changed
- `packages/memory-graph/src/components/node-hover-popover.tsx` -- scrollable content, EyeIcon + "View document" in shortcuts, KeyBadge styling, NavButton icon type broadened to `React.ReactNode`, all icons wrapped in KeyBadge
- `packages/memory-graph/src/components/memory-graph.tsx` -- popover dismissal wrapper (`handleOpenDocument`), prop forwarding
- `packages/memory-graph/src/types.ts` -- `onOpenDocument` prop on `MemoryGraphProps`, `colors` changed to `Partial<GraphThemeColors>`
- `apps/web/components/graph-layout-view.tsx` -- accepts and passes `onOpenDocument`
- `apps/web/components/memory-graph/memory-graph-wrapper.tsx` -- `onOpenDocument` in wrapper props, clean type import
- `apps/web/app/(app)/page.tsx` -- `handleOpenDocumentById` handler, wired to `GraphLayoutView`

## Testing

### Unit Tests (154/154 passed)
- **Command:** `cd packages/memory-graph && bun run test`
- **Result:** 9 test files, 154 tests passed (107 existing + 47 new)
- **New test file:** `packages/memory-graph/src/__tests__/node-hover-popover.test.tsx` covering:
  - Layout constants (`SHORTCUTS_W=160`, `CARD_W=280`, `TOTAL_W` formula)
  - Content area scrollability (`maxHeight:100`, `overflowY:auto`, `flex:1 1 auto`, no truncation)
  - KeyBadge styles (has `backgroundColor: colors.controlBg`, has `border` with `colors.controlBorder`)
  - Shortcuts panel styles (no `backgroundColor`, no `border`, no `borderRadius`)
  - EyeIcon SVG component (`aria-hidden`, viewBox, path, circle, stroke)
  - "View document" button render guard (`onOpenDocument && documentId`)
  - `documentId` derivation (memory vs document node)
  - VersionTimeline truncation limit (120 chars) and container maxHeight (160)
  - `onOpenDocument` prop wiring
  - NavButton icon prop type (`React.ReactNode`), all icons wrapped in KeyBadge
  - `handleOpenDocument` wrapper in MemoryGraph (dismiss calls, ordering, useCallback deps)

### Build
- **Command:** `bun run --filter=@supermemory/memory-graph build`
- **Result:** Build succeeds, 0 errors

### Lint/Format
- **Command:** `bunx biome ci --changed --since=origin/main --no-errors-on-unmatched`
- **Result:** 6 files checked, no fixes applied, 0 errors

### Type Checking
- `packages/memory-graph`: 0 errors
- `apps/web`: 0 new errors introduced

### CI
- "CI - Type Check, Format & Lint" workflow passes
- "Claude Code Review" workflow fails due to repo config (`non-human actor: vorflux` not in `allowed_bots` list) -- not related to code changes

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/d0c067f1-38c1-40c5-ada9-b2ffb7331d1c)
- Requested by: Mahesh Sanikommu (mahesh@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
2026-05-11 02:09:36 +00:00

670 lines
15 KiB
TypeScript

import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import type {
DocumentNodeData,
GraphNode,
GraphThemeColors,
MemoryNodeData,
} from "../types"
export interface NodeHoverPopoverProps {
node: GraphNode
screenX: number
screenY: number
nodeRadius: number
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
colors: GraphThemeColors
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
onOpenDocument?: (documentId: string) => void
}
function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
const copy = useCallback(
(value: string) => {
navigator.clipboard.writeText(value).then(
() => {
setCopied(true)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => setCopied(false), timeout)
},
() => {},
)
},
[timeout],
)
return { copied, copy }
}
function KeyBadge({
children,
colors,
}: {
children: React.ReactNode
colors: GraphThemeColors
}) {
const style: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 16,
height: 16,
borderRadius: 4,
fontSize: 10,
fontWeight: 500,
color: colors.popoverTextMuted,
lineHeight: 1,
backgroundColor: colors.controlBg,
padding: 2,
border: `1px solid ${colors.controlBorder}`,
boxShadow: "0 1px 2px rgba(0,0,0,0.12)",
}
return <span style={style}>{children}</span>
}
function EyeIcon({ color }: { color: string }) {
return (
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function NavButton({
icon,
label,
onClick,
colors,
}: {
icon: React.ReactNode
label: string
onClick?: () => void
colors: GraphThemeColors
}) {
const buttonStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
background: "none",
border: "none",
padding: "2px 0",
transition: "opacity 0.15s",
}
const labelStyle: React.CSSProperties = {
fontSize: 11,
whiteSpace: "nowrap",
color: colors.popoverTextSecondary,
}
return (
<button
onClick={onClick}
style={buttonStyle}
type="button"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<KeyBadge colors={colors}>{icon}</KeyBadge>
<span style={labelStyle}>{label}</span>
</button>
)
}
function CopyableId({
label,
value,
colors,
}: {
label: string
value: string
colors: GraphThemeColors
}) {
const { copied, copy } = useCopyToClipboard()
const short =
value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value
const buttonStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
}
const labelStyle: React.CSSProperties = {
fontSize: 10,
color: colors.popoverTextSecondary,
}
const valueStyle: React.CSSProperties = {
fontSize: 10,
fontFamily: "monospace",
color: colors.popoverTextMuted,
transition: "color 0.15s",
}
return (
<button onClick={() => copy(value)} style={buttonStyle} type="button">
<span style={labelStyle}>{label}</span>
<span style={valueStyle}>{copied ? "Copied!" : short}</span>
</button>
)
}
type Quadrant = "right" | "left" | "above" | "below"
function pickBestQuadrant(
screenX: number,
screenY: number,
nodeRadius: number,
containerWidth: number,
containerHeight: number,
popoverWidth: number,
popoverHeight: number,
): Quadrant {
const gap = 24
const spaceRight = containerWidth - (screenX + nodeRadius + gap)
const spaceLeft = screenX - nodeRadius - gap
const spaceAbove = screenY - nodeRadius - gap
const spaceBelow = containerHeight - (screenY + nodeRadius + gap)
const fits: [Quadrant, number][] = [
["right", spaceRight >= popoverWidth ? spaceRight : -1],
["left", spaceLeft >= popoverWidth ? spaceLeft : -1],
["above", spaceAbove >= popoverHeight ? spaceAbove : -1],
["below", spaceBelow >= popoverHeight ? spaceBelow : -1],
]
const preferred: Quadrant[] = ["right", "left", "below", "above"]
for (const q of preferred) {
const entry = fits.find(([dir]) => dir === q)
if (entry && entry[1] > 0) return q
}
return fits.sort((a, b) => b[1] - a[1])[0]?.[0] ?? "right"
}
function truncate(s: string, max: number) {
return s.length > max ? `${s.substring(0, max)}...` : s
}
function VersionTimeline({
chain,
currentId,
onSelect,
colors,
}: {
chain: ChainEntry[]
currentId: string
onSelect?: (id: string) => void
colors: GraphThemeColors
}) {
const containerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 0,
maxHeight: 160,
overflowY: "auto",
}
return (
<div style={containerStyle}>
{chain.map((entry) => {
const isCurrent = entry.id === currentId
const entryStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 8,
paddingLeft: 12,
paddingRight: 12,
paddingTop: 6,
paddingBottom: 6,
textAlign: "left",
cursor: "pointer",
transition: "background-color 0.15s",
background: isCurrent ? `${colors.accent}15` : "transparent",
border: "none",
borderLeft: isCurrent
? `2px solid ${colors.accent}`
: `2px solid ${colors.popoverBorder}`,
}
const versionStyle: React.CSSProperties = {
fontSize: 10,
fontWeight: 600,
flexShrink: 0,
marginTop: 1,
color: entry.isForgotten
? colors.memBorderForgotten
: isCurrent
? colors.accent
: colors.popoverTextSecondary,
}
const textStyle: React.CSSProperties = {
fontSize: 11,
lineHeight: 1.35,
color: isCurrent
? colors.popoverTextPrimary
: colors.popoverTextMuted,
}
return (
<button
key={entry.id}
onClick={() => onSelect?.(entry.id)}
style={entryStyle}
type="button"
>
<span style={versionStyle}>v{entry.version}</span>
<span style={textStyle}>{truncate(entry.memory, 120)}</span>
</button>
)
})}
</div>
)
}
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
function NodeHoverPopover({
node,
screenX,
screenY,
nodeRadius,
containerBounds,
versionChain,
colors,
onNavigateNext,
onNavigatePrev,
onNavigateUp,
onNavigateDown,
onSelectNode,
onOpenDocument,
}) {
const CARD_W = 280
const SHORTCUTS_W = 160
const GAP = 24
const TOTAL_W = CARD_W + 12 + SHORTCUTS_W
const isMemory = node.type === "memory"
const data = node.data
const memoryMeta = useMemo(() => {
if (!isMemory) return null
const md = data as MemoryNodeData
return {
version: md.version ?? 1,
isLatest: md.isLatest ?? false,
isForgotten: md.isForgotten ?? false,
forgetReason: md.forgetReason ?? null,
forgetAfter: md.forgetAfter ?? null,
}
}, [isMemory, data])
const hasChain = versionChain && versionChain.length > 1
const hasForgetInfo =
memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter)
const CARD_H = hasChain ? 230 : hasForgetInfo ? 190 : 170
const TOTAL_H = CARD_H
const { popoverX, popoverY, connectorPath } = useMemo(() => {
const cw = containerBounds?.width ?? 800
const ch = containerBounds?.height ?? 600
const quadrant = pickBestQuadrant(
screenX,
screenY,
nodeRadius,
cw,
ch,
TOTAL_W + GAP,
TOTAL_H,
)
let px: number
let py: number
let connStart: { x: number; y: number }
switch (quadrant) {
case "right":
px = screenX + nodeRadius + GAP
py = screenY - TOTAL_H / 2
connStart = { x: screenX + nodeRadius, y: screenY }
break
case "left":
px = screenX - nodeRadius - GAP - TOTAL_W
py = screenY - TOTAL_H / 2
connStart = { x: screenX - nodeRadius, y: screenY }
break
case "below":
px = screenX - TOTAL_W / 2
py = screenY + nodeRadius + GAP
connStart = { x: screenX, y: screenY + nodeRadius }
break
case "above":
px = screenX - TOTAL_W / 2
py = screenY - nodeRadius - GAP - TOTAL_H
connStart = { x: screenX, y: screenY - nodeRadius }
break
}
px = Math.max(8, Math.min(cw - TOTAL_W - 8, px))
py = Math.max(8, Math.min(ch - TOTAL_H - 8, py))
const cardCenterX = px + CARD_W / 2
const cardCenterY = py + TOTAL_H / 2
const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}`
return { popoverX: px, popoverY: py, connectorPath: path }
}, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H])
const content = useMemo(() => {
if (isMemory) {
const md = data as MemoryNodeData
return md.memory || md.content || ""
}
const dd = data as DocumentNodeData
return dd.summary || dd.title || ""
}, [isMemory, data])
const docData = !isMemory ? (data as DocumentNodeData) : null
// For document nodes, node.id IS the document ID
const documentId = isMemory ? (data as MemoryNodeData).documentId : node.id
const overlayStyle: React.CSSProperties = {
pointerEvents: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 100,
}
const svgStyle: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
overflow: "visible",
pointerEvents: "none",
}
const popoverContainerStyle: React.CSSProperties = {
position: "absolute",
display: "flex",
gap: 12,
pointerEvents: "auto",
left: popoverX,
top: popoverY,
}
const cardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
borderRadius: 12,
overflow: "hidden",
border: `1px solid ${colors.popoverBorder}`,
boxShadow:
"0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)",
width: CARD_W,
backgroundColor: colors.popoverBg,
}
const contentPadStyle: React.CSSProperties = {
padding: 12,
maxHeight: 100,
overflowY: "auto",
flex: "1 1 auto",
}
const contentTextStyle: React.CSSProperties = {
margin: 0,
lineHeight: "135%",
fontSize: 12,
color: colors.popoverTextSecondary,
}
const dividerStyle: React.CSSProperties = {
borderTop: `1px solid ${colors.popoverBorder}`,
}
const footerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: 12,
paddingRight: 12,
paddingTop: 8,
paddingBottom: 8,
...dividerStyle,
}
const idRowStyle: React.CSSProperties = {
paddingLeft: 12,
paddingRight: 12,
paddingTop: 6,
paddingBottom: 6,
display: "flex",
alignItems: "center",
...dividerStyle,
}
const shortcutsPanelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 6,
paddingLeft: 12,
paddingRight: 12,
paddingTop: 8,
paddingBottom: 8,
}
return (
<div style={overlayStyle}>
<svg aria-hidden="true" style={svgStyle}>
<path
d={connectorPath}
fill="none"
stroke={colors.accent}
strokeDasharray="4 2"
strokeWidth="1.5"
/>
</svg>
<div style={popoverContainerStyle}>
<div style={cardStyle}>
{hasChain ? (
<VersionTimeline
chain={versionChain}
colors={colors}
currentId={node.id}
onSelect={onSelectNode}
/>
) : (
<div style={contentPadStyle}>
<p style={contentTextStyle}>{content || "No content"}</p>
</div>
)}
{memoryMeta && hasForgetInfo && (
<div
style={{
paddingLeft: 12,
paddingRight: 12,
paddingTop: 6,
paddingBottom: 6,
display: "flex",
flexDirection: "column",
gap: 2,
...dividerStyle,
}}
>
{memoryMeta.forgetAfter && (
<span
style={{
fontSize: 10,
color: colors.memBorderExpiring,
}}
>
Expires:{" "}
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
</span>
)}
{memoryMeta.forgetReason && (
<span
style={{
fontSize: 10,
color: colors.popoverTextMuted,
}}
>
Reason: {memoryMeta.forgetReason}
</span>
)}
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
<span
style={{
fontSize: 10,
color: colors.memBorderForgotten,
}}
>
Forgotten
</span>
)}
</div>
)}
<div style={footerStyle}>
{memoryMeta ? (
<span
style={{
fontSize: 12,
fontWeight: 500,
color: memoryMeta.isForgotten
? colors.memBorderForgotten
: memoryMeta.isLatest
? colors.memBorderRecent
: colors.popoverTextSecondary,
}}
>
v{memoryMeta.version}{" "}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest
? "Latest"
: "Superseded"}
</span>
) : (
<>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{docData?.type || "document"}
</span>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{docData?.memories?.length ?? 0} memories
</span>
</>
)}
</div>
<div style={idRowStyle}>
{isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
)}
</div>
</div>
<div style={shortcutsPanelStyle}>
{onOpenDocument && documentId && (
<NavButton
colors={colors}
icon={<EyeIcon color={colors.popoverTextMuted} />}
label="View document"
onClick={() => onOpenDocument(documentId)}
/>
)}
{isMemory && (
<NavButton
colors={colors}
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : true) && (
<NavButton
colors={colors}
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
onClick={onNavigateDown}
/>
)}
<NavButton
colors={colors}
icon="→"
label={isMemory ? "Next memory" : "Next document"}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
onClick={onNavigatePrev}
/>
</div>
</div>
</div>
)
},
)