feat(web): org brain switcher in header + plan badges (#1006)

- Replace the top-left logo menu with an org switcher (Switch brain): lists organizations with avatar monograms, brand-blue active row, and the active org name (sans "Organization") shown above the supermemory wordmark.
- Switching an org persists the active org and reloads so all data re-scopes.
- Show each org's plan badge in the switcher, reusing the account settings badge (extracted OrgPlanBadge + resolveOrgPlan to a shared component).
- Match the profile dropdown to the same design (rows, hover/focus, border).
- Remove the 'Be part of the next supermemory app' research CTA card.
- Add hideCount prop to SpaceSelector; hide the space count in chat input.
This commit is contained in:
MaheshtheDev 2026-05-26 00:53:32 +00:00
parent fdecae74fc
commit aedcbeb321
9 changed files with 155 additions and 78 deletions

View file

@ -1,14 +1,12 @@
"use client"
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { NextAppResearchCta } from "@/components/next-app-research-cta"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<EnsureWorkspace>{children}</EnsureWorkspace>
<NextAppResearchCta />
<PWAInstallPrompt />
</>
)

View file

@ -59,6 +59,7 @@ export function HomeChatComposer({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>

View file

@ -980,6 +980,7 @@ export function ChatSidebar({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-10 min-h-10 max-w-[min(192px,42vw)] border border-[#73737333] bg-[#0D121A] shadow-[1.5px_1.5px_4.5px_0_rgba(0,0,0,0.70)_inset]"
/>
</>
@ -1176,6 +1177,7 @@ export function ChatSidebar({
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>

View file

@ -39,14 +39,39 @@ import { FeedbackModal } from "./feedback-modal"
import { useViewMode } from "@/lib/view-mode-context"
import { useQueryState } from "nuqs"
import { feedbackParam } from "@/lib/search-params"
import { useCustomer } from "autumn-js/react"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
interface HeaderProps {
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",
)
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring } = useAuth()
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const planByOrgId = new Map(
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
const isMobile = useIsMobile()
@ -66,6 +91,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const topLabel = orgLabel || userName
const hasOrgs = (organizations?.length ?? 0) > 0
const selectOrg = (slug: string, isActive: boolean) => {
if (isActive) return
void setActiveOrg(slug).then(() => window.location.reload())
}
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
@ -73,34 +106,65 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none md:-ml-2"
className="relative flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
>
<Logo className="h-6 md:h-7" />
{userName && (
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
{userName}
</p>
<p className="-mt-0.5 text-sm leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
)}
<Logo className="h-6" />
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
<p className="max-w-[16ch] truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
{topLabel}
</p>
<p className="-mt-0.5 text-sm leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={12}
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)]",
"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="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<Link href="/">
<Home className="size-4 text-[#737373]" />
@ -109,7 +173,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<a
href="https://console.supermemory.ai"
@ -122,7 +186,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="size-4 text-[#737373]" />
@ -290,49 +354,49 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
>
<DropdownMenuItem
onClick={onAddMemory}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<Plus className="size-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"
className="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"
>
<Home className="size-4 text-[#737373]" />
Home
</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"
className="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"
>
<Sun className="size-4 text-[#737373]" />
Integrations
</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"
className="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"
>
<GraphIcon className="size-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"
className="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"
>
<SearchIcon className="size-4 text-[#737373]" />
Search
</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"
className="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"
>
<LayoutGrid className="size-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("chat")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<MessageCircleIcon className="size-4 text-[#737373]" />
Chat with Nova
@ -340,14 +404,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<DropdownMenuSeparator className="bg-[#2E3033]" />
<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"
className="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"
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<Settings className="size-4 text-[#737373]" />
Settings

View file

@ -0,0 +1,35 @@
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { PLAN_DISPLAY_NAMES, type PlanType } from "@/hooks/use-token-usage"
const orgPlanBadgeBase = cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
)
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
export function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
export function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}

View file

@ -5,9 +5,9 @@ import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import {
PLAN_DISPLAY_NAMES,
PLAN_RANK,
useTokenUsage,
type PlanType,
@ -76,25 +76,6 @@ function SettingsCard({ children }: { children: React.ReactNode }) {
}
/** Matches ACTIVE / RECOMMENDED pills in billing settings. */
const orgPlanBadgeBase = cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
)
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
const ROLE_LABELS: Record<string, string> = {
owner: "Owner",
@ -152,18 +133,6 @@ function isPendingInvitation(invitation: {
return new Date(invitation.expiresAt).getTime() > Date.now()
}
function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}
export default function Account() {
const {
user,

View file

@ -53,6 +53,7 @@ export interface SpaceSelectorProps {
enableDelete?: boolean
compact?: boolean
includeAuto?: boolean
hideCount?: boolean
}
const triggerVariants = {
@ -112,6 +113,7 @@ export function SpaceSelector({
enableDelete = false,
compact = false,
includeAuto = false,
hideCount = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
@ -430,11 +432,14 @@ export function SpaceSelector({
>
{isLoading ? "…" : displayInfo.name}
</span>
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
{!compact &&
!hideCount &&
spaceCountData !== undefined &&
spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden

View file

@ -125,28 +125,28 @@ export function UserProfileMenu({
<DropdownMenuContent
align="end"
className={cn(
"min-w-[220px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
"min-w-[240px] 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%)",
}}
>
<div id="user-info" className="px-3 py-2.5">
<div id="user-info" className="px-2.5 py-2">
<p className="text-white text-sm font-medium truncate">{user.name}</p>
<p className="text-[#737373] text-xs truncate">{user.email}</p>
</div>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleTryOnboarding}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<RotateCcw className="size-4 text-[#737373]" />
Try onboarding
@ -154,16 +154,16 @@ export function UserProfileMenu({
{onOpenFeedback ? (
<DropdownMenuItem
onClick={onOpenFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<a href="mailto:support@supermemory.com">
<HelpCircle className="size-4 text-[#737373]" />
@ -172,7 +172,7 @@ export function UserProfileMenu({
</DropdownMenuItem>
<DropdownMenuItem
asChild
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<a
href="https://supermemory.link/discord"
@ -190,10 +190,10 @@ export function UserProfileMenu({
Discord
</a>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
onClick={handleSignOut}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
className="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"
>
<LogOut className="size-4 text-[#737373]" />
Logout

View file

@ -26,7 +26,7 @@ interface AuthContextType {
isRestoring: boolean
isSessionPending: boolean
setActiveOrg: (orgSlug: string) => Promise<void>
clearActiveOrg: () => void
clearActiveOrg: () => Promise<void>
updateOrgMetadata: (partial: Record<string, unknown>) => void
refetchActiveOrg: () => Promise<Organization | null>
refetchOrganizations: () => Promise<unknown>
@ -62,7 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, slug)
}, [])
const clearActiveOrg = useCallback(() => {
const clearActiveOrg = useCallback(async () => {
try {
await authClient.organization.setActive({ organizationId: null })
} catch {}
setOrg(null)
try {
localStorage.removeItem(STORAGE_KEY)