Skip to content

Commit ef0026a

Browse files
committed
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
1 parent 2cebe81 commit ef0026a

7 files changed

Lines changed: 322 additions & 23 deletions

File tree

apps/web/components/settings/company-brain-automations.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"use client"
22

3-
import { authClient } from "@lib/auth"
43
import { useAuth } from "@lib/auth-context"
54
import { cn } from "@lib/utils"
65
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@@ -43,6 +42,7 @@ import {
4342
TooltipTrigger,
4443
} from "@ui/components/tooltip"
4544
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
45+
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
4646
import { dmSans125ClassName } from "@/lib/fonts"
4747

4848
const BACKEND =
@@ -873,16 +873,7 @@ export default function CompanyBrainAutomations() {
873873
const removeDraft = (key: number) =>
874874
setDrafts((d) => d.filter((x) => x.key !== key))
875875

876-
const roleQuery = useQuery({
877-
queryKey: ["company-brain-automations", "role"],
878-
queryFn: async () =>
879-
(await authClient.organization.getActiveMember()).data?.role ?? null,
880-
staleTime: 60_000,
881-
enabled: isCompanyBrain,
882-
})
883-
const isAdmin = ["owner", "admin"].includes(
884-
(roleQuery.data ?? "").toLowerCase(),
885-
)
876+
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
886877

887878
const listQuery = useQuery({
888879
queryKey: ["company-brain-automations", "list", org?.id],

apps/web/components/settings/company-brain-connections.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"use client"
22

3-
import { authClient } from "@lib/auth"
3+
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
44
import { cn } from "@lib/utils"
5-
import { useQuery } from "@tanstack/react-query"
65
import { Loader2, Lock } from "lucide-react"
76
import { useCallback, useEffect, useState } from "react"
87
import { toast } from "sonner"
@@ -223,15 +222,7 @@ export default function CompanyBrainConnections() {
223222
const [customName, setCustomName] = useState("")
224223
const [customServerUrl, setCustomServerUrl] = useState("")
225224

226-
const roleQuery = useQuery({
227-
queryKey: ["company-brain-connections", "role"],
228-
queryFn: async () =>
229-
(await authClient.organization.getActiveMember()).data?.role ?? null,
230-
staleTime: 60_000,
231-
enabled: isCompanyBrain,
232-
})
233-
const role = (roleQuery.data ?? "").toLowerCase()
234-
const isAdmin = role === "owner" || role === "admin"
225+
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
235226

236227
const load = useCallback(async () => {
237228
const [catRes, connRes, slackRes] = await Promise.all([
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"use client"
2+
3+
import { cn } from "@lib/utils"
4+
import { Loader2, Lock } from "lucide-react"
5+
import { useMemo, useState } from "react"
6+
import {
7+
Select,
8+
SelectContent,
9+
SelectItem,
10+
SelectTrigger,
11+
SelectValue,
12+
} from "@ui/components/select"
13+
import {
14+
type BrainModelRole,
15+
useBrainModels,
16+
useUpdateBrainModels,
17+
} from "@/hooks/use-brain-models"
18+
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
19+
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
20+
import { dmSans125ClassName } from "@/lib/fonts"
21+
22+
const MODEL_LABELS: Record<string, string> = {
23+
"claude-sonnet-5": "Sonnet 5",
24+
"claude-opus-4.8": "Opus 4.8",
25+
"claude-sonnet-4.6": "Sonnet 4.6",
26+
"claude-haiku-4.5": "Haiku 4.5",
27+
"grok-4.5": "Grok 4.5",
28+
"grok-4.3": "Grok 4.3",
29+
"grok-4-fast": "Grok 4 Fast",
30+
"gpt-5.6": "GPT-5.6",
31+
"gpt-5.5": "GPT-5.5",
32+
}
33+
34+
const labelFor = (id: string) => MODEL_LABELS[id] ?? id
35+
36+
const ROWS: { role: BrainModelRole; title: string; help: string }[] = [
37+
{
38+
role: "main",
39+
title: "Main model",
40+
help: "Reasoning, tool use, and the final Slack answer.",
41+
},
42+
{
43+
role: "triage",
44+
title: "Triage model",
45+
help: "Decides whether and how the brain replies to a message.",
46+
},
47+
{
48+
role: "research",
49+
title: "Research model",
50+
help: "Grounded web research during company research.",
51+
},
52+
]
53+
54+
const controlClass = cn(
55+
dmSans125ClassName(),
56+
"h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
57+
)
58+
const selectContentClass = cn(
59+
dmSans125ClassName(),
60+
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
61+
)
62+
const selectItemClass =
63+
"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"
64+
65+
function SectionTitle({ children }: { children: React.ReactNode }) {
66+
return (
67+
<p
68+
className={cn(
69+
dmSans125ClassName(),
70+
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
71+
)}
72+
>
73+
{children}
74+
</p>
75+
)
76+
}
77+
78+
export default function CompanyBrainModels() {
79+
const isCompanyBrain = useHasCompanyBrain()
80+
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
81+
82+
const modelsQuery = useBrainModels(isCompanyBrain)
83+
const update = useUpdateBrainModels()
84+
85+
const [draft, setDraft] = useState<Partial<Record<BrainModelRole, string>>>(
86+
{},
87+
)
88+
89+
const resolved = modelsQuery.data?.resolved
90+
const defaults = modelsQuery.data?.defaults
91+
const choices = modelsQuery.data?.choices
92+
93+
const valueFor = (role: BrainModelRole): string =>
94+
draft[role] ?? resolved?.[role] ?? ""
95+
96+
const dirty = useMemo(() => {
97+
if (!resolved) return false
98+
return ROWS.some(
99+
({ role }) => draft[role] && draft[role] !== resolved[role],
100+
)
101+
}, [draft, resolved])
102+
103+
if (!isCompanyBrain) return null
104+
105+
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
106+
107+
return (
108+
<section className="flex flex-col gap-4 px-1">
109+
<div className="flex flex-col gap-0.5">
110+
<SectionTitle>Models</SectionTitle>
111+
<span
112+
className={cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")}
113+
>
114+
Choose which models Company Brain uses. Applies to this organization
115+
only.
116+
</span>
117+
</div>
118+
119+
{modelsQuery.isLoading ? (
120+
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
121+
<Loader2 className="size-4 animate-spin" />
122+
Loading models…
123+
</div>
124+
) : modelsQuery.isError ? (
125+
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
126+
Couldn't load models.
127+
</p>
128+
) : (
129+
<div className="flex flex-col gap-4">
130+
{ROWS.map(({ role, title, help }) => {
131+
const options = choices?.[role] ?? []
132+
const current = valueFor(role)
133+
return (
134+
<div key={role} className="flex flex-col gap-1.5">
135+
<div className="flex items-center justify-between gap-3">
136+
<span
137+
className={cn(
138+
dmSans125ClassName(),
139+
"text-[13px] font-medium text-[#FAFAFA]",
140+
)}
141+
>
142+
{title}
143+
</span>
144+
{defaults?.[role] === current ? (
145+
<span
146+
className={cn(
147+
dmSans125ClassName(),
148+
"text-[11px] text-[#737373]",
149+
)}
150+
>
151+
Default
152+
</span>
153+
) : null}
154+
</div>
155+
<Select
156+
value={current}
157+
disabled={disabled}
158+
onValueChange={(v) => setDraft((d) => ({ ...d, [role]: v }))}
159+
>
160+
<SelectTrigger className={controlClass}>
161+
<SelectValue placeholder="Select a model…" />
162+
</SelectTrigger>
163+
<SelectContent className={selectContentClass}>
164+
{options.map((id) => (
165+
<SelectItem
166+
key={id}
167+
value={id}
168+
className={selectItemClass}
169+
>
170+
{labelFor(id)}
171+
{defaults?.[role] === id ? " (default)" : ""}
172+
</SelectItem>
173+
))}
174+
</SelectContent>
175+
</Select>
176+
<span
177+
className={cn(
178+
dmSans125ClassName(),
179+
"text-[12px] text-[#9A9A9A]",
180+
)}
181+
>
182+
{help}
183+
</span>
184+
</div>
185+
)
186+
})}
187+
188+
{!isAdmin ? (
189+
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
190+
<Lock className="size-3.5" />
191+
Only organization admins can change these.
192+
</div>
193+
) : (
194+
<div className="flex justify-end">
195+
<button
196+
type="button"
197+
disabled={disabled || !dirty}
198+
onClick={() => {
199+
const patch: Partial<Record<BrainModelRole, string>> = {}
200+
for (const { role } of ROWS) {
201+
if (draft[role] && draft[role] !== resolved?.[role]) {
202+
patch[role] = draft[role]
203+
}
204+
}
205+
update.mutate(patch, { onSuccess: () => setDraft({}) })
206+
}}
207+
className={cn(
208+
dmSans125ClassName(),
209+
"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",
210+
)}
211+
>
212+
{update.isPending ? (
213+
<Loader2 className="size-3.5 animate-spin" />
214+
) : null}
215+
Save
216+
</button>
217+
</div>
218+
)}
219+
</div>
220+
)}
221+
</section>
222+
)
223+
}

apps/web/components/settings/settings-content.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import ConnectionsMCP from "@/components/settings/connections-mcp"
1313
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
1414
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
1515
import Proactiveness from "@/components/settings/proactiveness"
16+
import CompanyBrainModels from "@/components/settings/company-brain-models"
1617
import Support from "@/components/settings/support"
1718
import { ErrorBoundary } from "@/components/error-boundary"
1819
import { useRouter } from "next/navigation"
@@ -30,6 +31,7 @@ import {
3031
Zap,
3132
HelpCircle,
3233
CreditCard,
34+
Cpu,
3335
ShieldAlert,
3436
ChevronRight,
3537
ArrowUpRight,
@@ -55,6 +57,7 @@ export const TABS = [
5557
"integrations",
5658
"connections",
5759
"company-brain",
60+
"company-brain-models",
5861
"proactiveness",
5962
"support",
6063
] as const
@@ -98,6 +101,12 @@ const NAV_ITEMS: NavItem[] = [
98101
description: "Connect apps to your brain — org and personal",
99102
icon: <Building2 className="size-[18px]" />,
100103
},
104+
{
105+
id: "company-brain-models",
106+
label: "Models",
107+
description: "Choose the models your brain uses",
108+
icon: <Cpu className="size-[18px]" />,
109+
},
101110
{
102111
id: "proactiveness",
103112
label: "Proactiveness",
@@ -171,7 +180,7 @@ export function SettingsContent({
171180
? NAV_ITEMS.filter(
172181
(item) => item.id !== "integrations" && item.id !== "connections",
173182
)
174-
: NAV_ITEMS
183+
: NAV_ITEMS.filter((item) => item.id !== "company-brain-models")
175184
const router = useRouter()
176185
const isMobile = useIsMobile()
177186
const localStorageUsername = useLocalStorageUsername()
@@ -499,6 +508,7 @@ export function SettingsContent({
499508
{activeTab === "integrations" && <Integrations />}
500509
{activeTab === "connections" && <ConnectionsMCP />}
501510
{activeTab === "company-brain" && <CompanyBrainConnections />}
511+
{activeTab === "company-brain-models" && <CompanyBrainModels />}
502512
{activeTab === "proactiveness" && <Proactiveness />}
503513
{activeTab === "support" && <Support />}
504514
</ErrorBoundary>

0 commit comments

Comments
 (0)