add: dashboard improvements

This commit is contained in:
Mahesh Sanikommmu 2026-04-16 00:58:19 -07:00
parent 29fe0f4eaa
commit dc80c2fdbc
12 changed files with 725 additions and 131 deletions

View file

@ -1,9 +1,10 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import { useState, useCallback, useEffect, useMemo } from "react"
import { useQueryState } from "nuqs"
import { Header } from "@/components/header"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
import { IntegrationsView } from "@/components/integrations-view"
@ -44,6 +45,7 @@ import {
pluginsPanelParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -105,6 +107,14 @@ export default function NewPage() {
(selectedProjectTag !== undefined &&
novaContainerTags.includes(selectedProjectTag))
const { allProjects } = useContainerTags()
const dashboardSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const emptyStateSpaceName =
!isNovaSpaces && selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
@ -150,7 +160,10 @@ export default function NewPage() {
"integration",
integrationParam,
)
const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam)
const [pluginsPanelFromUrl, setPluginsPanel] = useQueryState(
"plugins",
pluginsPanelParam,
)
useEffect(() => {
if (integrationFromUrl || pluginsPanelFromUrl === true) {
@ -446,6 +459,11 @@ export default function NewPage() {
[setViewMode, setIntegration],
)
const handleOpenPlugins = useCallback(() => {
void setViewMode("integrations")
void setPluginsPanel(true)
}, [setViewMode, setPluginsPanel])
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
@ -456,6 +474,8 @@ export default function NewPage() {
const isChatView = viewMode === "chat"
const isGraphMode = viewMode === "graph" && !isMobile
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
// Animated gradient — commented for now (use with useSyncExternalStore + helpers above).
// const viewportWidth = useSyncExternalStore(
@ -509,7 +529,7 @@ export default function NewPage() {
layout="page"
isChatOpen
setIsChatOpen={(open) => {
if (!open) void setViewMode("list")
if (!open) void setViewMode("dashboard")
}}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={consumeQueuedChat}
@ -526,11 +546,11 @@ export default function NewPage() {
<div className="min-h-0 min-w-0 flex-1">
<GraphLayoutView />
</div>
) : (
) : viewMode === "list" ? (
<div
className={cn(
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
viewMode === "list" && "pb-32 md:pb-36",
"pb-10 md:pb-12",
)}
>
<MemoriesGrid
@ -570,12 +590,43 @@ export default function NewPage() {
}
/>
</div>
) : (
<DashboardView
spaceLabel={dashboardSpaceLabel}
headerNotice={
viewMode === "graph" && isMobile ? (
<div
id="graph-mobile-notice"
className="rounded-lg border border-[#2261CA33] bg-[#041127] px-3 py-2.5 text-sm text-[#8B8B8B]"
>
<span className="font-medium text-white">
Graph view is available on desktop.
</span>{" "}
Use a larger screen for the full graph, or keep working
from this home view.
</div>
) : undefined
}
highlights={highlightsData?.highlights ?? []}
isLoadingHighlights={isLoadingHighlights}
onAddMemory={handleAddMemory}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
onOpenIntegrations={handleOpenIntegrations}
onOpenPlugins={handleOpenPlugins}
onNavigateToMemories={() => void setViewMode("list")}
onOpenDocument={handleOpenDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
/>
)}
</ErrorBoundary>
</div>
</main>
{viewMode === "list" && (
{isDashboardShell && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",

View file

@ -54,7 +54,7 @@ export function HomeChatComposer({
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
activeStatus="Waiting for input..."
showStatusStrip={false}
stackedToolbar={
<>
<ChatModelSelector

View file

@ -20,6 +20,8 @@ interface ChatInputProps {
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
}
export default function ChatInput({
@ -33,6 +35,7 @@ export default function ChatInput({
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
showStatusStrip = true,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@ -55,57 +58,69 @@ export default function ChatInput({
<motion.div
className={cn("relative z-20!")}
animate={{
padding: isExpanded ? "16px" : "0",
margin: isExpanded ? "0" : "16px",
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
borderRadius: showStatusStrip
? isExpanded
? "0 0 12px 12px"
: "12px"
: "0",
backgroundColor: showStatusStrip
? isExpanded
? "#000B1B"
: "#01173C"
: "transparent",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
{showStatusStrip ? (
<>
<div
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
/>
)}
</button>
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
</>
) : null}
{stackedToolbar ? (
<div className="flex flex-col gap-2 rounded-xl border border-[#52596633] bg-[#070E1B] p-2 transition-all duration-200 focus-within:outline-1 focus-within:outline-[#525D6EB2]">
<textarea

View file

@ -0,0 +1,334 @@
"use client"
import type { ReactNode } from "react"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useQuery } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { useRouter } from "next/navigation"
import {
ArrowRight,
FileText,
LayoutGrid,
Link2,
SearchIcon,
Zap,
} from "lucide-react"
import type { z } from "zod"
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
import { Button } from "@ui/components/button"
import { Progress } from "@ui/components/progress"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useProject } from "@/stores"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { formatUsageNumber, tokensToCredits } from "@/lib/billing-utils"
import {
HighlightsCard,
type HighlightItem,
} from "@/components/highlights-card"
import { ChromeIcon } from "@/components/integration-icons"
import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const primaryActionClass = cn(
"rounded-xl border border-[rgba(82,89,102,0.22)] bg-[#14161A] p-3.5 sm:p-4",
"hover:border-[#3374FF]/45 hover:bg-[#1B1F24]",
"cursor-pointer text-left transition-colors",
)
const linkChipClass = cn(
"rounded-lg border border-[rgba(82,89,102,0.22)] bg-[#101318] px-3 py-2.5",
"text-xs font-medium text-white",
"hover:border-[#3374FF]/40 hover:bg-[#161A20]",
"inline-flex items-center justify-center gap-2 cursor-pointer transition-colors",
)
export function DashboardView({
spaceLabel,
headerNotice,
highlights,
isLoadingHighlights,
onAddMemory,
onOpenSearch,
onOpenIntegrations,
onOpenPlugins,
onNavigateToMemories,
onOpenDocument,
onHighlightsChat,
onHighlightsShowRelated,
}: {
spaceLabel: string
headerNotice?: ReactNode
highlights: HighlightItem[]
isLoadingHighlights: boolean
onAddMemory: (tab: "note" | "link") => void
onOpenSearch: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
onOpenPlugins: () => void
onNavigateToMemories: () => void
onOpenDocument: (document: DocumentWithMemories) => void
onHighlightsChat: (seed: string) => void
onHighlightsShowRelated: (query: string) => void
}) {
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const router = useRouter()
const autumn = useCustomer()
const {
tokensUsed,
tokensLimit,
tokensPercent,
searchesUsed,
searchesLimit,
searchesPercent,
currentPlan,
hasPaidPlan,
isLoading: isUsageLoading,
} = useTokenUsage(autumn)
const { data: recentsData, isPending: isRecentsPending } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 8,
sort: "createdAt",
order: "desc",
containerTags: effectiveContainerTags,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data as DocumentsResponse
},
staleTime: 60 * 1000,
enabled: !!user,
})
const recents = recentsData?.documents ?? []
const handleInstallChrome = () => {
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
}
return (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
dmSansClassName(),
)}
>
<div className="mx-auto w-full max-w-4xl space-y-6 md:space-y-7">
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
<header className="space-y-1 border-b border-[#161F2C] pb-5">
<p className="text-[11px] font-medium uppercase tracking-wide text-[#525D6E]">
Home
</p>
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
{spaceLabel}
</h1>
<p className="max-w-xl text-sm leading-snug text-[#8B8B8B]">
Add memories, search, and open integrations — everything for this
space in one place.
</p>
</header>
<section className="space-y-3">
<h2 className="text-xs font-medium uppercase tracking-wide text-[#525D6E]">
Quick actions
</h2>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3">
<button
type="button"
onClick={() => onAddMemory("link")}
className={cn(primaryActionClass, "flex flex-col gap-1.5")}
>
<div className="flex items-center gap-2">
<Link2 className="size-4 shrink-0 text-[#737373] sm:size-5" />
<span className="text-sm font-medium text-white">
Save a link
</span>
</div>
<span className="text-[11px] font-medium text-[#4BA0FA]">
Add now
</span>
</button>
<button
type="button"
onClick={() => onAddMemory("note")}
className={cn(primaryActionClass, "flex flex-col gap-1.5")}
>
<div className="flex items-center gap-2">
<FileText className="size-4 shrink-0 text-[#737373] sm:size-5" />
<span className="text-sm font-medium text-white">
Write a note
</span>
</div>
<span className="text-[11px] font-medium text-[#4BA0FA]">
Add now
</span>
</button>
<button
type="button"
onClick={() => {
analytics.searchOpened({ source: "header" })
onOpenSearch()
}}
className={cn(primaryActionClass, "flex flex-col gap-1.5")}
>
<div className="flex items-center gap-2">
<SearchIcon className="size-4 shrink-0 text-[#737373] sm:size-5" />
<span className="text-sm font-medium text-white">Search</span>
</div>
<span className="text-[11px] font-medium text-[#4BA0FA]">⌘K</span>
</button>
<button
type="button"
onClick={onNavigateToMemories}
className={cn(primaryActionClass, "flex flex-col gap-1.5")}
>
<div className="flex items-center gap-2">
<LayoutGrid className="size-4 shrink-0 text-[#737373] sm:size-5" />
<span className="text-sm font-medium text-white">
All memories
</span>
</div>
<span className="text-[11px] font-medium text-[#4BA0FA]">
Browse grid
</span>
</button>
</div>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={handleInstallChrome}
className={linkChipClass}
>
<ChromeIcon className="size-4 shrink-0 text-[#737373]" />
<span className="truncate">Chrome</span>
</button>
<button
type="button"
onClick={() => onOpenIntegrations()}
className={linkChipClass}
>
<Zap className="size-3.5 shrink-0 text-[#737373]" />
<span className="truncate">Integrations</span>
</button>
<button type="button" onClick={onOpenPlugins} className={linkChipClass}>
<span className="truncate">Plugins & MCP</span>
</button>
</div>
</section>
{highlights.length > 0 || isLoadingHighlights ? (
<section className="space-y-3">
<h2 className="text-xs font-medium uppercase tracking-wide text-[#525D6E]">
Highlights
</h2>
<HighlightsCard
items={highlights}
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
/>
</section>
) : null}
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-medium uppercase tracking-wide text-[#525D6E]">
Recently saved
</h2>
<button
type="button"
onClick={onNavigateToMemories}
className="text-xs font-medium text-[#4BA0FA] hover:text-[#6BB0FF]"
>
See all
</button>
</div>
{isRecentsPending ? (
<p className="text-sm text-[#525D6E]">Loading…</p>
) : recents.length === 0 ? (
<p className="text-sm text-[#525D6E]">
No memories yet — use quick actions above.
</p>
) : (
<ul className="divide-y divide-[#161F2C] rounded-xl border border-[#161F2C] bg-[#080B0F]">
{recents.map((doc) => (
<li key={doc.id ?? doc.customId}>
<button
type="button"
onClick={() => onOpenDocument(doc)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-[#0D121A]"
>
<span className="min-w-0 truncate text-sm text-white">
{doc.title?.trim() || "Untitled"}
</span>
<ArrowRight className="size-4 shrink-0 text-[#525D6E]" />
</button>
</li>
))}
</ul>
)}
</section>
{!isUsageLoading && (
<section
id="dashboard-usage"
className="flex flex-col gap-3 rounded-xl border border-[#161F2C] bg-[#080B0F]/80 px-4 py-3 sm:flex-row sm:items-center sm:gap-5"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 sm:flex-col sm:items-start sm:justify-center sm:gap-0.5">
<h2 className="text-xs font-medium text-white">Usage</h2>
<span className="text-[11px] capitalize leading-none text-[#8B8B8B]">
{currentPlan}
</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:gap-5">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex justify-between text-[10px] text-[#8B8B8B]">
<span>Credits</span>
<span>
{formatUsageNumber(tokensToCredits(tokensUsed))} /{" "}
{formatUsageNumber(tokensToCredits(tokensLimit))}
</span>
</div>
<Progress value={tokensPercent} className="h-1" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex justify-between text-[10px] text-[#8B8B8B]">
<span>Searches</span>
<span>
{formatUsageNumber(searchesUsed)} /{" "}
{formatUsageNumber(searchesLimit)}
</span>
</div>
<Progress value={searchesPercent} className="h-1" />
</div>
</div>
{!hasPaidPlan ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 px-3 text-xs sm:self-center"
onClick={() => router.push("/settings")}
>
Upgrade
</Button>
) : null}
</section>
)}
</div>
</div>
)
}

View file

@ -12,6 +12,9 @@ import {
ExternalLink,
MenuIcon,
MessageCircleIcon,
MoreHorizontal,
LifeBuoy,
LayoutGrid,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
@ -24,6 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useProject } from "@/stores"
import { useRouter } from "next/navigation"
import Link from "next/link"
@ -126,7 +130,6 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
{!isMobile && (
<SpaceSelector
selectedProjects={selectedProjects}
@ -140,12 +143,12 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<div className="flex items-center gap-2 z-10!">
<button
type="button"
onClick={() => void setViewMode("list")}
onClick={() => void setViewMode("dashboard")}
aria-label="Home"
aria-current={viewMode === "list" ? "page" : undefined}
aria-current={viewMode === "dashboard" ? "page" : undefined}
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-full border cursor-pointer transition-colors",
viewMode === "list"
viewMode === "dashboard"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
@ -160,6 +163,11 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
>
{(
[
{
mode: "list" as const,
label: "Memories",
icon: LayoutGrid,
},
{ mode: "graph" as const, label: "Graph", icon: GraphIcon },
{
mode: "integrations" as const,
@ -231,6 +239,34 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<Plus className="h-4 w-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("dashboard")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Home className="h-4 w-4 text-[#737373]" />
Dashboard
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("list")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<LayoutGrid className="h-4 w-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("graph")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<GraphIcon className="h-4 w-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenSearch?.()}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<SearchIcon className="h-4 w-4 text-[#737373]" />
<span className="sr-only">Search</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setViewMode("integrations")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
@ -250,7 +286,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
onClick={handleFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
<LifeBuoy className="h-4 w-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
@ -265,56 +301,116 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</>
) : (
<>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onAddMemory}
>
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full text-base h-10! shrink-0",
"max-lg:w-10 max-lg:min-w-10 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:gap-2 lg:px-4 lg:w-auto lg:min-w-0",
dmSansClassName(),
)}
onClick={onAddMemory}
aria-label="Add memory"
>
<Plus className="size-4 shrink-0" />
<span className="max-lg:sr-only">Add memory</span>
<span
className={cn(
"max-lg:hidden bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
C
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full text-base h-10! shrink-0",
"max-lg:w-10 max-lg:min-w-10 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:gap-2 lg:px-4 lg:w-auto lg:min-w-0",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
<span className="max-lg:hidden bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (⌘K)
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full h-10! w-10 min-w-10 justify-center px-0 lg:hidden",
dmSansClassName(),
)}
aria-label="More actions"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenSearch}
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<DropdownMenuItem
onClick={handleFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
<LifeBuoy className="h-4 w-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
className={cn(
"hidden lg:flex rounded-full text-base gap-2 h-10!",
dmSansClassName(),
)}
onClick={handleFeedback}
>
<div className="flex items-center gap-2">
<MessageCircleIcon className="size-4" />
<LifeBuoy className="size-4" />
Feedback
</div>
</Button>

View file

@ -71,10 +71,12 @@ export function MemoryGraph({
maxNodes={maxNodes}
canvasRef={canvasRef}
totalCount={totalCount}
colors={{
bg: "transparent",
edgeDerives: "#9ca3af",
} as any}
colors={
{
bg: "transparent",
edgeDerives: "#9ca3af",
} as any
}
{...rest}
>
{children}

View file

@ -5,7 +5,7 @@ import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
ChevronsLeftRight,
ChevronDown,
Plus,
Trash2,
XIcon,
@ -57,8 +57,13 @@ export interface SpaceSelectorProps {
}
const triggerVariants = {
default: "px-3 py-2 rounded-md hover:bg-white/5",
insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out",
default:
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
"hover:bg-white/5 " +
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
insideOut:
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
}
export function SpaceSelector({
@ -235,29 +240,71 @@ export function SpaceSelector({
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={
isLoading
? "Loading spaces"
: `Space: ${displayInfo.name}. Open menu to switch.`
}
className={cn(
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
triggerVariants[variant],
variant === "default" &&
compact &&
"h-9 min-h-9 gap-1.5 px-2.5",
dmSansClassName(),
triggerClassName,
)}
>
{isNovaSpaces ? (
<Globe className="size-4 text-white" />
<Globe
className={cn(
"shrink-0",
variant === "insideOut" ? "text-white" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : displayInfo.isMultiple ? (
<Layers className="size-4 text-white" />
<Layers
className={cn(
"shrink-0",
variant === "insideOut" ? "text-white" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : (
<span className="text-sm font-bold tracking-[-0.98px]">
<span
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
aria-hidden
>
{displayInfo.emoji}
</span>
)}
{!compact && (
<span className="text-sm font-medium text-white">
{isLoading ? "..." : displayInfo.name}
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
"max-w-[10rem] md:max-w-[15rem]",
)}
>
{isLoading ? "…" : displayInfo.name}
</span>
)}
{compact && (
<span className="sr-only">
{isLoading ? "Loading" : displayInfo.name}
</span>
)}
{showChevron && (
<ChevronsLeftRight className="size-4 rotate-90 text-white/70" />
<ChevronDown
className={cn(
"shrink-0 opacity-90",
variant === "insideOut" ? "text-white/80" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
)}
</button>
</DropdownMenuTrigger>

View file

@ -1,5 +1,6 @@
"use client"
import { useCustomer } from "autumn-js/react"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import {
@ -15,6 +16,7 @@ import { LogOut, Settings, RotateCcw, HelpCircle } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
export function UserProfileMenu({
className,
@ -26,6 +28,11 @@ export function UserProfileMenu({
const { user } = useAuth()
const router = useRouter()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
const planBadgeLabel =
currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
const handleTryOnboarding = () => {
resetOrgOnboarded()
@ -50,22 +57,38 @@ export function UserProfileMenu({
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={
planBadgeLabel
? `Account menu, ${planBadgeLabel} plan`
: "Account menu"
}
className={cn(
"rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-transform hover:scale-105",
"relative inline-flex shrink-0 rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className,
)}
>
<Avatar
className={cn(
"border border-[#2E3033] h-8 w-8 md:h-10 md:w-10",
"size-9 border border-[#161F2C]",
avatarClassName,
)}
>
<AvatarImage src={user.image ?? ""} />
<AvatarFallback className="bg-[#0D121A] text-white">
<AvatarFallback className="bg-[#0D121A] text-xs font-medium text-white">
{user.name?.charAt(0)}
</AvatarFallback>
</Avatar>
{!planLoading && planBadgeLabel ? (
<span
id="user-plan-badge"
className={cn(
"pointer-events-none absolute -bottom-0.5 left-1/2 z-10 -translate-x-1/2 rounded border px-1 py-px text-center text-[8px] font-bold uppercase leading-tight tracking-wide",
"border-[#2261CA33] bg-[#00173C] text-[#6BB0FF]",
)}
>
{planBadgeLabel}
</span>
) : null}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -29,8 +29,9 @@ export const analytics = {
chatHistoryViewed: () => safeCapture("chat_history_viewed"),
chatDeleted: () => safeCapture("chat_deleted"),
viewModeChanged: (mode: "graph" | "list" | "integrations" | "chat") =>
safeCapture("view_mode_changed", { mode }),
viewModeChanged: (
mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
) => safeCapture("view_mode_changed", { mode }),
documentCardClicked: () => safeCapture("document_card_clicked"),

View file

@ -23,12 +23,19 @@ export const shareParam = parseAsBoolean.withDefault(false)
export const feedbackParam = parseAsBoolean.withDefault(false)
// View & filter states
const viewLiterals = ["graph", "list", "integrations", "chat"] as const
const viewLiterals = [
"dashboard",
"graph",
"list",
"integrations",
"chat",
] as const
const integrationLiterals = ["import", "chrome", "connections"] as const
export type IntegrationParamValue = (typeof integrationLiterals)[number]
export const integrationParam = parseAsStringLiteral(integrationLiterals)
export type ViewParamValue = (typeof viewLiterals)[number]
export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
export const viewParam =
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
export const pluginsPanelParam = parseAsBoolean
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(

View file

@ -197,7 +197,11 @@ export function MemoryGraph({
setViewportVersion((v) => v + 1)
}
const { hasMore: more, isLoadingMore: loading, onLoadMore: load } = loadMoreRef.current
const {
hasMore: more,
isLoadingMore: loading,
onLoadMore: load,
} = loadMoreRef.current
if (!more || loading || !load || !viewportRef.current) return
const vp = viewportRef.current
@ -205,14 +209,17 @@ export function MemoryGraph({
if (currentNodes.length === 0) return
const topLeft = vp.screenToWorld(0, 0)
const bottomRight = vp.screenToWorld(containerSize.width, containerSize.height)
const bottomRight = vp.screenToWorld(
containerSize.width,
containerSize.height,
)
const viewW = bottomRight.x - topLeft.x
const viewH = bottomRight.y - topLeft.y
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const n of currentNodes) {
if (n.x < minX) minX = n.x
if (n.y < minY) minY = n.y
@ -613,7 +620,6 @@ export function MemoryGraph({
colors={colors}
/>
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
<div style={emptyStateStyle}>{children}</div>
)}

View file

@ -119,10 +119,16 @@ export async function realClaudeMemoryExample() {
const toolResults = []
if (responseData.content) {
const memoryToolCalls = responseData.content.filter(
(block: any): block is { type: 'tool_use'; id: string; name: 'memory'; input: { command: MemoryCommand; path: string } } =>
block.type === "tool_use" && block.name === "memory",
)
const memoryToolCalls = responseData.content.filter(
(
block: any,
): block is {
type: "tool_use"
id: string
name: "memory"
input: { command: MemoryCommand; path: string }
} => block.type === "tool_use" && block.name === "memory",
)
const results = await Promise.all(
memoryToolCalls.map((block: any) => {
@ -196,10 +202,16 @@ export async function processClaudeResponse(
const toolResults = []
if (claudeResponseData.content) {
const memoryToolCalls = claudeResponseData.content.filter(
(block: any): block is { type: 'tool_use'; id: string; name: 'memory'; input: { command: MemoryCommand; path: string } } =>
block.type === "tool_use" && block.name === "memory",
)
const memoryToolCalls = claudeResponseData.content.filter(
(
block: any,
): block is {
type: "tool_use"
id: string
name: "memory"
input: { command: MemoryCommand; path: string }
} => block.type === "tool_use" && block.name === "memory",
)
const results = await Promise.all(
memoryToolCalls.map((block: any) =>