feat(brain): Company Brain Models settings tab (#1292)

Adds an admin-gated Models tab (main/triage/research pickers) that reads/writes the mono /brain/models endpoint, shown only for Company Brain orgs. Extracts a shared useOrgMemberRole hook so the brain settings sections dedupe the getActiveMember call.

Fixes ENG-1054
This commit is contained in:
MaheshtheDev 2026-07-14 18:56:01 +00:00
parent 2cebe81512
commit ef0026a23c
7 changed files with 322 additions and 23 deletions

View file

@ -1,6 +1,5 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@ -43,6 +42,7 @@ import {
TooltipTrigger,
} from "@ui/components/tooltip"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
@ -873,16 +873,7 @@ export default function CompanyBrainAutomations() {
const removeDraft = (key: number) =>
setDrafts((d) => d.filter((x) => x.key !== key))
const roleQuery = useQuery({
queryKey: ["company-brain-automations", "role"],
queryFn: async () =>
(await authClient.organization.getActiveMember()).data?.role ?? null,
staleTime: 60_000,
enabled: isCompanyBrain,
})
const isAdmin = ["owner", "admin"].includes(
(roleQuery.data ?? "").toLowerCase(),
)
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const listQuery = useQuery({
queryKey: ["company-brain-automations", "list", org?.id],

View file

@ -1,8 +1,7 @@
"use client"
import { authClient } from "@lib/auth"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { Loader2, Lock } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
@ -223,15 +222,7 @@ export default function CompanyBrainConnections() {
const [customName, setCustomName] = useState("")
const [customServerUrl, setCustomServerUrl] = useState("")
const roleQuery = useQuery({
queryKey: ["company-brain-connections", "role"],
queryFn: async () =>
(await authClient.organization.getActiveMember()).data?.role ?? null,
staleTime: 60_000,
enabled: isCompanyBrain,
})
const role = (roleQuery.data ?? "").toLowerCase()
const isAdmin = role === "owner" || role === "admin"
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const load = useCallback(async () => {
const [catRes, connRes, slackRes] = await Promise.all([

View file

@ -0,0 +1,223 @@
"use client"
import { cn } from "@lib/utils"
import { Loader2, Lock } from "lucide-react"
import { useMemo, useState } from "react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import {
type BrainModelRole,
useBrainModels,
useUpdateBrainModels,
} from "@/hooks/use-brain-models"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { dmSans125ClassName } from "@/lib/fonts"
const MODEL_LABELS: Record<string, string> = {
"claude-sonnet-5": "Sonnet 5",
"claude-opus-4.8": "Opus 4.8",
"claude-sonnet-4.6": "Sonnet 4.6",
"claude-haiku-4.5": "Haiku 4.5",
"grok-4.5": "Grok 4.5",
"grok-4.3": "Grok 4.3",
"grok-4-fast": "Grok 4 Fast",
"gpt-5.6": "GPT-5.6",
"gpt-5.5": "GPT-5.5",
}
const labelFor = (id: string) => MODEL_LABELS[id] ?? id
const ROWS: { role: BrainModelRole; title: string; help: string }[] = [
{
role: "main",
title: "Main model",
help: "Reasoning, tool use, and the final Slack answer.",
},
{
role: "triage",
title: "Triage model",
help: "Decides whether and how the brain replies to a message.",
},
{
role: "research",
title: "Research model",
help: "Grounded web research during company research.",
},
]
const controlClass = cn(
dmSans125ClassName(),
"h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
)
const selectContentClass = cn(
dmSans125ClassName(),
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
)
const selectItemClass =
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
</p>
)
}
export default function CompanyBrainModels() {
const isCompanyBrain = useHasCompanyBrain()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const modelsQuery = useBrainModels(isCompanyBrain)
const update = useUpdateBrainModels()
const [draft, setDraft] = useState<Partial<Record<BrainModelRole, string>>>(
{},
)
const resolved = modelsQuery.data?.resolved
const defaults = modelsQuery.data?.defaults
const choices = modelsQuery.data?.choices
const valueFor = (role: BrainModelRole): string =>
draft[role] ?? resolved?.[role] ?? ""
const dirty = useMemo(() => {
if (!resolved) return false
return ROWS.some(
({ role }) => draft[role] && draft[role] !== resolved[role],
)
}, [draft, resolved])
if (!isCompanyBrain) return null
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
return (
<section className="flex flex-col gap-4 px-1">
<div className="flex flex-col gap-0.5">
<SectionTitle>Models</SectionTitle>
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")}
>
Choose which models Company Brain uses. Applies to this organization
only.
</span>
</div>
{modelsQuery.isLoading ? (
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
<Loader2 className="size-4 animate-spin" />
Loading models
</div>
) : modelsQuery.isError ? (
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
Couldn't load models.
</p>
) : (
<div className="flex flex-col gap-4">
{ROWS.map(({ role, title, help }) => {
const options = choices?.[role] ?? []
const current = valueFor(role)
return (
<div key={role} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span
className={cn(
dmSans125ClassName(),
"text-[13px] font-medium text-[#FAFAFA]",
)}
>
{title}
</span>
{defaults?.[role] === current ? (
<span
className={cn(
dmSans125ClassName(),
"text-[11px] text-[#737373]",
)}
>
Default
</span>
) : null}
</div>
<Select
value={current}
disabled={disabled}
onValueChange={(v) => setDraft((d) => ({ ...d, [role]: v }))}
>
<SelectTrigger className={controlClass}>
<SelectValue placeholder="Select a model…" />
</SelectTrigger>
<SelectContent className={selectContentClass}>
{options.map((id) => (
<SelectItem
key={id}
value={id}
className={selectItemClass}
>
{labelFor(id)}
{defaults?.[role] === id ? " (default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#9A9A9A]",
)}
>
{help}
</span>
</div>
)
})}
{!isAdmin ? (
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<Lock className="size-3.5" />
Only organization admins can change these.
</div>
) : (
<div className="flex justify-end">
<button
type="button"
disabled={disabled || !dirty}
onClick={() => {
const patch: Partial<Record<BrainModelRole, string>> = {}
for (const { role } of ROWS) {
if (draft[role] && draft[role] !== resolved?.[role]) {
patch[role] = draft[role]
}
}
update.mutate(patch, { onSuccess: () => setDraft({}) })
}}
className={cn(
dmSans125ClassName(),
"inline-flex h-8 items-center gap-1.5 rounded-full bg-white px-4 text-[12px] font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-40",
)}
>
{update.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : null}
Save
</button>
</div>
)}
</div>
)}
</section>
)
}

View file

@ -13,6 +13,7 @@ import ConnectionsMCP from "@/components/settings/connections-mcp"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import Proactiveness from "@/components/settings/proactiveness"
import CompanyBrainModels from "@/components/settings/company-brain-models"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
@ -30,6 +31,7 @@ import {
Zap,
HelpCircle,
CreditCard,
Cpu,
ShieldAlert,
ChevronRight,
ArrowUpRight,
@ -55,6 +57,7 @@ export const TABS = [
"integrations",
"connections",
"company-brain",
"company-brain-models",
"proactiveness",
"support",
] as const
@ -98,6 +101,12 @@ const NAV_ITEMS: NavItem[] = [
description: "Connect apps to your brain — org and personal",
icon: <Building2 className="size-[18px]" />,
},
{
id: "company-brain-models",
label: "Models",
description: "Choose the models your brain uses",
icon: <Cpu className="size-[18px]" />,
},
{
id: "proactiveness",
label: "Proactiveness",
@ -171,7 +180,7 @@ export function SettingsContent({
? NAV_ITEMS.filter(
(item) => item.id !== "integrations" && item.id !== "connections",
)
: NAV_ITEMS
: NAV_ITEMS.filter((item) => item.id !== "company-brain-models")
const router = useRouter()
const isMobile = useIsMobile()
const localStorageUsername = useLocalStorageUsername()
@ -499,6 +508,7 @@ export function SettingsContent({
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "company-brain" && <CompanyBrainConnections />}
{activeTab === "company-brain-models" && <CompanyBrainModels />}
{activeTab === "proactiveness" && <Proactiveness />}
{activeTab === "support" && <Support />}
</ErrorBoundary>

View file

@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BASE = `${BACKEND}/brain/models`
export type BrainModelRole = "main" | "triage" | "research"
export type BrainModelConfig = Record<BrainModelRole, string>
export type BrainModelsResponse = {
resolved: BrainModelConfig
defaults: BrainModelConfig
choices: Record<BrainModelRole, string[]>
}
export function useBrainModels(enabled: boolean) {
const { org } = useAuth()
return useQuery({
queryKey: ["brain", "models", org?.id],
queryFn: async (): Promise<BrainModelsResponse> => {
const res = await fetch(`${BASE}/`, { credentials: "include" })
if (!res.ok) throw new Error("Failed to load models")
return res.json()
},
enabled,
staleTime: 60_000,
})
}
export function useUpdateBrainModels() {
const { org } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: Partial<BrainModelConfig>) => {
const res = await fetch(`${BASE}/`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
body: JSON.stringify(patch),
})
if (res.status === 403)
throw new Error("Only admins can change brain models.")
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as {
message?: string
error?: string
}
throw new Error(b.message ?? b.error ?? "Failed to save models")
}
return res.json()
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["brain", "models", org?.id],
})
toast.success("Brain models saved")
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "Failed to save models"),
})
}

View file

@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
// Shared active-member role for the current org. Single queryKey so the
// company-brain settings sections dedupe the getActiveMember call.
export function useOrgMemberRole(enabled = true) {
const { org } = useAuth()
const query = useQuery({
queryKey: ["org", "member-role", org?.id],
queryFn: async () =>
(await authClient.organization.getActiveMember()).data?.role ?? null,
staleTime: 60_000,
enabled: enabled && !!org?.id,
})
const role = (query.data ?? "").toLowerCase()
const isAdmin = role === "owner" || role === "admin"
return { role, isAdmin, query }
}

View file

@ -228,6 +228,7 @@ export const analytics = {
| "integrations"
| "connections"
| "company-brain"
| "company-brain-models"
| "proactiveness"
| "support"
}) => safeCapture("settings_tab_changed", props),