diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 6edbc1ca2..d9c3851d5 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -42,6 +42,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] @@ -1180,6 +1181,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], @@ -1598,7 +1600,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 000000000..d11ae1042 --- /dev/null +++ b/apps/web/components/review-memories-modal.tsx @@ -0,0 +1,469 @@ +"use client" + +import { dmSansClassName } from "@/lib/fonts" +import { + type InferredMemory, + useInferredMemories, + 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 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" }) + } + // skip persists nothing — it simply resurfaces in a later session. + historyRef.current = [...historyRef.current, { id: card.id, decision }] + setHistory(historyRef.current) + indexRef.current += 1 + setIndex(indexRef.current) + }, + [cards, review], + ) + + 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" }) + } + indexRef.current = Math.max(0, indexRef.current - 1) + setIndex(indexRef.current) + }, [review]) + + // 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 000000000..fb1fbe75c --- /dev/null +++ b/apps/web/hooks/use-inferred-memories.ts @@ -0,0 +1,71 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +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), + ) + }, + }) +} diff --git a/packages/lib/api.ts b/packages/lib/api.ts index ba4036727..42901c0b1 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,