Merge remote-tracking branch 'origin/main' into codex/memory-graph-playground-clusters

# Conflicts:
#	packages/memory-graph/src/components/legend.tsx
#	packages/memory-graph/src/components/memory-graph.tsx
This commit is contained in:
Ishaan Gupta 2026-05-30 17:24:20 +05:30
commit 859298d1ff
13 changed files with 564 additions and 490 deletions

View file

@ -41,7 +41,7 @@ import {
CheckCircle2,
Loader2,
} from "lucide-react"
import { analytics } from "@/lib/analytics"
import { analytics, type OnboardingStep } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
type DetectedSource = "x" | "linkedin" | "resume" | null
@ -378,6 +378,13 @@ function isAccountSource(source: DetectedSource): source is "x" | "linkedin" {
return source === "x" || source === "linkedin"
}
const STATUS_TO_STEP: Record<Status, OnboardingStep> = {
idle: "profile_input",
processing: "processing",
done: "done",
error: "error",
}
function useSpotlightAutoRotation(
status: Status,
pauseSpotlight: boolean,
@ -555,6 +562,7 @@ export default function OnboardingPage() {
const fileRef = useRef<HTMLInputElement>(null)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const skippingRef = useRef(false)
const completedTrackedRef = useRef(false)
const [isSkipping, setIsSkipping] = useState(false)
const [spotlightCategory, setSpotlightCategory] =
useState<SpotlightCategoryId>("productivity")
@ -591,6 +599,21 @@ export default function OnboardingPage() {
usePollingCleanup(pollingRef)
useDoneAnimation(status, setStampLanded, setVisibleSnippets)
// biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only
useEffect(() => {
analytics.onboardingStepViewed({
step: STATUS_TO_STEP[status],
trigger: "auto",
})
if (status === "done" && !completedTrackedRef.current) {
completedTrackedRef.current = true
analytics.onboardingCompleted({
source: isAccountSource(detected) ? detected : undefined,
memories_count: memoriesCount,
})
}
}, [status])
const handleChange = (v: string) => {
setValue(v)
setDetected(detectSource(v))
@ -619,6 +642,7 @@ export default function OnboardingPage() {
if (skippingRef.current) return
skippingRef.current = true
setIsSkipping(true)
analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] })
try {
await ensureOrg()
const pendingPath = consumePendingConnectUrl()
@ -628,7 +652,7 @@ export default function OnboardingPage() {
skippingRef.current = false
setIsSkipping(false)
}
}, [ensureOrg, router])
}, [ensureOrg, router, status])
const pollDocument = useCallback((docId: string) => {
const maxAttempts = 60
@ -688,6 +712,7 @@ export default function OnboardingPage() {
const handleSubmit = useCallback(
async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => {
analytics.onboardingProfileSubmitted({ source })
setStatus("processing")
setSpotlightCategory("productivity")
setPauseSpotlight(false)

View file

@ -562,7 +562,7 @@ export default function NewPage() {
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !isChatView && !!session
const showBottomNav = isMobile && !!session
return (
<HotkeysProvider>

View file

@ -38,6 +38,7 @@ export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
}
export default function RootLayout({

View file

@ -19,7 +19,6 @@ import {
} from "@ui/components/sheet"
import { ScrollArea } from "@ui/components/scroll-area"
import {
ArrowLeft,
Check,
ChevronDownIcon,
HistoryIcon,
@ -97,7 +96,7 @@ export function ChatLaunchFab({
export function ChatSidebar({
isChatOpen,
setIsChatOpen,
setIsChatOpen: _setIsChatOpen,
queuedMessage,
queuedHighlightContent,
onConsumeQueuedMessage,
@ -781,8 +780,7 @@ export function ChatSidebar({
const isStackedInput = layout === "page"
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
const isResponding = status === "submitted" || status === "streaming"
const showInputStatusStrip =
!isStackedInput || isResponding || messages.length > 0
const showInputStatusStrip = !isStackedInput
const chatHistorySheet = (
<Sheet
@ -950,25 +948,13 @@ export function ChatSidebar({
"flex items-center justify-between px-0 z-10",
isPageDesktop
? "relative shrink-0 pt-2 pb-1"
: "absolute top-0 right-0 left-0 pt-4 px-4",
: isMobile
? "relative shrink-0 px-4 pt-4 pb-2"
: "absolute top-0 right-0 left-0 pt-4 px-4",
!isMobile && !isPageDesktop && "rounded-t-2xl",
)}
>
<div className="mr-2 flex min-w-0 flex-1 items-center gap-2">
{layout === "page" && isMobile && (
<Button
type="button"
variant="headers"
className="h-10! w-10! shrink-0 cursor-pointer rounded-full border-[#73737333] bg-[#0D121A] p-0!"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsChatOpen(false)}
aria-label="Back to memories"
>
<ArrowLeft className="size-4 text-[#737373]" />
</Button>
)}
{!isStackedInput && (
<>
<ChatModelSelector
@ -1019,7 +1005,7 @@ export function ChatSidebar({
messages.length > 0
? cn(
"flex flex-col space-y-3 min-h-full justify-end",
isPageDesktop ? "pt-2" : "pt-14",
isPageDesktop || isMobile ? "pt-2" : "pt-14",
)
: ""
}
@ -1142,7 +1128,9 @@ export function ChatSidebar({
className={cn(
"shrink-0",
isStackedInput &&
"pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
(isMobile
? "px-4 pb-2"
: "px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6"),
)}
>
<ChatInput
@ -1194,35 +1182,25 @@ export function ChatSidebar({
className={cn(
"relative flex flex-col backdrop-blur-md",
isMobile
? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none pb-safe"
? "flex h-full min-h-0 w-full flex-1 flex-col rounded-none"
: isPageDesktop
? "flex h-full min-h-0 w-full min-w-0 flex-1 flex-col basis-0 rounded-none border-x-0"
: "m-4 mt-2 w-[min(450px,calc(100vw-2rem))] md:w-[380px] lg:w-[450px] rounded-2xl",
dmSansClassName(),
)}
style={
isMobile
isMobile || isPageDesktop
? undefined
: isPageDesktop
? undefined
: {
height: `calc(100vh - ${heightOffset}px)`,
}
: {
height: `calc(100vh - ${heightOffset}px)`,
}
}
initial={
isMobile
? { y: "100%", opacity: 0 }
: layout === "page"
? { opacity: 0, y: 20 }
: { x: "100px", opacity: 0 }
layout === "page" ? { opacity: 0, y: 20 } : { x: "100px", opacity: 0 }
}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={
isMobile
? { y: "100%", opacity: 0 }
: layout === "page"
? { opacity: 0, y: 12 }
: { x: "100px", opacity: 0 }
layout === "page" ? { opacity: 0, y: 12 } : { x: "100px", opacity: 0 }
}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
>

View file

@ -169,9 +169,11 @@ function VersionStatus({
export function GraphListMemories({
memoryEntries,
documentId,
className,
}: {
memoryEntries: MemoryEntry[]
documentId?: string
className?: string
}) {
const { effectiveContainerTags } = useProject()
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
@ -193,7 +195,10 @@ export function GraphListMemories({
return (
<div
id="document-memories"
className="relative flex-1 px-3 pt-3 rounded-[14px] flex flex-col overflow-hidden shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
className={cn(
"relative h-[360px] shrink-0 px-3 pt-3 rounded-[14px] flex flex-col overflow-hidden shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] md:h-auto md:min-h-0 md:flex-1 md:shrink",
className,
)}
style={{
backgroundImage: "url('/dot-pattern.svg')",
backgroundRepeat: "repeat",

View file

@ -1,7 +1,9 @@
"use client"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs"
import {
ArrowUpRightIcon,
XIcon,
@ -268,60 +270,114 @@ export function DocumentModal({
],
)
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
isMobile
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
: "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
const hasPluginInsights =
pluginDocument &&
pluginDocument.kind !== "claude-code-doc" &&
pluginDocument.kind !== "openclaw-session"
const hasDocumentInsights = Boolean(
hasPluginInsights ||
_document?.summary ||
pluginDocument?.summary ||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
)
const documentPreview = (
<div
id="document-preview"
className="bg-[#14161A] rounded-[14px] overflow-hidden flex min-h-0 flex-1 flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative"
>
<DocumentContent
document={_document}
textEditorProps={textEditorProps}
pluginDocument={pluginDocument}
/>
</div>
)
const documentInsights = (
<div
id="document-memories-summary"
className={cn(
"gap-3 flex flex-col overflow-hidden",
isMobile && "min-h-0 flex-1",
dmSansClassName(),
)}
>
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
{_document && (_document.summary || pluginDocument?.summary) && (
<DocumentSummary
memoryEntries={_document.memoryEntries}
summary={(pluginDocument?.summary ?? _document.summary) as string}
createdAt={_document.createdAt}
/>
)}
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
<GraphListMemories
memoryEntries={_document.memoryEntries as MemoryEntry[]}
documentId={_document.id}
className={
isMobile ? "h-auto min-h-[420px] flex-1 shrink" : undefined
}
/>
)}
</div>
)
const modalContent = (
<>
{isMobile ? (
<DrawerTitle className="sr-only">
{_document?.title} - Document
</DrawerTitle>
) : (
<DialogTitle className="sr-only">
{_document?.title} - Document
</DialogTitle>
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}
/>
</div>
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
{pluginDocument?.kind === "claude-code-doc" &&
_document?.customId && (
<CopySessionIdButton sessionId={_document.customId} />
)}
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
deleteMutation={deleteMutation}
/>
{_document?.url && (
<a
href={getDocumentSourceUrl(_document)}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
isMobile ? "size-7 justify-center" : "px-3 py-2",
)}
>
{!isMobile && (
<span className="line-clamp-1">Visit source</span>
)}
<ArrowUpRightIcon className="size-4 text-[#737373]" />
</a>
)}
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}
/>
</div>
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
{pluginDocument?.kind === "claude-code-doc" &&
_document?.customId && (
<CopySessionIdButton sessionId={_document.customId} />
)}
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
deleteMutation={deleteMutation}
/>
{_document?.url && (
<a
href={getDocumentSourceUrl(_document)}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
isMobile ? "size-7 justify-center" : "px-3 py-2",
)}
>
{!isMobile && <span className="line-clamp-1">Visit source</span>}
<ArrowUpRightIcon className="size-4 text-[#737373]" />
</a>
)}
{isMobile ? (
<button
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
type="button"
tabIndex={-1}
onClick={onClose}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</button>
) : (
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
@ -331,50 +387,91 @@ export function DocumentModal({
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
)}
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
<div
id="document-preview"
className={cn(
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
)}
</div>
{isMobile && hasDocumentInsights ? (
<Tabs
defaultValue="content"
className="flex min-h-0 flex-1 flex-col pt-1.5"
>
<TabsList className="grid h-11 w-full grid-cols-2 rounded-full border border-[#263142] bg-[#0A1019] p-1 shadow-[inset_0_1px_2px_rgba(255,255,255,0.04),0_1px_3px_rgba(0,0,0,0.35)]">
<TabsTrigger
value="content"
className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]"
>
Content
</TabsTrigger>
<TabsTrigger
value="insights"
className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]"
>
Insights
</TabsTrigger>
</TabsList>
<TabsContent value="content" className="mt-4 flex min-h-0 flex-1">
{documentPreview}
</TabsContent>
<TabsContent
value="insights"
className="mt-4 flex min-h-0 flex-1 flex-col overflow-y-auto pb-1 scrollbar-thin"
>
<DocumentContent
document={_document}
textEditorProps={textEditorProps}
pluginDocument={pluginDocument}
/>
</div>
<div
id="document-memories-summary"
className={cn(
"gap-3 flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
{pluginDocument &&
pluginDocument.kind !== "claude-code-doc" &&
pluginDocument.kind !== "openclaw-session" && (
<PluginDetails parsed={pluginDocument} />
)}
{_document && (_document.summary || pluginDocument?.summary) && (
<DocumentSummary
memoryEntries={_document.memoryEntries}
summary={
(pluginDocument?.summary ?? _document.summary) as string
}
createdAt={_document.createdAt}
/>
)}
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
<GraphListMemories
memoryEntries={_document.memoryEntries as MemoryEntry[]}
documentId={_document.id}
/>
)}
</div>
{documentInsights}
</TabsContent>
</Tabs>
) : isMobile ? (
<div className="flex min-h-0 flex-1 pt-1.5">{documentPreview}</div>
) : (
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 min-h-0 overflow-hidden">
{documentPreview}
{documentInsights}
</div>
)}
</>
)
if (isMobile) {
return (
<Drawer
open={isOpen}
onOpenChange={(open: boolean) => !open && onClose()}
shouldScaleBackground
>
<DrawerContent
className={cn(
"flex flex-col gap-0 border-none bg-[#1B1F24] p-0",
"h-[88svh] max-h-[88svh] overflow-hidden rounded-t-[22px]",
"[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1",
dmSansClassName(),
)}
style={{
boxShadow:
"0 -12px 40px rgba(0, 0, 0, 0.45), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-3 pt-2 pb-4">
{modalContent}
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
"w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
{modalContent}
</DialogContent>
</Dialog>
)

View file

@ -4,7 +4,6 @@ import { Logo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { analytics } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
import { cn } from "@lib/utils"
@ -23,7 +22,6 @@ export function InitialHeader({
const handleSkip = () => {
markOrgOnboarded()
analytics.onboardingCompleted()
const pendingPath = consumePendingConnectUrl()
router.push(pendingPath ?? "/")
}

View file

@ -1135,7 +1135,10 @@ export function IntegrationsView() {
}
throw new Error(response.error?.message || "Failed to connect")
},
onMutate: (provider) => setConnectingProvider(provider),
onMutate: (provider) => {
setConnectingProvider(provider)
analytics.connectionAuthStarted({ provider })
},
onError: (err) => {
setConnectingProvider(null)
toast.error("Failed to connect", {
@ -1349,6 +1352,13 @@ export function IntegrationsView() {
return true
})
const trackCard = (item: Item) =>
analytics.integrationCardClicked({
kind: item.kind,
id: item.id,
name: item.name,
})
const renderRight = (item: Item): ReactNode => {
switch (item.kind) {
case "plugin": {
@ -1370,7 +1380,10 @@ export function IntegrationsView() {
const busy = connectingPlugin === item.pluginId
return (
<PillButton
onClick={() => createPluginKeyMutation.mutate(item.pluginId)}
onClick={() => {
trackCard(item)
createPluginKeyMutation.mutate(item.pluginId)
}}
disabled={!!connectingPlugin}
>
{busy ? (
@ -1397,7 +1410,10 @@ export function IntegrationsView() {
const busy = connectingProvider === item.provider
return (
<PillButton
onClick={() => addConnectionMutation.mutate(item.provider)}
onClick={() => {
trackCard(item)
addConnectionMutation.mutate(item.provider)
}}
disabled={!!connectingProvider}
>
{busy ? (
@ -1415,6 +1431,7 @@ export function IntegrationsView() {
return (
<PillButton
onClick={() => {
trackCard(item)
window.open(
(item.action as { type: "external"; href: string }).href,
"_blank",
@ -1431,12 +1448,13 @@ export function IntegrationsView() {
}
return (
<PillButton
onClick={() =>
onClick={() => {
trackCard(item)
setViewMode(
(item.action as { type: "view"; viewMode: ViewParamValue })
.viewMode,
)
}
}}
>
Connect
</PillButton>
@ -1444,13 +1462,23 @@ export function IntegrationsView() {
}
case "mcp-client":
return (
<PillButton onClick={() => openMcpClient(item.clientKey)}>
<PillButton
onClick={() => {
trackCard(item)
openMcpClient(item.clientKey)
}}
>
Connect
</PillButton>
)
case "import":
return (
<PillButton onClick={() => setViewMode(item.viewMode)}>
<PillButton
onClick={() => {
trackCard(item)
setViewMode(item.viewMode)
}}
>
Connect
</PillButton>
)

View file

@ -31,6 +31,9 @@ const CREDIT_FEATURE_ID = "usd_credits"
const TOP_UP_PLAN_ID = "credits_topup"
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
const SURFACE_SHADOW =
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
type BillingInvoice = {
planIds?: string[]
stripeId: string
@ -694,322 +697,237 @@ export default function Billing() {
</SettingsCard>
</section>
<section className="flex flex-col gap-4">
<SectionTitle>Credits</SectionTitle>
<SettingsCard>
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
Usage this period
<Dialog open={isCreditsDialogOpen} onOpenChange={setIsCreditsDialogOpen}>
<DialogContent
showCloseButton={false}
style={{ boxShadow: SURFACE_SHADOW }}
className="w-[min(560px,calc(100vw-32px))] rounded-[22px] border border-white/[0.12] bg-[#1B1F24] p-6"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className={cn(
dmSans125ClassName(),
"text-[22px] font-semibold tracking-[-0.22px] text-[#FAFAFA]",
)}
>
Buy Credits
</p>
<div className="mt-4 flex items-baseline gap-1 text-[13px] text-[#A3A3A3]">
<span className="font-semibold text-[#FAFAFA]">
{planUsagePct < 1 && planUsagePct > 0
? "< 1"
: Math.round(planUsagePct)}
%
</span>
<span>of monthly usage</span>
<span className="text-[#737373]">
{daysRemaining !== null
? `· resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
: "· resets with your billing cycle"}
</span>
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[#2E353D]">
<div
className="h-full rounded-full bg-[#4BA0FA]"
style={{ width: `${planUsagePct}%` }}
/>
</div>
<p className="mt-3 text-[12px] text-[#737373]">
{planUsagePct > 0
? `${formatUsd(usdSpent)} used this period`
: "No usage yet this period"}
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[15px] text-[#A3A3A3]",
)}
>
Add USD to your balance for metered usage.
</p>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:min-w-[170px]">
<Dialog
open={isCreditsDialogOpen}
onOpenChange={setIsCreditsDialogOpen}
>
<DialogTrigger asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9]",
)}
>
<Plus className="size-3.5" />
Buy credits
</button>
</DialogTrigger>
<DialogContent
showCloseButton={false}
className="w-[min(560px,calc(100vw-32px))] rounded-[18px] border border-[#1C2B3E] bg-[#0B0D12] p-6 shadow-[0px_18px_70px_rgba(0,0,0,0.72)]"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className={cn(
dmSans125ClassName(),
"text-[22px] font-semibold tracking-[-0.22px] text-[#FAFAFA]",
)}
>
Buy Credits
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[15px] text-[#A3A3A3]",
)}
>
Add USD to your balance for metered usage.
</p>
</div>
<DialogClose asChild>
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
>
<X className="size-5" />
</button>
</DialogClose>
</div>
<div className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<p
className={cn(
dmSans125ClassName(),
"text-[16px] font-semibold text-[#FAFAFA]",
)}
>
Choose an amount
</p>
<FieldSelect
value={topUpAmount}
values={TOP_UP_AMOUNTS}
prefix="$"
onChange={(value) => {
setTopUpAmount(value)
setCustomTopUpAmount("")
}}
disabled={topUpPendingAmount !== null}
/>
<div className="flex flex-col gap-2">
<label
htmlFor="custom-topup-amount"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Custom amount (USD)
</label>
<input
id="custom-topup-amount"
inputMode="decimal"
min={1}
onChange={(event) =>
setCustomTopUpAmount(event.target.value)
}
placeholder="e.g. 75"
type="number"
value={customTopUpAmount}
className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
/>
</div>
</div>
<div className="h-px bg-white/[0.06]" />
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
Auto reload
</p>
<span className="text-[12px] text-[#737373]">
{autoTopUpEnabled ? "on" : "off"}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<p
className={cn(
dmSans125ClassName(),
"text-[16px] text-[#FAFAFA]",
)}
>
Auto reload is{" "}
{autoTopUpEnabled ? "enabled" : "disabled"}
</p>
<button
type="button"
disabled={
isSavingAutoTopUp ||
!isAdmin ||
(!hasPaymentMethod && !activeAutoTopUp?.enabled)
}
onClick={() =>
handleAutoReloadToggle(!autoTopUpEnabled)
}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
)}
>
{autoTopUpEnabled ? "Disable" : "Enable"}
</button>
</div>
{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
<p className="text-[13px] text-[#737373]">
Save a card in Manage Billing to enable automatic
reloads.
</p>
) : null}
<div
className={cn(
"grid gap-3 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3 transition-[filter,opacity] sm:grid-cols-2",
!autoTopUpEnabled &&
"pointer-events-none select-none opacity-45 blur-[3px]",
)}
>
<div className="flex flex-col gap-2">
<label
htmlFor="auto-topup-threshold"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Threshold (USD)
</label>
<input
id="auto-topup-threshold"
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
inputMode="decimal"
min={0}
onChange={(event) => {
const value = Number.parseFloat(
event.target.value,
)
setAutoTopUpThreshold(
Number.isFinite(value) ? value : 0,
)
}}
type="number"
value={
Number.isFinite(autoTopUpThreshold)
? autoTopUpThreshold
: ""
}
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="auto-topup-amount"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Reload amount (USD)
</label>
<input
id="auto-topup-amount"
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
inputMode="decimal"
min={0.01}
onChange={(event) => {
const value = Number.parseFloat(
event.target.value,
)
setAutoTopUpAmount(
Number.isFinite(value) ? value : 0,
)
}}
type="number"
value={
Number.isFinite(autoTopUpAmount)
? autoTopUpAmount
: ""
}
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
/>
</div>
<div className="sm:col-span-2">
<button
type="button"
onClick={() => void handleSaveAutoTopUp()}
disabled={isSavingAutoTopUp || !isAdmin}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
)}
>
{isSavingAutoTopUp ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Save threshold &amp; reload amount
</button>
</div>
</div>
</div>
<button
type="button"
onClick={() => void handleTopUp(selectedTopUpAmount)}
disabled={
topUpPendingAmount !== null ||
!isAdmin ||
selectedTopUpAmount <= 0
}
className={cn(
dmSans125ClassName(),
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
)}
>
{topUpPendingAmount !== null ? (
<LoaderIcon className="size-4 animate-spin" />
) : null}
Buy {formatUsd(selectedTopUpAmount)} in credits
</button>
{!isAdmin ? (
<p className="text-center text-[11px] text-[#737373]">
Only owners/admins can purchase credits.
</p>
) : null}
</div>
</DialogContent>
</Dialog>
<DialogClose asChild>
<button
type="button"
onClick={() => setIsCreditsDialogOpen(true)}
disabled={!isAdmin}
className="inline-flex h-8 items-center justify-center gap-2 rounded-[8px] text-[12px] font-medium text-[#A3A3A3] transition-colors hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
>
<span
className="size-1.5 rounded-full"
style={{
backgroundColor: autoTopUpEnabled ? "#4BA0FA" : "#737373",
}}
/>
Auto reload: {autoTopUpEnabled ? "on" : "off"}
<X className="size-5" />
</button>
</div>
</DialogClose>
</div>
</SettingsCard>
{hasPaidPlan ? (
<div className="mt-8 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<p
className={cn(
dmSans125ClassName(),
"text-[16px] font-semibold text-[#FAFAFA]",
)}
>
Choose an amount
</p>
<FieldSelect
value={topUpAmount}
values={TOP_UP_AMOUNTS}
prefix="$"
onChange={(value) => {
setTopUpAmount(value)
setCustomTopUpAmount("")
}}
disabled={topUpPendingAmount !== null}
/>
<div className="flex flex-col gap-2">
<label
htmlFor="custom-topup-amount"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Custom amount (USD)
</label>
<input
id="custom-topup-amount"
inputMode="decimal"
min={1}
onChange={(event) => setCustomTopUpAmount(event.target.value)}
placeholder="e.g. 75"
type="number"
value={customTopUpAmount}
className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
/>
</div>
</div>
<div className="h-px bg-white/[0.06]" />
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
Auto reload
</p>
<span className="text-[12px] text-[#737373]">
{autoTopUpEnabled ? "on" : "off"}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<p
className={cn(
dmSans125ClassName(),
"text-[16px] text-[#FAFAFA]",
)}
>
Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"}
</p>
<button
type="button"
disabled={
isSavingAutoTopUp ||
!isAdmin ||
(!hasPaymentMethod && !activeAutoTopUp?.enabled)
}
onClick={() => handleAutoReloadToggle(!autoTopUpEnabled)}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
)}
>
{autoTopUpEnabled ? "Disable" : "Enable"}
</button>
</div>
{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
<p className="text-[13px] text-[#737373]">
Save a card in Manage Billing to enable automatic reloads.
</p>
) : null}
<div
className={cn(
"grid gap-3 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3 transition-[filter,opacity] sm:grid-cols-2",
!autoTopUpEnabled &&
"pointer-events-none select-none opacity-45 blur-[3px]",
)}
>
<div className="flex flex-col gap-2">
<label
htmlFor="auto-topup-threshold"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Threshold (USD)
</label>
<input
id="auto-topup-threshold"
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
inputMode="decimal"
min={0}
onChange={(event) => {
const value = Number.parseFloat(event.target.value)
setAutoTopUpThreshold(Number.isFinite(value) ? value : 0)
}}
type="number"
value={
Number.isFinite(autoTopUpThreshold)
? autoTopUpThreshold
: ""
}
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="auto-topup-amount"
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
>
Reload amount (USD)
</label>
<input
id="auto-topup-amount"
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
inputMode="decimal"
min={0.01}
onChange={(event) => {
const value = Number.parseFloat(event.target.value)
setAutoTopUpAmount(Number.isFinite(value) ? value : 0)
}}
type="number"
value={
Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : ""
}
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
/>
</div>
<div className="sm:col-span-2">
<button
type="button"
onClick={() => void handleSaveAutoTopUp()}
disabled={isSavingAutoTopUp || !isAdmin}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
)}
>
{isSavingAutoTopUp ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Save threshold &amp; reload amount
</button>
</div>
</div>
</div>
<button
type="button"
onClick={() => void handleTopUp(selectedTopUpAmount)}
disabled={
topUpPendingAmount !== null ||
!isAdmin ||
selectedTopUpAmount <= 0
}
className={cn(
dmSans125ClassName(),
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
)}
>
{topUpPendingAmount !== null ? (
<LoaderIcon className="size-4 animate-spin" />
) : null}
Buy {formatUsd(selectedTopUpAmount)} in credits
</button>
{!isAdmin ? (
<p className="text-center text-[11px] text-[#737373]">
Only owners/admins can purchase credits.
</p>
) : null}
</div>
</DialogContent>
</Dialog>
{hasPaidPlan ? (
<section className="flex flex-col gap-4">
<SectionTitle>Credits</SectionTitle>
<SettingsCard className="border border-dashed border-white/10 bg-[#14161A]/70">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
Top-up credits{" "}
<span className="font-normal normal-case tracking-normal text-[#A3A3A3]">
(optional)
</span>
Top-up credits
</p>
<p
className={cn(
@ -1039,12 +957,12 @@ export default function Billing() {
)}
>
<Plus className="size-3.5" />
{creditRemaining > 0 ? "Add more" : "Add credits"}
{creditRemaining > 0 ? "Add more" : "Buy credits"}
</button>
</div>
</SettingsCard>
) : null}
</section>
</section>
) : null}
<section className="flex flex-col gap-4">
<SectionTitle>Invoice history</SectionTitle>

View file

@ -94,7 +94,7 @@ function PillButton({
children: React.ReactNode
onClick: () => void
disabled?: boolean
variant?: "default" | "danger" | "primary"
variant?: "default" | "ghost" | "primary"
}) {
return (
<button
@ -103,14 +103,10 @@ function PillButton({
disabled={disabled}
className={cn(
dmSansClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
"bg-[#0D121A] text-[#FAFAFA] hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
variant === "primary"
? "border-transparent"
: variant === "danger"
? "border-transparent"
: "border-transparent",
"inline-flex h-9 items-center justify-center gap-2 rounded-full px-4 text-[13px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
variant === "ghost"
? "px-3 font-medium text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
: "border border-transparent bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
)}
>
{children}
@ -212,7 +208,7 @@ export function OrgContext() {
<PillButton
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
disabled={!settingsReady || updateSettings.isPending}
variant={enabled ? "danger" : "primary"}
variant={enabled ? "ghost" : "primary"}
>
{enabled ? "DISABLE" : "ENABLE"}
</PillButton>
@ -300,7 +296,9 @@ export function OrgContext() {
</div>
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
<PillButton onClick={handleCancel}>CANCEL</PillButton>
<PillButton onClick={handleCancel} variant="ghost">
CANCEL
</PillButton>
<PillButton
onClick={handleSave}
disabled={!dirty || updateSettings.isPending}
@ -363,13 +361,16 @@ export function OrgContext() {
</DialogPrimitive.Close>
</div>
<div className="flex justify-end gap-2">
<PillButton onClick={() => setConfirmDialog(null)}>
<PillButton
onClick={() => setConfirmDialog(null)}
variant="ghost"
>
CANCEL
</PillButton>
<PillButton
onClick={handleConfirmToggle}
disabled={updateSettings.isPending}
variant={confirmDialog === "disable" ? "danger" : "primary"}
variant="primary"
>
{updateSettings.isPending && (
<LoaderIcon className="size-3.5 animate-spin" />

View file

@ -1,5 +1,8 @@
import posthog from "posthog-js"
export type OnboardingStep = "profile_input" | "processing" | "done" | "error"
export type OnboardingSource = "x" | "linkedin" | "resume"
// Helper function to safely capture events
const safeCapture = (
eventName: string,
@ -40,12 +43,13 @@ export const analytics = {
upgradeCompleted: () => safeCapture("upgrade_completed"),
billingPortalOpened: () => safeCapture("billing_portal_opened"),
connectionAdded: (provider: string) =>
safeCapture("connection_added", { provider }),
connectionDeleted: () => safeCapture("connection_deleted"),
connectionAuthStarted: () => safeCapture("connection_auth_started"),
connectionAuthCompleted: () => safeCapture("connection_auth_completed"),
connectionAuthFailed: () => safeCapture("connection_auth_failed"),
connectionAuthStarted: (props: { provider: string }) =>
safeCapture("connection_auth_started", props),
// integrations surface (main Nova page)
integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
safeCapture("integration_card_clicked", props),
nextAppResearchCtaDismissed: () =>
safeCapture("next_app_research_cta_dismissed"),
@ -72,21 +76,13 @@ export const analytics = {
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
// onboarding analytics
onboardingStepViewed: (props: { step: string; trigger: "user" | "auto" }) =>
safeCapture("onboarding_step_viewed", props),
onboardingStepViewed: (props: {
step: OnboardingStep
trigger: "user" | "auto"
}) => safeCapture("onboarding_step_viewed", props),
onboardingNameSubmitted: (props: { name_length: number }) =>
safeCapture("onboarding_name_submitted", props),
onboardingProfileSubmitted: (props: {
has_twitter: boolean
has_linkedin: boolean
other_links_count: number
description_length: number
}) => safeCapture("onboarding_profile_submitted", props),
onboardingRelatableSelected: (props: { options: string[] }) =>
safeCapture("onboarding_relatable_selected", props),
onboardingProfileSubmitted: (props: { source: OnboardingSource }) =>
safeCapture("onboarding_profile_submitted", props),
onboardingIntegrationClicked: (props: { integration: string }) =>
safeCapture("onboarding_integration_clicked", props),
@ -100,7 +96,13 @@ export const analytics = {
onboardingXBookmarksDetailOpened: () =>
safeCapture("onboarding_x_bookmarks_detail_opened"),
onboardingCompleted: () => safeCapture("onboarding_completed"),
onboardingSkipped: (props: { from_step: OnboardingStep }) =>
safeCapture("onboarding_skipped", props),
onboardingCompleted: (props?: {
source?: OnboardingSource
memories_count?: number
}) => safeCapture("onboarding_completed", props),
// main app analytics
searchOpened: (props: {

View file

@ -7,6 +7,8 @@ interface LegendProps {
isLoading?: boolean
colors: GraphThemeColors
hoveredNode?: string | null
compact?: boolean
maxHeight?: number
}
function HexagonIcon({
@ -290,6 +292,8 @@ export const Legend = memo(function Legend({
isLoading: _isLoading = false,
colors,
hoveredNode,
compact = false,
maxHeight,
}: LegendProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
@ -311,7 +315,8 @@ export const Legend = memo(function Legend({
const outerStyle: React.CSSProperties = {
overflow: "hidden",
width: 214,
width: compact ? "min(214px, calc(100vw - 32px))" : 214,
maxWidth: "100%",
}
const cardStyle: React.CSSProperties = {
@ -319,6 +324,7 @@ export const Legend = memo(function Legend({
backgroundColor: colors.controlBg,
border: `1px solid ${colors.controlBorder}`,
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
maxHeight,
}
const headerBtnStyle: React.CSSProperties = {
@ -327,6 +333,7 @@ export const Legend = memo(function Legend({
alignItems: "center",
gap: 6,
width: "100%",
justifyContent: "flex-start",
cursor: "pointer",
outline: "none",
background: "none",
@ -390,6 +397,21 @@ export const Legend = memo(function Legend({
marginTop: 2,
}
const expandedContentStyle: React.CSSProperties = {
marginTop: 16,
display: "flex",
flexDirection: "column",
gap: 16,
...(compact
? {
maxHeight: maxHeight ? Math.max(maxHeight - 56, 112) : 220,
overflowY: "auto",
overscrollBehavior: "contain",
paddingRight: 2,
}
: {}),
}
return (
<div style={outerStyle}>
<div style={cardStyle}>
@ -408,14 +430,7 @@ export const Legend = memo(function Legend({
</button>
{isExpanded && (
<div
style={{
marginTop: 16,
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
<div style={expandedContentStyle}>
{/* Statistics section */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={sectionLabelStyle}>Statistics</span>

View file

@ -111,6 +111,9 @@ export function MemoryGraph({
const graphFitHeight = isCompactViewport
? Math.max(containerSize.height - 170, 240)
: containerSize.height
const compactLegendMaxHeight = isCompactViewport
? Math.max(containerSize.height - 104, 160)
: undefined
// Rebuild version chain index during render (not in an effect) so that
// the chain data is up-to-date when getChain() is called in useMemo below.
@ -730,13 +733,14 @@ export function MemoryGraph({
const bottomLeftStackStyle: React.CSSProperties = {
position: "absolute",
bottom: 16,
left: 16,
bottom: isCompactViewport ? 12 : 16,
left: isCompactViewport ? 12 : 16,
zIndex: 20,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 8,
maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined,
}
return (
@ -809,7 +813,9 @@ export function MemoryGraph({
colors={colors}
edges={edges}
hoveredNode={hoveredNode}
compact={isCompactViewport}
isLoading={isLoading}
maxHeight={compactLegendMaxHeight}
nodes={nodes}
/>
</div>