From aedcbeb321bc6c21d7600af51fee7a23d225cfe8 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 26 May 2026 00:53:32 +0000 Subject: [PATCH] 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. --- apps/web/app/(app)/layout.tsx | 2 - .../components/chat/home-chat-composer.tsx | 1 + apps/web/components/chat/index.tsx | 2 + apps/web/components/header.tsx | 116 ++++++++++++++---- apps/web/components/org-plan-badge.tsx | 35 ++++++ apps/web/components/settings/account.tsx | 33 +---- apps/web/components/space-selector.tsx | 15 ++- apps/web/components/user-profile-menu.tsx | 22 ++-- packages/lib/auth-context.tsx | 7 +- 9 files changed, 155 insertions(+), 78 deletions(-) create mode 100644 apps/web/components/org-plan-badge.tsx diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index b2a27e07..331f950c 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -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 ( <> {children} - ) diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 9e50fe33..8f4188a8 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -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]" /> diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 98dc46d7..db4be786 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -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]" /> diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 9d840b70..6847e5cd 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -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 (
@@ -73,34 +106,65 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { + {hasOrgs && ( + <> +

+ Switch brain +

+
+ {organizations?.map((o) => { + const active = org?.id === o.id + const plan = resolveOrgPlan( + o.id, + active, + currentPlan, + planByOrgId, + ) + return ( + selectOrg(o.slug, active)} + className={cn(brainItemClass(active))} + > + + {o.name?.trim().charAt(0).toUpperCase() || "?"} + + {o.name} + + + ) + })} +
+ + + )} @@ -109,7 +173,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { @@ -290,49 +354,49 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { > Add memory 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 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" > Integrations 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" > Graph 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" > Search 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" > Memories 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" > Chat with Nova @@ -340,14 +404,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { Feedback 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 diff --git a/apps/web/components/org-plan-badge.tsx b/apps/web/components/org-plan-badge.tsx new file mode 100644 index 00000000..e5775e65 --- /dev/null +++ b/apps/web/components/org-plan-badge.tsx @@ -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 = { + 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 ( + + {PLAN_DISPLAY_NAMES[plan]} + + ) +} + +export function resolveOrgPlan( + orgId: string, + isCurrent: boolean, + currentPlan: PlanType, + planByOrgId: Map, +): PlanType { + const fromSummary = planByOrgId.get(orgId) + if (fromSummary) return fromSummary + if (isCurrent) return currentPlan + return "free" +} diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index a4a48380..f4ab9a52 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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 = { - 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 ( - - {PLAN_DISPLAY_NAMES[plan]} - - ) -} const ROLE_LABELS: Record = { 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, -): PlanType { - const fromSummary = planByOrgId.get(orgId) - if (fromSummary) return fromSummary - if (isCurrent) return currentPlan - return "free" -} - export default function Account() { const { user, diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index 5a18937e..f74d57da 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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} - {!compact && spaceCountData !== undefined && spaceCountData > 0 && ( - - · {formatCount(spaceCountData)} - - )} + {!compact && + !hideCount && + spaceCountData !== undefined && + spaceCountData > 0 && ( + + · {formatCount(spaceCountData)} + + )} -