supermemory/apps/web/components/review-memories-modal.tsx
sohamd22 8c35c1ecad review suggestions option for inferred memories (#1138)
### TL;DR

Adds a swipeable "Review suggestions" card to the dashboard that lets users approve or decline inferred memories surfaced by Nova.

### What changed?

- Added a `ReviewMemoriesCard` component that appears in the "Suggested for you" section of the dashboard (both desktop and mobile layouts). The card is hidden when there are no pending inferred memories, so it never renders empty chrome. While the modal is open, the displayed count is frozen so the trigger button doesn't tick down or disappear mid-session. Switching spaces closes the modal automatically.
- Added a `ReviewMemoriesModal` component that presents inferred memories as a swipeable card deck. Users can approve (swipe right / ✓), decline (swipe left / ✗), or skip each memory. The modal includes:
  - Drag-to-swipe with a full-card color wash (green for keep, red for decline) and verdict pills that intensify as the swipe threshold approaches
  - Keyboard support: `→` to approve, `←` to decline, `↓` or `Space` to skip, and `Cmd/Ctrl+Z` to undo
  - An undo button that steps back one card and reverts the server-side decision, using refs to avoid stale-state bugs during rapid interactions
  - A progress dot indicator showing position in the queue alongside a numeric counter
  - A "All caught up" completion state summarising how many memories were kept
  - Reduced-motion support via `useReducedMotion`
  - The card queue is snapshotted when the modal opens so cache updates from review mutations don't reshuffle the stack mid-session
- Added a `useInferredMemories` hook to fetch the pending review queue for a given container tag, and a `useReviewInferredMemory` mutation hook that calls the review endpoint. On success it removes the reviewed entry from the cached queue directly; on undo it invalidates the query to refetch the restored memory from the server.
- Registered two new API schema entries: `GET /container-tags/:containerTag/inferred` to fetch the pending queue and `POST /container-tags/:containerTag/inferred/:memoryId/review` to submit an approve, decline, or undo action.

### How to test?

1. Ensure there are inferred memories pending review for a container tag.
2. Open the dashboard — a "Review suggestions" card should appear in the "Suggested for you" section showing the count of pending memories.
3. Click the card to open the modal and swipe or use the buttons/keyboard to approve, decline, or skip memories.
4. Verify that approved and declined memories are removed from the queue after each decision and that the completion state appears once all cards are reviewed.
5. Use the undo button or `Cmd/Ctrl+Z` to step back through decisions and confirm the server-side state is reverted correctly.
6. Confirm the card does not render when there are zero pending inferred memories.
7. Switch spaces while the modal is open and confirm it closes without carrying state into the new space.

### Why make this change?

Nova infers memories on behalf of users but may not always be fully confident in them. This feature gives users a lightweight, low-friction way to review and curate those suggestions directly from the dashboard, improving the quality and trustworthiness of their memory store.
2026-07-04 20:57:33 +00:00

477 lines
15 KiB
TypeScript

"use client"
import { dmSansClassName } from "@/lib/fonts"
import {
type InferredMemory,
useInferredMemories,
useInferredMemoryCache,
useReviewInferredMemory,
} from "@/hooks/use-inferred-memories"
import { cn } from "@lib/utils"
import { Dialog, DialogContent } from "@ui/components/dialog"
import { Check, Undo2, X } from "lucide-react"
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react"
import { useCallback, useEffect, useRef, useState } from "react"
// Distance / velocity past which a drag commits to a decision.
const SWIPE_OFFSET = 120
const SWIPE_VELOCITY = 600
const STACK_DEPTH = 3 // how many cards are visible at once
type Decision = "approve" | "decline" | "skip"
export function ReviewMemoriesModal({
open,
onOpenChange,
containerTag,
}: {
open: boolean
onOpenChange: (open: boolean) => void
containerTag: string | undefined
}) {
const { data: memories = [] } = useInferredMemories(containerTag)
const { mutate: review } = useReviewInferredMemory(containerTag)
const queue = useInferredMemoryCache(containerTag)
const reduceMotion = useReducedMotion()
// Snapshot the queue when the modal opens so cache updates from the
// mutation don't reshuffle the stack mid-session.
const [cards, setCards] = useState<InferredMemory[]>([])
const [index, setIndex] = useState(0)
const [exitDir, setExitDir] = useState(1)
const [approved, setApproved] = useState(0)
const [history, setHistory] = useState<{ id: string; decision: Decision }[]>(
[],
)
// Synchronous source of truth so rapid swipes/undos don't read stale state.
const indexRef = useRef(0)
const historyRef = useRef<{ id: string; decision: Decision }[]>([])
// Intentionally keyed on `open` only: including `memories` would reset the
// stack every time the review mutation trims the cache. The queue is loaded
// by the time the trigger renders, so a snapshot on open is sufficient.
// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot-on-open
useEffect(() => {
if (!open) return
setCards(memories)
setIndex(0)
indexRef.current = 0
setApproved(0)
setHistory([])
historyRef.current = []
}, [open])
const current = cards[index]
const done = cards.length > 0 && index >= cards.length
const decide = useCallback(
(decision: Decision) => {
const card = cards[indexRef.current]
if (!card) return
// 1 = fly right (approve), -1 = fly left (decline), 0 = drop down (skip)
setExitDir(decision === "approve" ? 1 : decision === "decline" ? -1 : 0)
if (decision === "approve") {
setApproved((n) => n + 1)
review({ memoryId: card.id, action: "approve" })
} else if (decision === "decline") {
review({ memoryId: card.id, action: "decline" })
} else {
// Skip persists nothing server-side, but drop it from the cached queue
// so the trigger's live count falls and the prompt can be dismissed.
queue.drop(card.id)
}
historyRef.current = [...historyRef.current, { id: card.id, decision }]
setHistory(historyRef.current)
indexRef.current += 1
setIndex(indexRef.current)
},
[cards, review, queue.drop],
)
const canUndo = history.length > 0
// Step back one card and revert its server-side review (skip had none).
// Reads from refs so consecutive/rapid undos each target the correct card.
const undo = useCallback(() => {
const last = historyRef.current[historyRef.current.length - 1]
if (!last) return
historyRef.current = historyRef.current.slice(0, -1)
setHistory(historyRef.current)
if (last.decision === "approve") {
setApproved((n) => Math.max(0, n - 1))
review({ memoryId: last.id, action: "undo" })
} else if (last.decision === "decline") {
review({ memoryId: last.id, action: "undo" })
} else {
const card = cards.find((c) => c.id === last.id)
if (card) queue.restore(card)
}
indexRef.current = Math.max(0, indexRef.current - 1)
setIndex(indexRef.current)
}, [review, cards, queue.restore])
// Keyboard: ← decline, → approve, ↓/space skip.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault()
undo()
return
}
if (done || !current) return
if (e.key === "ArrowRight") decide("approve")
else if (e.key === "ArrowLeft") decide("decline")
else if (e.key === "ArrowDown" || e.key === " ") {
e.preventDefault()
decide("skip")
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, done, current, decide, undo])
const visible = cards.slice(index, index + STACK_DEPTH)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"border-surface-border bg-surface-base p-0 gap-0 overflow-hidden sm:max-w-[420px] rounded-[22px]",
dmSansClassName(),
)}
showCloseButton={false}
>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4">
<div>
<p className="text-[15px] font-semibold leading-tight text-fg-primary">
Suggested memories
</p>
<p className="mt-1 text-[12px] leading-tight text-fg-faint">
Memories we inferred keep the ones that fit
</p>
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="-mr-1 -mt-1 rounded-full p-1.5 text-fg-faint transition-colors hover:bg-white/[0.06] hover:text-fg-muted"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{/* Progress dots + undo + counter */}
{cards.length > 0 && (
<div className="flex items-center justify-between px-6 pb-1">
<div className="flex flex-wrap items-center gap-1.5">
{cards.map((c, i) => (
<span
key={c.id}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
i === index
? "w-5 bg-brand-accent"
: i < index
? "w-1.5 bg-brand-accent/40"
: "w-1.5 bg-white/15",
)}
/>
))}
</div>
<div className="flex shrink-0 items-center gap-2.5 pl-3">
{canUndo && (
<button
type="button"
onClick={undo}
className="flex items-center gap-1 text-[11px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
>
<Undo2 className="size-3.5" />
Undo
</button>
)}
<span className="text-[11px] tabular-nums text-fg-faint">
{Math.min(index + 1, cards.length)}/{cards.length}
</span>
</div>
</div>
)}
{/* Card deck */}
<div className="relative px-6 pt-4 pb-3">
<div className="relative h-[340px]">
{/* Nova glow halo behind the deck */}
{!done && (
<div
aria-hidden
className="pointer-events-none absolute -inset-3 rounded-[28px] opacity-70 blur-2xl"
style={{
background:
"radial-gradient(58% 52% at 50% 42%, rgba(75,160,250,0.45), rgba(54,216,196,0.16) 58%, transparent 76%)",
}}
/>
)}
{done ? (
<DoneState approved={approved} total={cards.length} />
) : (
<AnimatePresence custom={exitDir} initial={false}>
{visible.map((mem, i) => (
<SwipeCard
key={mem.id}
memory={mem}
depth={i}
globalIndex={index + i}
isTop={i === 0}
reduceMotion={!!reduceMotion}
onDecide={decide}
/>
))}
</AnimatePresence>
)}
</div>
</div>
{/* Controls */}
{!done && current && (
<div className="flex items-center justify-center gap-3 px-5 pb-6 pt-3">
<motion.button
type="button"
aria-label="Decline"
onClick={() => decide("decline")}
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.9 }}
transition={{ duration: 0.15 }}
className="flex size-14 items-center justify-center rounded-full border border-[#ff6b6b]/30 bg-[#ff6b6b]/10 text-[#ff6b6b] transition-colors hover:bg-[#ff6b6b]/20"
>
<X className="size-6" />
</motion.button>
<motion.button
type="button"
onClick={() => decide("skip")}
whileTap={{ scale: 0.94 }}
transition={{ duration: 0.15 }}
className="px-4 text-[13px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
>
Skip
</motion.button>
<motion.button
type="button"
aria-label="Keep"
onClick={() => decide("approve")}
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.9 }}
transition={{ duration: 0.15 }}
className="flex size-14 items-center justify-center rounded-full border border-[#4ade80]/30 bg-[#4ade80]/10 text-[#4ade80] transition-colors hover:bg-[#4ade80]/20"
>
<Check className="size-6" />
</motion.button>
</div>
)}
</DialogContent>
</Dialog>
)
}
function SwipeCard({
memory,
depth,
globalIndex,
isTop,
reduceMotion,
onDecide,
}: {
memory: InferredMemory
depth: number
globalIndex: number
isTop: boolean
reduceMotion: boolean
onDecide: (d: Decision) => void
}) {
const x = useMotionValue(0)
const rotate = useTransform(x, [-200, 200], reduceMotion ? [0, 0] : [-8, 8])
// Slack-style: the whole card washes green (keep) / red (decline) and the
// border + verdict pill intensify the closer you get to committing.
const wash = useTransform(
x,
[-150, -30, 0, 30, 150],
[
"rgba(239,68,68,0.34)",
"rgba(239,68,68,0.04)",
"rgba(255,255,255,0)",
"rgba(74,222,128,0.04)",
"rgba(74,222,128,0.34)",
],
)
const borderColor = useTransform(
x,
[-150, -30, 0, 30, 150],
[
"rgba(239,68,68,0.7)",
"rgba(255,255,255,0.08)",
"rgba(255,255,255,0.08)",
"rgba(255,255,255,0.08)",
"rgba(74,222,128,0.7)",
],
)
const keepOpacity = useTransform(x, [30, 120], [0, 1])
const keepScale = useTransform(x, [30, 120], [0.7, 1])
const declineOpacity = useTransform(x, [-120, -30], [1, 0])
const declineScale = useTransform(x, [-120, -30], [1, 0.7])
const scale = 1 - depth * 0.055
const y = depth * 16
return (
<motion.div
className="absolute inset-0"
style={{
x: isTop ? x : 0,
rotate: isTop ? rotate : 0,
// Absolute position keeps the exiting card above the one rising behind.
zIndex: 999 - globalIndex,
}}
variants={{
exit: (dir: number) => {
if (reduceMotion)
return { opacity: 0, transition: { duration: 0.25 } }
// dir 0 = skip → drop straight down; ±1 = fly out sideways.
if (dir === 0)
return {
y: 480,
opacity: 0,
scale: 0.9,
transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] },
}
return {
x: dir * 540,
opacity: 0,
rotate: dir * 12,
transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] },
}
},
}}
exit="exit"
initial={{ scale: scale - 0.04, y: y + 10, opacity: 0 }}
animate={{
scale,
y,
opacity: depth === 0 ? 1 : depth === 1 ? 0.8 : 0.45,
}}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
drag={isTop ? "x" : false}
dragSnapToOrigin
dragElastic={0.7}
whileDrag={{ scale: 1.02 }}
onDragEnd={(_e, info) => {
if (!isTop) return
const { offset, velocity } = info
if (offset.x > SWIPE_OFFSET || velocity.x > SWIPE_VELOCITY)
onDecide("approve")
else if (offset.x < -SWIPE_OFFSET || velocity.x < -SWIPE_VELOCITY)
onDecide("decline")
}}
>
<motion.div
style={isTop ? { borderColor } : undefined}
className={cn(
"relative h-full overflow-hidden rounded-[20px] border border-white/[0.08] bg-surface-card shadow-[0_20px_50px_-12px_rgba(0,0,0,0.55)]",
isTop && "cursor-grab active:cursor-grabbing",
)}
>
{/* Full-card color wash that tracks the swipe */}
{isTop && (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ backgroundColor: wash }}
/>
)}
{/* Verdict pills — slide/scale in toward the swipe direction */}
{isTop && (
<>
<motion.div
style={{ opacity: keepOpacity, scale: keepScale }}
className="pointer-events-none absolute right-4 top-4 flex items-center gap-1.5 rounded-full bg-[#4ade80] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#06230f] shadow-lg"
>
<Check className="size-3.5" /> Keep
</motion.div>
<motion.div
style={{ opacity: declineOpacity, scale: declineScale }}
className="pointer-events-none absolute left-4 top-4 flex items-center gap-1.5 rounded-full bg-[#ff6b6b] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#2a0808] shadow-lg"
>
<X className="size-3.5" /> Decline
</motion.div>
</>
)}
<div className="relative flex h-full flex-col p-6">
<div className="flex shrink-0 items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-brand-accent/85">
<span className="size-1.5 rounded-full bg-brand-accent shadow-[0_0_8px_rgba(75,160,250,0.8)]" />
Inferred
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
<p className="text-[19px] font-medium leading-[1.32] tracking-[-0.01em] text-fg-primary">
{memory.memory}
</p>
</div>
<div className="mt-4 flex shrink-0 items-center gap-2 text-[11px] text-fg-faint">
{memory.parentCount > 0 && (
<span className="rounded-full bg-white/[0.04] px-2 py-0.5 ring-1 ring-white/[0.06]">
{memory.parentCount === 1
? "from 1 memory"
: `from ${memory.parentCount} memories`}
</span>
)}
<span>{relativeTime(memory.createdAt)}</span>
</div>
</div>
</motion.div>
</motion.div>
)
}
function DoneState({ approved, total }: { approved: number; total: number }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="flex h-full flex-col items-center justify-center gap-3 text-center"
>
<span className="flex size-14 items-center justify-center rounded-full bg-[#4ade801a] ring-1 ring-[#4ade8055]">
<Check className="size-7 text-[#4ade80]" />
</span>
<div>
<p className="text-base font-semibold text-fg-primary">All caught up</p>
<p className="mt-1 text-[13px] text-fg-faint">
{approved > 0
? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.`
: "Nothing kept this time — they'll stay tucked away."}
</p>
</div>
</motion.div>
)
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ""
const diff = Date.now() - then
const day = 86_400_000
if (diff < day) return "today"
if (diff < 2 * day) return "yesterday"
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`
if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago`
return `${Math.floor(diff / (30 * day))}mo ago`
}