From 8c35c1ecad208e375aed8566fe4dc3b78dbfaf90 Mon Sep 17 00:00:00 2001 From: sohamd22 <85427822+sohamd22@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:57:33 +0000 Subject: [PATCH] review suggestions option for inferred memories (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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. --- apps/web/components/dashboard-view.tsx | 8 +- apps/web/components/review-memories-card.tsx | 79 +++ apps/web/components/review-memories-modal.tsx | 477 ++++++++++++++++++ apps/web/hooks/use-inferred-memories.ts | 109 ++++ packages/lib/api.ts | 28 + 5 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/review-memories-card.tsx create mode 100644 apps/web/components/review-memories-modal.tsx create mode 100644 apps/web/hooks/use-inferred-memories.ts diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index b24e93fa..37355282 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -43,6 +43,7 @@ import { import { normalizePluginClientId } from "@/lib/plugin-catalog" import { detectPluginSpace } from "@/lib/plugin-space" import { useDigests } from "@/hooks/use-digests" +import { ReviewMemoriesCard } from "@/components/review-memories-card" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -1181,6 +1182,7 @@ export function DashboardView({ }) { const { user, org } = useAuth() const { effectiveContainerTags } = useProject() + const primaryContainerTag = effectiveContainerTags?.[0] const _router = useRouter() const { data: recentsData, isPending: isRecentsLoading } = useQuery({ queryKey: ["dashboard-recents", effectiveContainerTags], @@ -1600,7 +1602,8 @@ export function DashboardView({ )} -
+
+ Suggested for you

-
+
+ { + if (next) setFrozenCount(liveCount) + setOpen(next) + } + + // Switching spaces shouldn't carry an open modal over to the new space. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on space switch + useEffect(() => { + setOpen(false) + }, [containerTag]) + + return ( + <> + {count > 0 && ( + + )} + + {(open || liveCount > 0) && ( + + )} + + ) +} diff --git a/apps/web/components/review-memories-modal.tsx b/apps/web/components/review-memories-modal.tsx new file mode 100644 index 00000000..37a36fec --- /dev/null +++ b/apps/web/components/review-memories-modal.tsx @@ -0,0 +1,477 @@ +"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([]) + 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 ( + + + {/* Header */} +
+
+

+ Suggested memories +

+

+ Memories we inferred — keep the ones that fit +

+
+ +
+ + {/* Progress dots + undo + counter */} + {cards.length > 0 && ( +
+
+ {cards.map((c, i) => ( + + ))} +
+
+ {canUndo && ( + + )} + + {Math.min(index + 1, cards.length)}/{cards.length} + +
+
+ )} + + {/* Card deck */} +
+
+ {/* Nova glow halo behind the deck */} + {!done && ( +
+ )} + {done ? ( + + ) : ( + + {visible.map((mem, i) => ( + + ))} + + )} +
+
+ + {/* Controls */} + {!done && current && ( +
+ 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" + > + + + 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 + + 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" + > + + +
+ )} + +
+ ) +} + +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 ( + { + 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") + }} + > + + {/* Full-card color wash that tracks the swipe */} + {isTop && ( + + )} + + {/* Verdict pills — slide/scale in toward the swipe direction */} + {isTop && ( + <> + + Keep + + + Decline + + + )} + +
+
+ + Inferred +
+ +
+

+ {memory.memory} +

+
+ +
+ {memory.parentCount > 0 && ( + + {memory.parentCount === 1 + ? "from 1 memory" + : `from ${memory.parentCount} memories`} + + )} + {relativeTime(memory.createdAt)} +
+
+
+
+ ) +} + +function DoneState({ approved, total }: { approved: number; total: number }) { + return ( + + + + +
+

All caught up

+

+ {approved > 0 + ? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.` + : "Nothing kept this time — they'll stay tucked away."} +

+
+
+ ) +} + +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` +} diff --git a/apps/web/hooks/use-inferred-memories.ts b/apps/web/hooks/use-inferred-memories.ts new file mode 100644 index 00000000..c93d0fce --- /dev/null +++ b/apps/web/hooks/use-inferred-memories.ts @@ -0,0 +1,109 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useCallback } from "react" +import { toast } from "sonner" + +export type InferredMemory = { + id: string + memory: string + parentCount: number + createdAt: string + updatedAt: string + metadata: Record | null +} + +export type ReviewAction = "approve" | "decline" | "undo" + +const inferredKey = (containerTag: string | undefined) => + ["inferred-memories", containerTag] as const + +// Inferred memories the engine wasn't fully confident about, awaiting review. +export function useInferredMemories(containerTag: string | undefined) { + return useQuery({ + queryKey: inferredKey(containerTag), + queryFn: async (): Promise => { + if (!containerTag) return [] + const res = await $fetch("@get/container-tags/:containerTag/inferred", { + params: { containerTag }, + }) + if (res.error) throw new Error(res.error?.message ?? "Failed to load") + return res.data?.memories ?? [] + }, + enabled: !!containerTag, + staleTime: 60 * 1000, + }) +} + +export function useReviewInferredMemory(containerTag: string | undefined) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + memoryId, + action, + }: { + memoryId: string + action: ReviewAction + }) => { + if (!containerTag) throw new Error("Missing container tag") + const res = await $fetch( + "@post/container-tags/:containerTag/inferred/:memoryId/review", + { params: { containerTag, memoryId }, body: { action } }, + ) + if (res.error) throw new Error(res.error?.message ?? "Review failed") + return res.data + }, + // The modal pops cards off its local stack as you swipe, so we just drop + // the reviewed entry from the cached queue once the request settles. + // Undo restores the memory server-side, so refetch to bring it back. + onSuccess: (_data, { memoryId, action }) => { + if (action === "undo") { + queryClient.invalidateQueries({ + queryKey: inferredKey(containerTag), + }) + return + } + queryClient.setQueryData( + inferredKey(containerTag), + (prev) => prev?.filter((m) => m.id !== memoryId), + ) + }, + // The card already flew off the stack, so a silent failure would read as + // "saved". Surface it; the entry stays cached (onSuccess never ran) so it + // resurfaces for review later. + onError: (_err, { action }) => { + toast.error( + action === "undo" + ? "Couldn't undo that review. Try again." + : "Couldn't save your review. It'll resurface for review later.", + ) + }, + }) +} + +// Session-local queue edits for decisions that don't hit the server (skip), +// so the trigger's live count reflects them and the prompt can be dismissed. +export function useInferredMemoryCache(containerTag: string | undefined) { + const queryClient = useQueryClient() + const drop = useCallback( + (memoryId: string) => + queryClient.setQueryData( + inferredKey(containerTag), + (prev) => prev?.filter((m) => m.id !== memoryId), + ), + [queryClient, containerTag], + ) + const restore = useCallback( + (memory: InferredMemory) => + queryClient.setQueryData( + inferredKey(containerTag), + (prev) => + prev?.some((m) => m.id === memory.id) + ? prev + : [...(prev ?? []), memory], + ), + [queryClient, containerTag], + ) + return { drop, restore } +} diff --git a/packages/lib/api.ts b/packages/lib/api.ts index ba403672..42901c0b 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -57,6 +57,34 @@ const WaitlistStatusResponseSchema = z.object({ }) export const apiSchema = createSchema({ + // Inferred-memory review queue (Nova "Suggested for you") + "@get/container-tags/:containerTag/inferred": { + output: z.object({ + memories: z.array( + z.object({ + id: z.string(), + memory: z.string(), + parentCount: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + metadata: z.record(z.string(), z.unknown()).nullable(), + }), + ), + total: z.number(), + }), + params: z.object({ containerTag: z.string() }), + }, + + "@post/container-tags/:containerTag/inferred/:memoryId/review": { + input: z.object({ action: z.enum(["approve", "decline", "undo"]) }), + output: z.object({ + id: z.string(), + isInference: z.boolean(), + reviewStatus: z.enum(["approved", "declined"]).nullable(), + }), + params: z.object({ containerTag: z.string(), memoryId: z.string() }), + }, + "@get/analytics/chat": { output: AnalyticsChatResponseSchema, query: AnalyticsRequestSchema,