supermemory/apps/web/components/company-brain-header.tsx
Dhravya Shah eac070048b
feat(web): add memory button to company brain navbar (#1468)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 22:04:01 -07:00

647 lines
20 KiB
TypeScript

"use client"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useIsMobile } from "@hooks/use-mobile"
import { useQuery } from "@tanstack/react-query"
import {
Building2,
Code2,
ExternalLink,
Home,
LifeBuoy,
LayoutGrid,
MenuIcon,
Plus,
SearchIcon,
Settings,
Settings2,
UserPlus,
ChevronRight,
Sun,
} from "lucide-react"
import Link from "next/link"
import { useQueryState } from "nuqs"
import { useCallback } from "react"
import { DomainLogo } from "@/components/onboarding-brain/step-about"
import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons"
import { BrainTrialPill } from "@/components/brain-trial-pill"
import { GraphIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSansClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useProject } from "@/stores"
import { useCustomer } from "autumn-js/react"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SlackStatus = { connected: boolean; teamName: string | null }
interface CompanyBrainHeaderProps {
onAddMemory?: () => void
onOpenSearch?: () => void
}
const brainItemClass = (active: boolean) =>
cn(
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
active ? "bg-white/[0.06] text-white" : "text-white/85",
)
const brainTileClass = (active: boolean) =>
cn(
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
active
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
const menuItemClass =
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
const circleNavClass = (active: boolean) =>
cn(
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)
const tabClass = (active: boolean) =>
cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-foreground hover:bg-white/5",
dmSansClassName(),
)
function useSlackStatus() {
return useQuery({
queryKey: ["brain-slack-status"],
queryFn: async (): Promise<SlackStatus> => {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (!res.ok) return { connected: false, teamName: null }
return (await res.json()) as SlackStatus
},
staleTime: 30_000,
})
}
export function CompanyBrainHeader({
onAddMemory,
onOpenSearch,
}: CompanyBrainHeaderProps) {
const { user, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const { viewMode, setViewMode } = useViewMode()
const { selectedProjects, setSelectedProjects } = useProject()
const { openSettings } = useSettingsModal()
const isMobile = useIsMobile()
const [feedbackOpen, setFeedbackOpen] = useQueryState(
"feedback",
feedbackParam,
)
const [, setInvite] = useQueryState("invite")
const { data: slackStatus } = useSlackStatus()
const planByOrgId = new Map(
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const brandLabel = orgLabel || "Workspace"
const domain = getBrainWorkspaceDomain(
org?.metadata as Record<string, unknown> | string | null | undefined,
)
const hasOrgs = (organizations?.length ?? 0) > 0
const memberRole = org?.members
?.find((m) => m.userId === user?.id)
?.role?.toLowerCase()
const canInvite = memberRole === "owner" || memberRole === "admin"
const isOverview = viewMode === "dashboard"
const isGraph = viewMode === "graph"
const isMemories = viewMode === "list"
const isConfigure = viewMode === "configure"
const slackConnected = slackStatus?.connected ?? false
const selectOrg = useCallback(
(slug: string, isActive: boolean) => {
if (isActive) return
void setActiveOrg(slug).then(() => window.location.reload())
},
[setActiveOrg],
)
const goOverview = useCallback(() => {
void setViewMode("dashboard")
}, [setViewMode])
const goGraph = useCallback(() => {
void setViewMode("graph")
}, [setViewMode])
const goMemories = useCallback(() => {
void setViewMode("list")
}, [setViewMode])
const goConfigure = useCallback(() => {
void setViewMode("configure")
}, [setViewMode])
const goIntegrations = useCallback(() => {
void setViewMode("integrations")
}, [setViewMode])
const handleInvite = useCallback(() => {
setInvite("1")
openSettings("account")
}, [openSettings, setInvite])
const handleFeedback = useCallback(() => {
void setFeedbackOpen(true)
}, [setFeedbackOpen])
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1 px-2 py-2 md:grid md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 flex-1 shrink items-center justify-start gap-1.5 md:justify-self-start md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex min-w-0 max-w-[31vw] shrink cursor-pointer items-center rounded-lg px-1 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none min-[380px]:max-w-[9rem] sm:max-w-[min(52vw,240px)] md:-ml-2 md:max-w-[min(52vw,240px)] md:shrink-0 md:px-1.5 before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] md:before:-inset-x-2 md:before:-inset-y-2.5"
>
<div
className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] sm:size-8"
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
}}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<div className="ml-1.5 min-w-0 flex flex-col items-start justify-center max-[340px]:hidden sm:ml-2">
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
Company Brain
</p>
<p className="-mt-0.5 max-w-full truncate text-sm leading-none font-semibold text-white/90 sm:text-[15px]">
{brandLabel}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={12}
className={cn(
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{hasOrgs && (
<>
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
Switch brain
</p>
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
{organizations?.map((o) => {
const active = org?.id === o.id
const plan = resolveOrgPlan(
o.id,
active,
currentPlan,
planByOrgId,
)
return (
<DropdownMenuItem
key={o.id}
onClick={() => selectOrg(o.slug, active)}
className={cn(brainItemClass(active))}
>
<span className={cn(brainTileClass(active))}>
{o.name?.trim().charAt(0).toUpperCase() || "?"}
</span>
<span className="flex-1 truncate">{o.name}</span>
<OrgPlanBadge plan={plan} />
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
</>
)}
<DropdownMenuItem asChild className={menuItemClass}>
<Link href="/">
<Home className="size-4 text-[#737373]" />
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={goConfigure} className={menuItemClass}>
<Settings2 className="size-4 text-[#737373]" />
Configure
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem asChild className={menuItemClass}>
<a
href="https://console.supermemory.ai"
target="_blank"
rel="noreferrer"
>
<Code2 className="size-4 text-[#737373]" />
Developer console
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className={menuItemClass}>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="size-4 text-[#737373]" />
supermemory.ai
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMobile && (
<>
<ChevronRight
className="size-4 shrink-0 text-[#3F4853]"
aria-hidden
/>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
enableEdit={false}
/>
</>
)}
</div>
{!isMobile && (
<div className="z-10! flex min-w-0 max-w-full items-center justify-center gap-1.5 overflow-hidden px-1 md:justify-self-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Overview"
aria-current={isOverview ? "page" : undefined}
onClick={goOverview}
className={circleNavClass(isOverview)}
>
<Home className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Overview
</TooltipContent>
</Tooltip>
<div
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto snap-x snap-mandatory scroll-fade-x rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<button
type="button"
role="tab"
aria-selected={isGraph}
onClick={goGraph}
className={tabClass(isGraph)}
>
<GraphIcon className="size-3.5 shrink-0 sm:size-4" />
Graph
</button>
<button
type="button"
role="tab"
aria-selected={isMemories}
onClick={goMemories}
className={tabClass(isMemories)}
>
<LayoutGrid className="size-3.5 shrink-0 sm:size-4" />
Memories
</button>
<button
type="button"
role="tab"
aria-selected={isConfigure}
onClick={goConfigure}
className={tabClass(isConfigure)}
>
<Settings2 className="size-3.5 shrink-0 sm:size-4" />
Configure
</button>
</div>
<SlackNavButton
connected={slackConnected}
teamName={slackStatus?.teamName ?? null}
active={isConfigure && slackConnected}
onManage={goConfigure}
/>
</div>
)}
<div className="z-10! flex min-w-0 shrink-0 items-center gap-1.5 md:justify-self-end">
{isMobile ? (
<>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
compact
triggerClassName="max-w-[34vw] shrink min-[380px]:max-w-[9rem]"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
aria-label="Open navigation menu"
className="size-9! min-h-9 min-w-9 rounded-full px-0! text-base"
>
<MenuIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"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%)",
}}
>
{onAddMemory && (
<>
<DropdownMenuItem
onClick={onAddMemory}
className={menuItemClass}
>
<Plus className="size-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
</>
)}
<DropdownMenuItem
onClick={goOverview}
className={menuItemClass}
>
<Home className="size-4 text-[#737373]" />
Overview
</DropdownMenuItem>
<DropdownMenuItem onClick={goGraph} className={menuItemClass}>
<GraphIcon className="size-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={goMemories}
className={menuItemClass}
>
<LayoutGrid className="size-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={goConfigure}
className={menuItemClass}
>
<Settings2 className="size-4 text-[#737373]" />
Configure
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
{slackConnected ? (
<DropdownMenuItem
onClick={goConfigure}
className={menuItemClass}
>
<SlackMark className="size-4" />
Slack connected
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild className={menuItemClass}>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="bg-[#2E3033]" />
{canInvite && (
<DropdownMenuItem
onClick={handleInvite}
className={menuItemClass}
>
<UserPlus className="size-4 text-[#737373]" />
Invite teammates
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onOpenSearch}
className={menuItemClass}
>
<SearchIcon className="size-4 text-[#737373]" />
Search
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={handleFeedback}
className={menuItemClass}
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<BrainTrialPill className="h-9 px-3" />
{onAddMemory && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={onAddMemory}
aria-label="Add memory"
>
<Plus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Add</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
)}
{canInvite && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={handleInvite}
aria-label="Invite teammates"
>
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Invite</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Invite teammates
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (K)
</TooltipContent>
</Tooltip>
</>
)}
<UserProfileMenu onOpenFeedback={handleFeedback} />
</div>
<FeedbackModal
isOpen={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
</div>
)
}
function SlackNavButton({
connected,
teamName,
active,
onManage,
}: {
connected: boolean
teamName: string | null
active: boolean
onManage: () => void
}) {
const label = connected
? `Slack${teamName ? ` · ${teamName}` : ""}`
: "Add to Slack"
if (!connected) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
aria-label="Add to Slack"
className={circleNavClass(false)}
>
<SlackMark className="size-4" />
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add to Slack
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
onClick={onManage}
className={cn(circleNavClass(active), "relative")}
>
<SlackMark className="size-4" />
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-[#2EB67D] ring-2 ring-[#00173C]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
{label}
</TooltipContent>
</Tooltip>
)
}