From ef0026a23c160e2976480352e5a6f58762d70d13 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:56:01 +0000 Subject: [PATCH] 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 --- .../settings/company-brain-automations.tsx | 13 +- .../settings/company-brain-connections.tsx | 13 +- .../settings/company-brain-models.tsx | 223 ++++++++++++++++++ .../components/settings/settings-content.tsx | 12 +- apps/web/hooks/use-brain-models.ts | 64 +++++ apps/web/hooks/use-org-member-role.ts | 19 ++ apps/web/lib/analytics.ts | 1 + 7 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 apps/web/components/settings/company-brain-models.tsx create mode 100644 apps/web/hooks/use-brain-models.ts create mode 100644 apps/web/hooks/use-org-member-role.ts diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx index 26d1fda3..bea9e912 100644 --- a/apps/web/components/settings/company-brain-automations.tsx +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -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], diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index a6fb1ff8..556bc599 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -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([ diff --git a/apps/web/components/settings/company-brain-models.tsx b/apps/web/components/settings/company-brain-models.tsx new file mode 100644 index 00000000..51b2bb9b --- /dev/null +++ b/apps/web/components/settings/company-brain-models.tsx @@ -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 = { + "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 ( +

+ {children} +

+ ) +} + +export default function CompanyBrainModels() { + const isCompanyBrain = useHasCompanyBrain() + const { isAdmin } = useOrgMemberRole(isCompanyBrain) + + const modelsQuery = useBrainModels(isCompanyBrain) + const update = useUpdateBrainModels() + + const [draft, setDraft] = useState>>( + {}, + ) + + 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 ( +
+
+ Models + + Choose which models Company Brain uses. Applies to this organization + only. + +
+ + {modelsQuery.isLoading ? ( +
+ + Loading models… +
+ ) : modelsQuery.isError ? ( +

+ Couldn't load models. +

+ ) : ( +
+ {ROWS.map(({ role, title, help }) => { + const options = choices?.[role] ?? [] + const current = valueFor(role) + return ( +
+
+ + {title} + + {defaults?.[role] === current ? ( + + Default + + ) : null} +
+ + + {help} + +
+ ) + })} + + {!isAdmin ? ( +
+ + Only organization admins can change these. +
+ ) : ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index fa1db533..dd8617d0 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -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: , }, + { + id: "company-brain-models", + label: "Models", + description: "Choose the models your brain uses", + icon: , + }, { 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" && } {activeTab === "connections" && } {activeTab === "company-brain" && } + {activeTab === "company-brain-models" && } {activeTab === "proactiveness" && } {activeTab === "support" && } diff --git a/apps/web/hooks/use-brain-models.ts b/apps/web/hooks/use-brain-models.ts new file mode 100644 index 00000000..e8985eee --- /dev/null +++ b/apps/web/hooks/use-brain-models.ts @@ -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 + +export type BrainModelsResponse = { + resolved: BrainModelConfig + defaults: BrainModelConfig + choices: Record +} + +export function useBrainModels(enabled: boolean) { + const { org } = useAuth() + return useQuery({ + queryKey: ["brain", "models", org?.id], + queryFn: async (): Promise => { + 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) => { + 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"), + }) +} diff --git a/apps/web/hooks/use-org-member-role.ts b/apps/web/hooks/use-org-member-role.ts new file mode 100644 index 00000000..66475e0c --- /dev/null +++ b/apps/web/hooks/use-org-member-role.ts @@ -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 } +} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 7ce5b9df..7b6369f3 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -228,6 +228,7 @@ export const analytics = { | "integrations" | "connections" | "company-brain" + | "company-brain-models" | "proactiveness" | "support" }) => safeCapture("settings_tab_changed", props),