Merge remote-tracking branch 'origin/main' into Prasanna721/dalat-v1

# Conflicts:
#	bun.lock
This commit is contained in:
Prasanna A P 2026-07-20 19:51:40 -07:00
commit 2c6d5f36ed
31 changed files with 1542 additions and 628 deletions

View file

@ -28,6 +28,12 @@
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<strong>#1 on every major AI memory benchmark — <a href="https://github.com/xiaowu0162/LongMemEval">LongMemEval</a>, <a href="https://github.com/snap-research/locomo">LoCoMo</a>, and <a href="https://github.com/Salesforce/ConvoMem">ConvoMem</a>.</strong><br/>
<strong>95% Recall@15 with a 99.4% context reduction · ~50ms user profiles.</strong><br/>
<a href="https://supermemory.ai/research">Read the research →</a>
</p>
---
Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory.
@ -356,10 +362,14 @@ Supermemory is state of the art across all major AI memory benchmarks:
| Benchmark | What it measures | Result |
|---|---|---|
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **81.6% — #1** |
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **#1** |
| **[LoCoMo](https://github.com/snap-research/locomo)** | Fact recall across extended conversations (single-hop, multi-hop, temporal, adversarial) | **#1** |
| **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | Personalization and preference learning | **#1** |
On LongMemEval, supermemory reaches **95% Recall@15 while adding only ~720 tokens of context — a 99.4% context reduction** (99.6% at @10, 99.8% at @5). Recall by category: Knowledge Updates 99%, Assistant recall 100%, User recall 97%, Multi-session 93%, Temporal Reasoning 91%, Preference 90%.
We also built the **Supermemory Filesystem (SMFS)**, which uses **3.0× fewer tokens on Claude** (24M vs 72M) and **1.75× fewer on Codex** across the 110-question xAFS benchmark. See the full write-ups on our [research page](https://supermemory.ai/research).
We also built **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)** — an open-source framework for standardized, reproducible benchmarks of memory providers. Compare Supermemory, Mem0, Zep, and others head-to-head:
```bash

View file

@ -7,7 +7,7 @@ icon: "mail"
Connect Gmail to automatically sync email threads into your supermemory knowledge base. Supports real-time updates via Google Cloud Pub/Sub webhooks and incremental synchronization.
<Note>
**Scale Plan Required:** The Gmail connector is available on Scale and Enterprise plans only.
**Max Plan Required:** The Gmail connector is available on Max plan and above.
</Note>
## Quick Setup

View file

@ -13,10 +13,6 @@ icon: "/images/claude-code-icon.svg"
/>
</div>
<Warning>
This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing).
</Warning>
[supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
<Tip>

View file

@ -5,10 +5,6 @@ description: "OpenCode Supermemory Plugin — persistent memory across coding se
icon: "/images/opencode-logo.png"
---
<Warning>
This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing).
</Warning>
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
<Tip>

View file

@ -86,8 +86,6 @@ export default function BrainOnboardingPage() {
() => workspaceDomainFromEmail(user?.email),
[user?.email],
)
// Team (Company Brain) onboarding is gated behind a private-beta flag.
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
const [mode, setMode] = useState<BrainMode>(detectedMode)
const [about, setAbout] = useState<AboutValues>({
@ -339,8 +337,6 @@ export default function BrainOnboardingPage() {
setCreatingOrg(false)
}
}, [ensureOrg, goNext, forceCreate, organizations, router])
// Company Brain (team) onboarding is a single research surface, no stepper.
const isCompanyBrain = allowTeam && mode === "team"
const handleBrainConfirm = useCallback(

View file

@ -20,6 +20,7 @@ import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
import { ConfigureView } from "@/components/configure-view"
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
import { ChromeDetail } from "@/components/integrations/chrome-detail"
@ -698,6 +699,10 @@ export function AppExperience() {
onOpenDocument={handleOpenDocument}
/>
</div>
) : viewMode === "configure" ? (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! md:p-6">
<ConfigureView />
</div>
) : viewMode === "mcp" ? (
<MCPDetailView
onBack={() => void setViewMode("integrations")}

View file

@ -219,7 +219,7 @@ function StatsRow({
<button
type="button"
onClick={onInvite}
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
className="hidden items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] sm:inline-flex"
>
<UserPlus className="size-3" />
Invite
@ -235,31 +235,64 @@ function StatsRow({
]
return (
<section
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
className="grid grid-cols-4 divide-x divide-white/[0.04] overflow-hidden rounded-[16px] bg-[#1B1F24]"
style={cardStyle}
>
{tiles.map((t) => (
<div key={t.label} className="px-5 py-4">
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
{tiles.map((t, index) => (
<div
key={t.label}
className={cn(
"relative min-w-0 px-3 py-3 sm:px-5 sm:py-4",
index === 2 && canInvite && "pr-8 sm:pr-5",
)}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<p className="min-w-0 truncate text-[8px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#737373] sm:text-[10px] sm:tracking-[0.12em]">
<MobileStatLabel label={t.label} />
</p>
{t.action}
</div>
<p
className={cn(
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
"mt-1 text-[17px] font-semibold leading-none tabular-nums text-[#fafafa] sm:mt-1.5 sm:text-[22px]",
dmSans125ClassName(),
)}
>
{t.value}
</p>
{index === 2 && canInvite && (
<button
type="button"
onClick={onInvite}
aria-label="Invite teammates"
title="Invite teammates"
className="absolute right-1.5 bottom-1.5 inline-flex size-6 items-center justify-center rounded-md bg-white/[0.03] text-[#737373] transition-colors hover:bg-white/[0.07] hover:text-[#fafafa] sm:hidden"
>
<UserPlus className="size-3" />
</button>
)}
</div>
))}
</section>
)
}
function MobileStatLabel({ label }: { label: string }) {
const mobile =
label === "Connected sources"
? "Sources"
: label === "Active members"
? "Members"
: label
return (
<>
<span className="sm:hidden">{mobile}</span>
<span className="hidden sm:inline">{label}</span>
</>
)
}
function RecentMemories({
docs,
loading,

View file

@ -5,11 +5,11 @@ import { ArrowRight, Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useViewMode } from "@/lib/view-mode-context"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
// Preferred ordering for the dashboard; only unconnected apps are surfaced.
const FEATURED_SLUGS: readonly string[] = ["linear", "granola", "sentry"]
// Example prompts on the right card — can include apps not shown on the left.
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
@ -155,76 +155,91 @@ export function ConnectionsBoard() {
}
}
const { openSettings } = useSettingsModal()
const { setViewMode } = useViewMode()
const apps = catalog ?? []
const loading = catalog === null
const featured = FEATURED_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const unconnected = apps.filter((a) => !isConnected(a.slug))
const featured = [
...FEATURED_SLUGS.map((slug) =>
unconnected.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a)),
...unconnected.filter((a) => !FEATURED_SLUGS.includes(a.slug)),
].slice(0, 3)
const overflow = unconnected.filter((a) => !featured.includes(a))
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const remainingCount = Math.max(apps.length - featured.length, 0)
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
const showBoard = loading || unconnected.length > 0
return (
<div className="space-y-4">
{slack && !slack.connected && <SlackBanner />}
<div className="grid items-start gap-4 lg:grid-cols-5">
<section
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
style={cardStyle}
>
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Connect your tools
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
Give your Slack agent live access to the apps your team already
uses.
</p>
</div>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<TileSkeleton key={i} showDivider={i < 2} />
))
) : (
<>
{featured.map((entry, i) => (
<AppTile
key={entry.slug}
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
name={entry.name}
subtitle={titleCase(entry.category)}
connected={isConnected(entry.slug)}
busy={busy === entry.slug}
onConnect={() => connect(entry)}
showDivider={i < featured.length - 1 || remainingCount > 0}
/>
))}
{remainingCount > 0 && (
<MoreTile
count={remainingCount}
onClick={() => openSettings("company-brain")}
/>
{showBoard ? (
<section
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
style={cardStyle}
>
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
</>
)}
</div>
</section>
>
Connect your tools
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
Give your Slack agent live access to the apps your team already
uses.
</p>
</div>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<TileSkeleton key={i} showDivider={i < 2} />
))
) : (
<>
{featured.map((entry, i) => (
<AppTile
key={entry.slug}
icon={brainConnectorIcon(
entry.slug,
entry.name,
"size-5",
)}
name={entry.name}
subtitle={titleCase(entry.category)}
connected={isConnected(entry.slug)}
busy={busy === entry.slug}
onConnect={() => connect(entry)}
showDivider={
i < featured.length - 1 || overflow.length > 0
}
/>
))}
{overflow.length > 0 && (
<MoreTile
count={overflow.length}
names={overflow.slice(0, 3).map((a) => a.name)}
onClick={() => void setViewMode("configure")}
/>
)}
</>
)}
</div>
</section>
) : null}
<AgentPreview
apps={previewApps}
isConnected={isConnected}
connectedCount={connectedCount}
wide={!showBoard}
/>
</div>
</div>
@ -235,10 +250,12 @@ function AgentPreview({
apps,
isConnected,
connectedCount,
wide = false,
}: {
apps: CatalogEntry[]
isConnected: (slug: string) => boolean
connectedCount: number
wide?: boolean
}) {
const prompts = apps
.filter((a) => AGENT_PROMPTS[a.slug])
@ -252,7 +269,10 @@ function AgentPreview({
return (
<section
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
className={cn(
"relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5",
wide ? "lg:col-span-5" : "lg:col-span-2",
)}
style={cardStyle}
>
<div className="flex items-center gap-2">
@ -359,7 +379,15 @@ function AppTile({
)
}
function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
function MoreTile({
count,
names,
onClick,
}: {
count: number
names: string[]
onClick: () => void
}) {
return (
<button
type="button"
@ -380,7 +408,8 @@ function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
{count} more {count === 1 ? "app" : "apps"}
</p>
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
Notion, PostHog, Plain and more
{names.join(", ")}
{count > names.length ? " and more" : ""}
</p>
</div>
<div className="flex w-[88px] shrink-0 justify-end">
@ -411,7 +440,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
function SlackBanner() {
return (
<section
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
style={cardStyle}
>
<div
@ -422,37 +451,45 @@ function SlackBanner() {
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-3.5">
<div
className="flex size-12 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F] sm:size-12"
style={tileStyle}
>
<SlackMark className="size-7" />
<SlackMark className="size-6 sm:size-7" />
</div>
<div className="min-w-0">
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
"truncate text-[15px] font-semibold leading-tight text-[#fafafa] sm:text-[16px]",
dmSans125ClassName(),
)}
>
Company Brain in Slack
<span className="sm:hidden">Slack agent</span>
<span className="hidden sm:inline">Company Brain in Slack</span>
</p>
<p className="mt-0.5 text-[13px] font-medium leading-[1.5] text-[#737373]">
Install Supermemory so your team can{" "}
<span className="text-[#A1A1AA]">@supermemory</span> in any
channel.
<p className="mt-1 truncate text-[12px] font-medium leading-[1.45] text-[#737373] sm:mt-0.5 sm:text-[13px] sm:leading-[1.5]">
<span className="sm:hidden">
Ask <span className="text-[#A1A1AA]">@supermemory</span> from
any channel.
</span>
<span className="hidden sm:inline">
Install Supermemory so your team can{" "}
<span className="text-[#A1A1AA]">@supermemory</span> in any
channel.
</span>
</p>
</div>
</div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-white px-4 py-2.5 text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:self-auto"
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
>
<SlackMark className="size-[18px]" />
Add to Slack
<SlackMark className="hidden sm:block sm:size-[18px]" />
<span className="sm:hidden">Add</span>
<span className="hidden sm:inline">Add to Slack</span>
</a>
</div>
</section>

View file

@ -19,11 +19,11 @@ import {
ExternalLink,
Home,
LifeBuoy,
Link2,
LayoutGrid,
MenuIcon,
SearchIcon,
Settings,
Settings2,
UserPlus,
ChevronRight,
Sun,
@ -35,7 +35,7 @@ import { DomainLogo } from "@/components/onboarding-brain/step-about"
import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import { GraphIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useTokenUsage } from "@/hooks/use-token-usage"
@ -121,7 +121,6 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
feedbackParam,
)
const [, setInvite] = useQueryState("invite")
const [settingsTab] = useQueryState("settings")
const { data: slackStatus } = useSlackStatus()
const planByOrgId = new Map(
@ -140,10 +139,10 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
?.role?.toLowerCase()
const canInvite = memberRole === "owner" || memberRole === "admin"
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
const isOverview = viewMode === "dashboard"
const isGraph = viewMode === "graph"
const isMemories = viewMode === "list"
const isConnections = settingsTab === "company-brain"
const isConfigure = viewMode === "configure"
const slackConnected = slackStatus?.connected ?? false
const selectOrg = useCallback(
@ -166,9 +165,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
void setViewMode("list")
}, [setViewMode])
const goConnections = useCallback(() => {
openSettings("company-brain")
}, [openSettings])
const goConfigure = useCallback(() => {
void setViewMode("configure")
}, [setViewMode])
const goIntegrations = useCallback(() => {
void setViewMode("integrations")
@ -184,16 +183,16 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
}, [setFeedbackOpen])
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">
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1 px-2 py-2 md:grid md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 flex-1 shrink items-center justify-start gap-1.5 md:justify-self-start md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex max-w-[min(52vw,240px)] 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-['']"
className="relative flex min-w-0 max-w-[31vw] shrink cursor-pointer items-center rounded-lg px-1 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none min-[380px]:max-w-[9rem] sm:max-w-[min(52vw,240px)] md:-ml-2 md:max-w-[min(52vw,240px)] md:shrink-0 md:px-1.5 before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] md:before:-inset-x-2 md:before:-inset-y-2.5"
>
<div
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] sm:size-8"
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
@ -205,7 +204,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
<div className="ml-1.5 min-w-0 flex flex-col items-start justify-center max-[340px]:hidden sm:ml-2">
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
Company Brain
</p>
@ -264,9 +263,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
<Link2 className="size-4 text-[#737373]" />
Connections
<DropdownMenuItem onClick={goConfigure} className={menuItemClass}>
<Settings2 className="size-4 text-[#737373]" />
Configure
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
@ -318,7 +317,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
</div>
{!isMobile && (
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
<div className="z-10! flex min-w-0 max-w-full items-center justify-center gap-1.5 overflow-hidden px-1 md:justify-self-center">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -364,24 +363,24 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
<button
type="button"
role="tab"
aria-selected={isConnections}
onClick={goConnections}
className={tabClass(isConnections)}
aria-selected={isConfigure}
onClick={goConfigure}
className={tabClass(isConfigure)}
>
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
Connections
<Settings2 className="size-3.5 shrink-0 sm:size-4" />
Configure
</button>
</div>
<SlackNavButton
connected={slackConnected}
teamName={slackStatus?.teamName ?? null}
active={isConnections && slackConnected}
onManage={goConnections}
active={isConfigure && slackConnected}
onManage={goConfigure}
/>
</div>
)}
<div className="z-10! flex shrink-0 items-center gap-1.5">
<div className="z-10! flex min-w-0 shrink-0 items-center gap-1.5 md:justify-self-end">
{isMobile ? (
<>
<SpaceSelector
@ -389,12 +388,14 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
onValueChange={setSelectedProjects}
enableDelete={false}
compact
triggerClassName="max-w-[34vw] shrink min-[380px]:max-w-[9rem]"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
aria-label="Open navigation menu"
className="size-9! min-h-9 min-w-9 rounded-full px-0! text-base"
>
<MenuIcon className="size-4" />
</Button>
@ -429,11 +430,11 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={goConnections}
onClick={goConfigure}
className={menuItemClass}
>
<IntegrationsIcon className="size-4 text-[#737373]" />
Connections
<Settings2 className="size-4 text-[#737373]" />
Configure
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
@ -444,7 +445,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
</DropdownMenuItem>
{slackConnected ? (
<DropdownMenuItem
onClick={goConnections}
onClick={goConfigure}
className={menuItemClass}
>
<SlackMark className="size-4" />

View file

@ -0,0 +1,130 @@
"use client"
import { cn } from "@lib/utils"
import { Blocks, CalendarClock, Cpu } from "lucide-react"
import { useState } from "react"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import CompanyBrainModels from "@/components/settings/company-brain-models"
import Proactiveness from "@/components/settings/proactiveness"
import { ErrorBoundary } from "@/components/error-boundary"
import { dmSans125ClassName } from "@/lib/fonts"
type ConfigureSection = "company-brain" | "models" | "automations"
const SECTIONS: {
id: ConfigureSection
label: string
description: string
icon: typeof Blocks
}[] = [
{
id: "company-brain",
label: "Integrations",
description:
"Connect the tools your brain works with. Your account covers your own actions and reads; workspace accounts are a shared fallback.",
icon: Blocks,
},
{
id: "models",
label: "Models",
description:
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
icon: Cpu,
},
{
id: "automations",
label: "Automations",
description:
"Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.",
icon: CalendarClock,
},
]
export function ConfigureView() {
const [activeSection, setActiveSection] =
useState<ConfigureSection>("company-brain")
const active = SECTIONS.find((section) => section.id === activeSection)
if (!active) return null
return (
<div
className={cn(
dmSans125ClassName(),
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
)}
>
<section
aria-label="Configure Company Brain"
className="flex flex-1 flex-col rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-6"
>
<div className="flex flex-1 flex-col gap-5 md:flex-row md:gap-8">
<nav
aria-label="Configure sections"
className="scrollbar-none flex shrink-0 gap-1 overflow-x-auto md:w-52 md:flex-col md:overflow-x-visible"
>
<p className="hidden px-3 pb-1.5 font-semibold text-[11px] text-[#5B6675] uppercase tracking-[0.08em] md:block">
Configure
</p>
{SECTIONS.map((section) => {
const isActive = section.id === activeSection
const Icon = section.icon
return (
<button
key={section.id}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => setActiveSection(section.id)}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors",
isActive
? "bg-white/[0.08] text-[#FAFAFA]"
: "text-[#8B929E] hover:bg-white/[0.04] hover:text-[#FAFAFA]",
)}
>
<Icon
className={cn(
"size-4 shrink-0",
isActive ? "text-[#FAFAFA]" : "text-[#737B87]",
)}
/>
{section.label}
</button>
)
})}
</nav>
<div className="min-w-0 flex-1">
<header className="mb-5">
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
>
{active.label}
</h2>
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
{active.description}
</p>
</header>
<ErrorBoundary
key={activeSection}
fallback={
<p className="py-6 text-center text-[13px] text-[#8B929E]">
Something went wrong loading this section.
</p>
}
>
{activeSection === "company-brain" ? (
<CompanyBrainConnections />
) : activeSection === "models" ? (
<CompanyBrainModels showHeading={false} />
) : (
<Proactiveness />
)}
</ErrorBoundary>
</div>
</div>
</section>
</div>
)
}

View file

@ -43,12 +43,21 @@ const BACKEND =
type Phase = "confirm" | "research"
function normalizeDomain(input: string): string {
return input
const host = input
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
// The confirmation card is often filled with a company name (for example,
// "Zomato") even though research needs a hostname. Preserve explicit TLDs,
// and make the common single-label case usable without a failed API round trip.
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
return `${host}.com`
}
return host
}
export function CompanyBrainOnboarding({
@ -306,30 +315,35 @@ function DockedHeader({
}) {
const brandName = workspaceNameFromDomain(domain) || domain
return (
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
<DomainLogo domain={domain} />
</div>
<span className="text-[14px] font-semibold text-[#fafafa]">
{brandName}
</span>
<span
className={cn(
"text-[12px] font-medium",
done ? "text-[#5CD68A]" : "text-[#737373]",
)}
>
{done ? "Company Brain ready" : "Building your Company Brain…"}
</span>
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-3">
<span
title={brandName}
className="min-w-0 truncate text-[14px] font-semibold text-[#fafafa] md:flex-1"
>
{brandName}
</span>
<span
className={cn(
"shrink-0 whitespace-nowrap text-[12px] font-medium",
done ? "text-[#5CD68A]" : "text-[#737373]",
)}
>
{done ? "Company Brain ready" : "Building your Company Brain…"}
</span>
</div>
{done ? (
<Button
type="button"
onClick={onContinue}
className={cn(
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
dmSans125ClassName(),
)}
>
@ -337,7 +351,7 @@ function DockedHeader({
<ArrowRight className="size-3.5" />
</Button>
) : (
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
<Loader2 className="ml-auto size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
)}
</div>
)

View file

@ -13,7 +13,7 @@ import {
brainConnectorIcon,
SlackMark,
} from "@/components/brain-connector-icons"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useRouter } from "next/navigation"
import { useResearchStatus } from "@/hooks/use-research-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
@ -83,7 +83,7 @@ export function ResearchActionRail({
onStatsChange?: (stats: ParallelSetupStats) => void
}) {
const { org } = useAuth()
const { openSettings } = useSettingsModal()
const router = useRouter()
const { events } = useResearchStatus()
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [rows, setRows] = useState<ConnRow[]>([])
@ -448,7 +448,7 @@ export function ResearchActionRail({
onConnect={connect}
onBrowse={() => {
pauseRotation()
openSettings("company-brain")
router.push("/?view=configure")
}}
/>
)}

View file

@ -19,7 +19,7 @@ export function BrainShell({ step, steps, children }: ShellProps) {
return (
<div
className={cn(
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#FAFAFA]",
dmSansClassName(),
)}
>
@ -46,20 +46,25 @@ export function BrainShell({ step, steps, children }: ShellProps) {
}}
/>
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center px-6 md:px-10 py-4 gap-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa] justify-self-start" />
<StepIndicator step={step} visibleSteps={visibleSteps} />
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 py-4 md:px-10">
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
<div className="hidden justify-center md:flex">
<StepIndicator step={step} visibleSteps={visibleSteps} />
</div>
<span aria-hidden />
</header>
<main
className={cn(
"relative z-10 flex-1 flex justify-center px-4 md:px-10 py-6 md:py-10",
"relative z-10 flex min-h-dvh flex-col items-center px-4 md:px-10",
step === "sources"
? "items-start overflow-y-auto"
: "items-center overflow-hidden",
? "justify-start overflow-y-auto pt-20 pb-10"
: "justify-center overflow-y-auto py-20 md:overflow-hidden",
)}
>
<div className="mb-5 flex justify-center md:hidden">
<StepIndicator step={step} visibleSteps={visibleSteps} />
</div>
<div
className={cn(
"w-full",
@ -89,7 +94,7 @@ function StepIndicator({
const isLast = i === visibleSteps.length - 1
return (
<div key={s} className="flex items-start">
<div className="flex flex-col items-center gap-1.5 min-w-[58px]">
<div className="flex min-w-[44px] flex-col items-center gap-1.5 md:min-w-[58px]">
<StepDot done={isDone} current={isCurrent} />
<span
className={cn(
@ -105,7 +110,7 @@ function StepIndicator({
</span>
</div>
{!isLast && (
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
<div className="mt-[5px] h-px min-w-[18px] flex-1 md:min-w-[28px]">
<motion.div
initial={false}
animate={{

View file

@ -474,9 +474,9 @@ export function StepSources({
}
return (
<div className="mx-auto w-full max-w-[1400px] pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-4">
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
<div className="mx-auto w-full max-w-[1400px] pb-28 md:pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-3 md:py-4">
<div className="md:absolute md:inset-x-0 md:top-[46%] md:-translate-y-1/2">
<div className="mb-6 px-1">
<p
className={cn(
@ -494,7 +494,7 @@ export function StepSources({
</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
{mode === "personal" ? (
<>
<SourceCard
@ -565,7 +565,7 @@ export function StepSources({
)}
</div>
<div className="absolute left-0 right-0 top-full mt-6 px-1">
<div className="mt-4 px-1 md:absolute md:left-0 md:right-0 md:top-full md:mt-6">
<div className="flex items-center justify-between gap-3">
<button
type="button"
@ -579,7 +579,7 @@ export function StepSources({
)}
/>
More integrations
<span className="text-[#525D6E]">
<span className="hidden text-[#525D6E] sm:inline">
(Gmail, GitHub, OneDrive)
</span>
</button>
@ -591,7 +591,7 @@ export function StepSources({
</div>
{moreOpen ? (
<div className="mt-10 grid md:grid-cols-3 gap-4">
<div className="mt-4 grid gap-3 md:mt-10 md:grid-cols-3 md:gap-4">
<MoreSourcesGrid
mode={mode}
values={values}
@ -929,22 +929,21 @@ function SourceActions({
return (
<div
className={cn(
"mt-6 flex items-center justify-end gap-[22px] px-1",
"mt-4 flex items-center justify-end gap-3 px-1 md:mt-6 md:gap-[22px]",
className,
)}
>
<button
type="button"
onClick={onContinue}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
className="hidden text-[#737373] font-medium text-[14px] transition-colors hover:text-[#999] sm:inline"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={connectedCount === 0}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
>
Continue
{connectedCount > 0 && (
@ -1276,16 +1275,16 @@ function SourceCard({
return (
<div
className={cn(
"min-h-[190px] rounded-[18px] p-5 transition-colors bg-[#1B1F24] flex flex-col",
"min-h-[146px] rounded-[18px] bg-[#1B1F24] p-3.5 transition-colors flex flex-col md:min-h-[190px] md:p-5",
isDone && "ring-1 ring-[#2261CA33]",
)}
style={modalCardStyle}
>
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
<div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-start gap-2.5 md:grid-cols-[48px_minmax(0,1fr)_auto] md:gap-3">
<div className="pt-0.5">
<div
className={cn(
"size-12 rounded-[12px] flex items-center justify-center shrink-0",
"size-10 rounded-[12px] flex items-center justify-center shrink-0 md:size-12",
bareIconFrame
? "bg-transparent border border-transparent"
: "bg-[#14161A] border border-[rgba(82,89,102,0.2)]",
@ -1296,10 +1295,10 @@ function SourceCard({
</div>
</div>
<div className="min-w-0 pr-1">
<p className="text-[15px] leading-tight font-semibold text-[#fafafa]">
<p className="text-[14px] leading-tight font-semibold text-[#fafafa] md:text-[15px]">
{title}
</p>
<p className="text-[12px] text-[#737373] mt-1 leading-[1.35] font-medium">
<p className="mt-0.5 text-[11.5px] text-[#737373] leading-[1.3] font-medium md:mt-1 md:text-[12px] md:leading-[1.35]">
{blurb}
</p>
{headerNote}
@ -1320,7 +1319,7 @@ function SourceCard({
: undefined
}
className={cn(
"shrink-0 rounded-full h-9 px-4 text-[13px] font-medium text-[#fafafa] gap-1.5",
"h-8 shrink-0 rounded-full px-3 text-[12px] font-medium text-[#fafafa] gap-1.5 md:h-9 md:px-4 md:text-[13px]",
disabled && "opacity-50",
)}
>
@ -1336,15 +1335,15 @@ function SourceCard({
)}
</div>
<ul className="mt-4 space-y-1.5">
<ul className="mt-3 space-y-1 md:mt-4 md:space-y-1.5">
{perks.map((p) => (
<li
key={p}
className="flex items-start gap-2.5 text-[12px] text-[#737373] font-medium leading-[1.5]"
className="flex items-start gap-2 text-[11px] text-[#737373] font-medium leading-[1.35] md:gap-2.5 md:text-[12px] md:leading-[1.5]"
>
<span
aria-hidden
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[7px]"
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[6px] md:mt-[7px]"
/>
<span>{p}</span>
</li>
@ -1352,7 +1351,7 @@ function SourceCard({
</ul>
{(footerLeft || footerRight) && (
<div className="mt-auto pt-4 flex items-end justify-between gap-3">
<div className="mt-auto flex items-end justify-between gap-3 pt-3 md:pt-4">
<div>{footerLeft}</div>
<div className="pb-1.5">{footerRight}</div>
</div>
@ -1364,10 +1363,10 @@ function SourceCard({
function SpaceChip({ name }: { name: string }) {
return (
<div
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373]"
className="inline-flex items-center gap-1 text-[10px] font-medium text-[#737373] md:gap-1.5 md:text-[11px]"
title={`This source will save into the "${name}" space.`}
>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
<span className="text-[9px] uppercase tracking-[0.08em] text-[#525D6E] md:text-[10px]">
Saves to
</span>
<FolderOpen className="size-3 text-[#737373]" />

View file

@ -144,14 +144,14 @@ export function StepTeam({
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto">
<div className="max-w-2xl mx-auto pb-24 md:pb-0">
<section
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
className="rounded-[22px] bg-[#1B1F24] p-5 md:p-8"
style={modalCardStyle}
>
<div className="flex items-start gap-4">
<div className="flex items-start gap-3 md:gap-4">
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
className="size-11 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0 md:size-12"
style={inputBevelStyle}
>
<Users className="size-5 text-[#fafafa]" />
@ -159,13 +159,13 @@ export function StepTeam({
<div className="min-w-0 flex-1">
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
"text-[19px] font-semibold text-[#fafafa] md:text-[20px]",
dmSans125ClassName(),
)}
>
Invite your team
</p>
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
<p className="text-[13px] text-[#737373] mt-1 leading-[1.45] font-medium md:text-[14px] md:leading-[1.5]">
A brain gets sharper as more people contribute. You can also do
this later.
</p>
@ -177,9 +177,9 @@ export function StepTeam({
e.preventDefault()
addInvites(draft)
}}
className="mt-6 flex gap-2"
className="mt-5 flex min-w-0 gap-2 md:mt-6"
>
<div className="relative flex-1">
<div className="relative min-w-0 flex-1">
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
<Input
value={draft}
@ -191,8 +191,8 @@ export function StepTeam({
addInvites(pasted)
}
}}
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
className={cn(inputClass, "pl-10")}
placeholder={`alex@${domainOrFallback}`}
className={cn(inputClass, "min-w-0 pl-10")}
style={inputBevelStyle}
/>
</div>
@ -200,19 +200,19 @@ export function StepTeam({
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
className="h-12 shrink-0 rounded-full px-3.5 text-[13px] font-medium text-[#fafafa] md:px-4"
>
<Plus className="size-4" />
Add
</Button>
</form>
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-1 md:pl-2">
Paste multiple emails at once we'll split them for you.
</p>
{count === 0 ? (
<div className="mt-6 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-5 py-8 text-center">
<div className="mt-5 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-4 py-7 text-center md:mt-6 md:px-5 md:py-8">
<p className="text-[13px] text-[#737373] font-medium">
No invites yet.
</p>
@ -242,11 +242,11 @@ export function StepTeam({
</div>
)}
</div>
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
<div className="mt-3 space-y-1.5 max-h-[240px] overflow-y-auto scrollbar-thin pr-1 md:max-h-[280px]">
{values.invites.map((inv) => (
<div
key={inv.email}
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2"
className="flex flex-wrap items-center gap-2 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2 sm:flex-nowrap md:gap-3"
style={inputBevelStyle}
>
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
@ -263,7 +263,7 @@ export function StepTeam({
setRole(inv.email, r as "admin" | "member")
}
>
<SelectTrigger className="w-24 h-7 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
<SelectTrigger className="h-7 w-24 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
@ -295,12 +295,12 @@ export function StepTeam({
</>
)}
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
<div className="mt-5 flex items-center justify-end gap-3 border-t border-white/[0.06] pt-4 md:mt-6 md:gap-[22px] md:pt-5">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
className="text-[#737373] font-medium text-[13px] transition-colors hover:text-[#999] disabled:opacity-50 md:text-[14px]"
>
Skip for now
</button>
@ -308,7 +308,7 @@ export function StepTeam({
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
>
{submitting ? (
<>

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"
@ -9,26 +8,19 @@ import {
CalendarClock,
ChevronDown,
ChevronUp,
FilePlus2,
FileText,
GitPullRequest,
Info,
LifeBuoy,
ListTodo,
Loader2,
Lock,
MessageCircleQuestion,
Plus,
Radar,
Trash2,
} from "lucide-react"
import { useRef, useState } from "react"
import { toast } from "sonner"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import {
Select,
SelectContent,
@ -167,7 +159,7 @@ const PRESETS: Preset[] = [
{
id: "standup",
category: "team",
label: "Daily standup",
label: "Morning checkup",
description:
"Shipped work, decisions, blockers & open questions from the last 24h.",
icon: CalendarClock,
@ -179,7 +171,7 @@ const PRESETS: Preset[] = [
{
id: "weekly-recap",
category: "team",
label: "Weekly team recap",
label: "Company progress",
description:
"Decisions, shipped work & unresolved threads from the past week.",
icon: CalendarClock,
@ -236,6 +228,18 @@ const PRESETS: Preset[] = [
frequency: "daily",
time: "09:00",
},
{
id: "competitor-check",
category: "product",
label: "Competitor check",
description: "What competitors shipped, announced, or changed this week.",
icon: Radar,
prompt:
"Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.",
frequency: "weekly",
weekday: 1,
time: "09:00",
},
{
id: "release-notes",
category: "product",
@ -273,38 +277,12 @@ function sortPresets(connected: Set<string>): Preset[] {
return [...PRESETS].sort((a, b) => rank(a) - rank(b))
}
// Up to 3 presets spanning distinct categories (connection-preferred order).
function galleryPresets(sorted: Preset[]): Preset[] {
const seen = new Set<Category>()
const out: Preset[] = []
for (const p of sorted) {
if (seen.has(p.category)) continue
seen.add(p.category)
out.push(p)
if (out.length === 3) break
}
return out
}
function cadenceLabel(p: Preset): string {
if (p.frequency === "weekly")
return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}`
return `Daily · ${p.time}`
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
</p>
)
}
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",
@ -318,9 +296,6 @@ 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"
const DM_VALUE = "__dm__"
const menuItemClass =
"cursor-pointer gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium text-white/85 hover:bg-white/[0.06] hover:text-white data-[highlighted]:bg-white/[0.06] data-[highlighted]:text-white focus:bg-white/[0.06] focus:text-white"
function AutomationCard({
initial,
id,
@ -830,7 +805,7 @@ function AutomationRow({
)
}
function PresetTile({
function PresetCard({
preset,
onPick,
}: {
@ -844,19 +819,31 @@ function PresetTile({
onClick={onPick}
className={cn(
dmSans125ClassName(),
"flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-[#14161A] px-3 py-2.5 text-left transition-colors hover:border-white/[0.12] hover:bg-[#171A1F]",
"flex min-w-0 cursor-pointer flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 text-left",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#171A1F]",
)}
title={preset.description}
>
<Icon className="size-4 shrink-0 text-[#9A9A9A]" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium text-[#FAFAFA]">
{preset.label}
</span>
<span className="truncate text-[11px] text-[#6B6B6B]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
<Icon className="size-4 text-[#9A9A9A]" />
</div>
<div className="min-w-0 pt-0.5">
<p className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
{preset.label}
</p>
<p className="mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]">
{preset.description}
</p>
</div>
</div>
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<span className="text-[12px] font-medium text-[#737373]">
{cadenceLabel(preset)}
</span>
</span>
<span className="text-[12px] font-medium text-[#8B929E]">
Use template
</span>
</div>
</button>
)
}
@ -873,17 +860,6 @@ 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 listQuery = useQuery({
queryKey: ["company-brain-automations", "list", org?.id],
queryFn: async () => {
@ -935,71 +911,12 @@ export default function CompanyBrainAutomations() {
queryKey: ["company-brain-automations", "list"],
})
}
const showGallery = automations.length === 0 && drafts.length === 0
const usedTitles = new Set(automations.map((a) => a.title))
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
const hasList = automations.length > 0 || drafts.length > 0
return (
<section className="flex flex-col gap-3 px-1">
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-3">
<SectionTitle>Channel automations</SectionTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 px-3 text-[12px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA]",
)}
>
<Plus className="size-3.5" />
New automation
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
dmSans125ClassName(),
"min-w-[240px] rounded-xl border-white/[0.08] bg-[#1B1F24] p-1.5",
)}
>
<DropdownMenuItem
onClick={() => addDraft(emptyDraft())}
className={menuItemClass}
>
<FilePlus2 className="size-4 text-[#737373]" />
Blank automation
</DropdownMenuItem>
{presets.map((p) => {
const Icon = p.icon
return (
<DropdownMenuItem
key={p.id}
onClick={() => addDraft(presetToDraft(p))}
className={menuItemClass}
>
<Icon className="size-4 text-[#737373]" />
{p.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<span
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 text-[12px] text-[#6B6B6B]",
)}
>
<Lock className="size-3 shrink-0" />
Read-only scheduled summaries posted to a channel.{" "}
{isAdmin
? "You manage all across the org."
: "You manage the ones you create."}
</span>
</div>
<div className="flex flex-col gap-3">
{automations.map((a) =>
openId === a.id ? (
@ -1044,40 +961,37 @@ export default function CompanyBrainAutomations() {
/>
))}
{showGallery ? (
<div className="flex flex-col gap-2">
<p
className={cn(dmSans125ClassName(), "text-[12px] text-[#6B6B6B]")}
>
Start from a template:
</p>
<div className="flex flex-col gap-2">
{galleryPresets(presets).map((p) => (
<PresetTile
key={p.id}
preset={p}
onPick={() => addDraft(presetToDraft(p))}
/>
))}
<button
type="button"
onClick={() => addDraft(emptyDraft())}
className={cn(
dmSans125ClassName(),
"flex items-center justify-center gap-2 rounded-[10px] border border-dashed border-white/[0.1] bg-transparent px-3 py-2.5 text-[13px] text-[#9A9A9A] transition-colors hover:border-white/20 hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
Start from scratch
</button>
</div>
<p
className={cn(dmSans125ClassName(), "text-[11px] text-[#5A5A5A]")}
>
More templates in the New automation menu.
</p>
</div>
{hasList ? (
<p
className={cn(
dmSans125ClassName(),
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
)}
>
Templates
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{availablePresets.map((p) => (
<PresetCard
key={p.id}
preset={p}
onPick={() => addDraft(presetToDraft(p))}
/>
))}
<button
type="button"
onClick={() => addDraft(emptyDraft())}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
New automation
</button>
</div>
</div>
</section>
)

View file

@ -1,10 +1,22 @@
"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 * as DialogPrimitive from "@radix-ui/react-dialog"
import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
@ -33,7 +45,6 @@ type ConnRow = {
userId: string | null
}
type SlackStatus = { connected: boolean; teamName: string | null }
type Scope = "org" | "user"
function titleCase(s: string) {
return s.replace(/\b\w/g, (c) => c.toUpperCase())
@ -48,28 +59,20 @@ function slugifyMcpName(value: string) {
.slice(0, 63)
}
function SecondaryButton({
children,
href,
}: {
children: React.ReactNode
href: string
}) {
return (
<a
href={href}
className={cn(
dmSans125ClassName(),
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full border border-[#1E293B] bg-[#0D121A] px-4 h-9",
"text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
)}
>
{children}
</a>
)
}
const pillLinkClass = cn(
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80",
)
function StatusDot({ connected }: { connected: boolean }) {
function ScopeChip({
label,
connected,
}: {
label: string
connected: boolean
}) {
return (
<span
className={cn(
@ -84,19 +87,22 @@ function StatusDot({ connected }: { connected: boolean }) {
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
)}
/>
{connected ? "Connected" : "Not connected"}
{label}
</span>
)
}
const menuItemClass =
"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"
function AppCard({
name,
subtitle,
icon,
connected,
canConnect,
canDisconnect,
lockedHint,
userConnected,
orgConnected,
isAdmin,
personalOnly,
busy,
onConnect,
onDisconnect,
@ -104,16 +110,20 @@ function AppCard({
name: string
subtitle: string
icon: React.ReactNode
connected: boolean
canConnect: boolean
canDisconnect: boolean
lockedHint?: string
userConnected: boolean
orgConnected: boolean
isAdmin: boolean
personalOnly?: boolean
busy: boolean
onConnect: () => void
onDisconnect: () => void
onConnect: (shared: boolean) => void
onDisconnect: (shared: boolean) => void
}) {
const anyConnected = userConnected || orgConnected
const showOrgChip = !personalOnly && (orgConnected || isAdmin)
const adminMenu = isAdmin && !personalOnly
return (
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{icon}
@ -138,67 +148,154 @@ function AppCard({
</div>
</div>
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<StatusDot connected={connected} />
{connected && canDisconnect ? (
<PillButton onClick={onDisconnect} disabled={busy}>
<div className="flex min-w-0 items-center gap-3">
{personalOnly || !anyConnected ? (
<ScopeChip
label={userConnected ? "Connected" : "Not connected"}
connected={userConnected}
/>
) : (
<>
<ScopeChip label="You" connected={userConnected} />
{showOrgChip ? (
<ScopeChip label="Workspace" connected={orgConnected} />
) : null}
</>
)}
</div>
{adminMenu ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={busy}
className={cn(
dmSans125ClassName(),
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<>
{anyConnected ? "Manage" : "Connect"}
<ChevronDown className="size-3.5 text-[#737373]" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
dmSans125ClassName(),
"min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<DropdownMenuItem
className={menuItemClass}
onClick={() =>
userConnected ? onDisconnect(false) : onConnect(false)
}
>
{userConnected ? "Disconnect my account" : "Connect my account"}
</DropdownMenuItem>
<DropdownMenuItem
className={menuItemClass}
onClick={() =>
orgConnected ? onDisconnect(true) : onConnect(true)
}
>
{orgConnected
? "Disconnect workspace"
: "Connect for workspace"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : userConnected ? (
<PillButton onClick={() => onDisconnect(false)} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Disconnect
</PillButton>
) : (
!connected &&
(canConnect ? (
<PillButton onClick={onConnect} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</PillButton>
) : lockedHint ? (
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
<Lock className="size-3" />
{lockedHint}
</span>
) : null)
) : personalOnly ? null : (
<PillButton onClick={() => onConnect(false)} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</PillButton>
)}
</div>
</div>
)
}
function ScopeToggle({
scope,
onChange,
function SlackCard({
status,
isAdmin,
installHref,
}: {
scope: Scope
onChange: (s: Scope) => void
status: SlackStatus | null
isAdmin: boolean
installHref: string
}) {
const items: { id: Scope; label: string }[] = [
{ id: "org", label: "Organization" },
{ id: "user", label: "Personal" },
]
const connected = status?.connected ?? false
return (
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
{items.map((it) => (
<button
key={it.id}
type="button"
onClick={() => onChange(it.id)}
className={cn(
dmSans125ClassName(),
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
scope === it.id
? "bg-[#1E293B] text-[#FAFAFA]"
: "text-[#737373] hover:text-[#FAFAFA]",
)}
>
{it.label}
</button>
))}
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
<SlackMark className="size-5" />
</div>
<div className="min-w-0 pt-0.5">
<p
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
Slack
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
)}
>
Messaging
</p>
</div>
</div>
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<ScopeChip
label={
connected
? status?.teamName
? `Workspace · ${status.teamName}`
: "Workspace"
: "Not connected"
}
connected={connected}
/>
{isAdmin ? (
<a
href={installHref}
className={cn(dmSans125ClassName(), pillLinkClass)}
>
{connected ? "Reconnect" : "Connect"}
</a>
) : null}
</div>
</div>
)
}
function RowSkeleton() {
return (
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex items-center gap-3">
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
<div className="space-y-2">
@ -206,7 +303,7 @@ function RowSkeleton() {
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
</div>
</div>
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
<div className="mt-5 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
</div>
)
}
@ -219,19 +316,11 @@ export default function CompanyBrainConnections() {
const [rows, setRows] = useState<ConnRow[]>([])
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [scope, setScope] = useState<Scope>("user")
const [customOpen, setCustomOpen] = useState(false)
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([
@ -388,8 +477,12 @@ export default function CompanyBrainConnections() {
}
if (data.authUrl) {
window.open(data.authUrl, "_blank", "noopener")
setCustomOpen(false)
setCustomName("")
setCustomServerUrl("")
} else if (data.ok) {
toast.success(`${slug} connected.`)
setCustomOpen(false)
setCustomName("")
setCustomServerUrl("")
await load()
@ -461,43 +554,9 @@ export default function CompanyBrainConnections() {
!catalogSlugs.has(row.serverSlug),
)
: []
const shared = scope === "org"
const description = shared
? "Connected by admins. Used for reads when you haven't connected your own."
: "Your personal accounts, used for your actions and your reads."
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<ScopeToggle scope={scope} onChange={setScope} />
{slackStatus?.connected && slackStatus.teamName ? (
<p
className={cn(
dmSans125ClassName(),
"ml-auto text-[13px] font-medium text-[#737373]",
)}
>
Slack · {slackStatus.teamName}
</p>
) : null}
{isAdmin ? (
<SecondaryButton href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Reconnect Slack
</SecondaryButton>
) : null}
</div>
<p
className={cn(
dmSans125ClassName(),
"px-1 text-[13px] font-medium text-[#737373]",
)}
>
{description}
</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{loading ? (
<>
<RowSkeleton />
@ -506,100 +565,128 @@ export default function CompanyBrainConnections() {
</>
) : (
<>
{!shared && isStaff ? (
<form
onSubmit={connectCustom}
className="flex min-h-[152px] min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
>
<div>
<p
className={cn(
dmSans125ClassName(),
"text-[14px] font-semibold text-[#FAFAFA]",
)}
>
Custom MCP server
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
)}
>
Add a personal OAuth MCP server by URL.
</p>
</div>
<div className="space-y-2">
<input
value={customName}
onChange={(event) => setCustomName(event.target.value)}
placeholder="Name"
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<input
value={customServerUrl}
onChange={(event) => setCustomServerUrl(event.target.value)}
placeholder="https://example.com/mcp"
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
<PillButton
type="submit"
disabled={busy?.startsWith("custom:") ?? false}
>
{busy?.startsWith("custom:") && (
<Loader2 className="size-3.5 animate-spin" />
)}
Connect
</PillButton>
</div>
</div>
</form>
) : null}
<SlackCard
status={slackStatus}
isAdmin={isAdmin}
installHref={`${BACKEND}/brain/slack/oauth/install`}
/>
{apps.map((entry) => (
<AppCard
key={`${scope}-${entry.slug}`}
key={entry.slug}
name={entry.name}
subtitle={titleCase(entry.category)}
icon={brainConnectorIcon(entry.slug, entry.name)}
connected={isConnected(entry.slug, shared)}
canConnect={shared ? isAdmin : true}
canDisconnect={shared ? isAdmin : true}
lockedHint={shared ? "Admin only" : undefined}
busy={busy === `${entry.slug}:${scope}`}
onConnect={() => connect(entry, shared)}
onDisconnect={() => disconnect(entry, shared)}
userConnected={isConnected(entry.slug, false)}
orgConnected={isConnected(entry.slug, true)}
isAdmin={isAdmin}
busy={busy?.startsWith(`${entry.slug}:`) ?? false}
onConnect={(shared) => connect(entry, shared)}
onDisconnect={(shared) => disconnect(entry, shared)}
/>
))}
{!shared &&
customRows.map((row) => (
<AppCard
key={`custom-${row.serverSlug}`}
name={titleCase(row.serverSlug.replace(/-/g, " "))}
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
connected
canConnect={false}
canDisconnect
busy={busy === `${row.serverSlug}:user`}
onConnect={() => {}}
onDisconnect={() =>
disconnect(
{
slug: row.serverSlug,
name: titleCase(row.serverSlug.replace(/-/g, " ")),
category: "Custom OAuth MCP",
authType: "oauth",
},
false,
)
}
/>
))}
{customRows.map((row) => (
<AppCard
key={`custom-${row.serverSlug}`}
name={titleCase(row.serverSlug.replace(/-/g, " "))}
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
userConnected
orgConnected={false}
isAdmin={false}
personalOnly
busy={busy === `${row.serverSlug}:user`}
onConnect={() => {}}
onDisconnect={() =>
disconnect(
{
slug: row.serverSlug,
name: titleCase(row.serverSlug.replace(/-/g, " ")),
category: "Custom OAuth MCP",
authType: "oauth",
},
false,
)
}
/>
))}
{isStaff ? (
<button
type="button"
onClick={() => setCustomOpen(true)}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
Add custom MCP
</button>
) : null}
</>
)}
</div>
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
<DialogContent
className={cn(
"w-[90%]! max-w-[440px]! flex flex-col gap-4 rounded-[22px] border-none bg-[#1B1F24] p-4",
dmSans125ClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<div className="flex items-start justify-between gap-4">
<DialogHeader className="flex-1 space-y-1 pl-1">
<DialogTitle className="font-semibold text-[#FAFAFA]">
Custom MCP server
</DialogTitle>
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
Add a personal OAuth MCP server by URL.
</p>
</DialogHeader>
<DialogPrimitive.Close
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] transition-opacity hover:opacity-100 focus:outline-hidden"
style={{
boxShadow:
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<XIcon className="size-4 text-[#737373]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<form onSubmit={connectCustom} className="flex flex-col gap-2">
<input
value={customName}
onChange={(event) => setCustomName(event.target.value)}
placeholder="Name"
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<input
value={customServerUrl}
onChange={(event) => setCustomServerUrl(event.target.value)}
placeholder="https://example.com/mcp"
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<div className="flex justify-end pt-2">
<PillButton
type="submit"
disabled={busy?.startsWith("custom:") ?? false}
>
{busy?.startsWith("custom:") && (
<Loader2 className="size-3.5 animate-spin" />
)}
Connect
</PillButton>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,537 @@
"use client"
import { cn } from "@lib/utils"
import { Check, ChevronDown, Loader2, Lock } from "lucide-react"
import { useMemo, useState } from "react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import {
type BrainModelConfig,
type BrainModelRole,
type BrainReasoningEffort,
type BrainReasoningKey,
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"
import { PillButton } from "../integrations/install-steps"
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
// One-line personality tags so non-experts can tell models apart.
const MODEL_TAGS: Record<string, string> = {
"claude-sonnet-5": "balanced",
"claude-opus-4.8": "most capable, slower",
"claude-sonnet-4.6": "balanced",
"claude-haiku-4.5": "fast and light",
"grok-4.5": "sharp all-rounder",
"grok-4.3": "capable",
"grok-4-fast": "fastest",
"gpt-5.6": "capable",
"gpt-5.5": "capable",
}
const EFFORT_LABELS: Record<BrainReasoningEffort, string> = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra high",
}
const ROWS: {
role: BrainModelRole
effortKey: BrainReasoningKey
title: string
help: string
effortHelp: string
}[] = [
{
role: "main",
effortKey: "mainEffort",
title: "Answers",
help: "Writes the replies your brain sends in Slack.",
effortHelp: "Deeper thinking gives better answers but takes longer.",
},
{
role: "triage",
effortKey: "triageEffort",
title: "When to reply",
help: "Decides whether and how the brain responds to a message.",
effortHelp:
"Deeper thinking routes messages more carefully but takes longer.",
},
{
role: "research",
effortKey: "researchEffort",
title: "Web research",
help: "Looks things up on the web when researching your company.",
effortHelp: "Deeper research per web search, at the cost of speed.",
},
]
type FullConfig = Required<BrainModelConfig>
type PresetDef = {
id: string
label: string
description: string
build: (
defaults: BrainModelConfig,
choices: { main: string[] } & Partial<
Record<BrainReasoningKey, BrainReasoningEffort[]>
>,
) => FullConfig
}
const pickEffort = (
options: BrainReasoningEffort[] | undefined,
wanted: BrainReasoningEffort,
fallback: BrainReasoningEffort,
): BrainReasoningEffort =>
!options || options.length === 0 || options.includes(wanted)
? wanted
: fallback
const PRESETS: PresetDef[] = [
{
id: "fast",
label: "Fastest",
description: "Snappy replies, lighter on credits. Best for quick lookups.",
build: (defaults, choices) => ({
main: choices.main.includes("grok-4-fast")
? "grok-4-fast"
: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
}),
},
{
id: "balanced",
label: "Balanced",
description: "Our recommended mix of speed and answer quality.",
build: (defaults) => ({
main: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: defaults.mainEffort ?? "high",
triageEffort: defaults.triageEffort ?? "low",
researchEffort: defaults.researchEffort ?? "high",
}),
},
{
id: "thorough",
label: "Most thorough",
description: "Deepest answers and research. Slower, uses more credits.",
build: (defaults, choices) => ({
main: defaults.main,
triage: defaults.triage,
research: defaults.research,
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
}),
},
]
const CONFIG_KEYS = [
"main",
"triage",
"research",
"mainEffort",
"triageEffort",
"researchEffort",
] as const
const extraHighIsBounded = (model: string): boolean =>
model.startsWith("grok-") || model.startsWith("gpt-")
const controlClass = cn(
dmSans125ClassName(),
"h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
)
const fieldLabel = cn(
dmSans125ClassName(),
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
)
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({
showHeading = true,
}: {
showHeading?: boolean
}) {
const isCompanyBrain = useHasCompanyBrain()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const modelsQuery = useBrainModels(isCompanyBrain)
const update = useUpdateBrainModels()
const [draft, setDraft] = useState<Partial<BrainModelConfig>>({})
const [advancedOpen, setAdvancedOpen] = useState(false)
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 effortFor = (key: BrainReasoningKey): BrainReasoningEffort | "" =>
draft[key] ?? resolved?.[key] ?? defaults?.[key] ?? ""
const dirty = useMemo(() => {
if (!resolved) return false
return ROWS.some(({ role, effortKey }) => {
const modelChanged =
draft[role] !== undefined && draft[role] !== resolved[role]
const effortChanged =
draft[effortKey] !== undefined &&
draft[effortKey] !== resolved[effortKey]
return modelChanged || effortChanged
})
}, [draft, resolved])
const presets = useMemo(() => {
if (!defaults || !choices) return []
return PRESETS.map((p) => ({ ...p, config: p.build(defaults, choices) }))
}, [defaults, choices])
// Preset whose full config matches what's currently on screen (draft over saved).
const activePresetId = useMemo(() => {
if (!resolved) return null
const current: Record<string, string | undefined> = {}
for (const key of CONFIG_KEYS) {
current[key] = draft[key] ?? resolved[key] ?? defaults?.[key]
}
return (
presets.find((p) =>
CONFIG_KEYS.every((key) => current[key] === p.config[key]),
)?.id ?? null
)
}, [presets, draft, resolved, defaults])
if (!isCompanyBrain) return null
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
return (
<section className="flex flex-col gap-4 px-1">
{showHeading ? (
<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>
) : null}
{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">
<div className="grid gap-3 sm:grid-cols-3">
{presets.map((preset) => {
const isActive = preset.id === activePresetId
return (
<button
key={preset.id}
type="button"
disabled={disabled}
aria-pressed={isActive}
onClick={() => {
if (!resolved) return
setDraft({ ...preset.config })
}}
className={cn(
dmSans125ClassName(),
"flex min-w-0 cursor-pointer flex-col gap-1.5 rounded-xl p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
"bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
isActive
? "bg-[#10161f] ring-1 ring-[#2261CA]/45"
: "hover:bg-[#171A1F]",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
{preset.label}
{preset.id === "balanced" ? (
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
Recommended
</span>
) : null}
</span>
{isActive ? (
<Check className="size-4 shrink-0 text-[#6BB0FF]" />
) : null}
</span>
<span className="text-[12px] font-medium leading-[1.5] text-[#737373]">
{preset.description}
</span>
</button>
)
})}
</div>
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className={cn(
dmSans125ClassName(),
"flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-1 py-1 text-[12px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA]",
)}
>
<ChevronDown
className={cn(
"size-3.5 transition-transform",
(advancedOpen || activePresetId === null) && "rotate-180",
)}
/>
Advanced
{activePresetId === null ? (
<span className="text-[11px] text-[#5F6673]">
· custom settings in use
</span>
) : null}
</button>
{advancedOpen || activePresetId === null ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{ROWS.map(({ role, effortKey, title, help, effortHelp }) => {
const options = choices?.[role] ?? []
const current = valueFor(role)
const effortOptions = choices?.[effortKey] ?? []
const currentEffort = effortFor(effortKey)
const isDefault =
defaults?.[role] === current &&
(effortOptions.length === 0 ||
defaults?.[effortKey] === currentEffort)
return (
<div
key={role}
className="flex min-w-0 flex-col gap-5 rounded-xl bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{title}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1.5 min-h-[2lh] text-[12px] font-medium leading-[1.55] text-[#737373]",
)}
>
{help}
</p>
</div>
{isDefault ? (
<span
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-full border border-white/[0.08] px-2 py-0.5 text-[10px] font-medium text-[#737B87]",
)}
>
Default
</span>
) : null}
</div>
<div className="flex flex-col gap-2">
<span className={fieldLabel}>Model</span>
<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)}
{MODEL_TAGS[id] ? (
<span className="text-[#737B87]">
{" "}
· {MODEL_TAGS[id]}
</span>
) : null}
{defaults?.[role] === id ? " (default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{effortOptions.length > 0 ? (
<div className="flex flex-col gap-2">
<span className={fieldLabel}>Thinking depth</span>
<div className="flex w-full items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{effortOptions.map((effort) => {
const isOn = currentEffort === effort
return (
<button
key={effort}
type="button"
aria-pressed={isOn}
disabled={disabled}
onClick={() =>
setDraft((currentDraft) => ({
...currentDraft,
[effortKey]: effort,
}))
}
className={cn(
dmSans125ClassName(),
"h-7 min-w-0 flex-1 cursor-pointer rounded-full px-1 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isOn
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#8B929E] hover:text-[#FAFAFA]",
)}
>
{EFFORT_LABELS[effort]}
</button>
)
})}
</div>
<div className="flex items-center justify-between px-1.5 text-[10px] font-medium text-[#4A5260]">
<span>Faster</span>
<span>Smarter</span>
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[11px] leading-[1.5] text-[#5F6673]",
)}
>
{effortHelp}
</p>
{currentEffort === "xhigh" &&
extraHighIsBounded(current) ? (
<p
className={cn(
dmSans125ClassName(),
"text-[11px] text-amber-300/80",
)}
>
Extra high maps to High for this model provider.
</p>
) : null}
</div>
) : null}
</div>
)
})}
</div>
) : null}
{!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 items-center justify-end gap-3">
{dirty ? (
<button
type="button"
onClick={() => setDraft({})}
disabled={update.isPending}
className={cn(
dmSans125ClassName(),
"h-9 rounded-full px-3 text-[13px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA] disabled:opacity-45",
)}
>
Reset
</button>
) : null}
<PillButton
disabled={disabled || !dirty}
onClick={() => {
const patch: Partial<BrainModelConfig> = {}
for (const { role, effortKey } of ROWS) {
if (draft[role] && draft[role] !== resolved?.[role]) {
patch[role] = draft[role]
}
if (
draft[effortKey] &&
draft[effortKey] !== resolved?.[effortKey]
) {
patch[effortKey] = draft[effortKey]
}
}
update.mutate(patch, { onSuccess: () => setDraft({}) })
}}
>
{update.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : null}
Save
</PillButton>
</div>
)}
</div>
)}
</section>
)
}

View file

@ -10,9 +10,6 @@ import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
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 Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
@ -54,8 +51,6 @@ export const TABS = [
"billing",
"integrations",
"connections",
"company-brain",
"proactiveness",
"support",
] as const
export type SettingsTab = (typeof TABS)[number]
@ -92,18 +87,6 @@ const NAV_ITEMS: NavItem[] = [
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "company-brain",
label: "Company Brain",
description: "Connect apps to your brain — org and personal",
icon: <Building2 className="size-[18px]" />,
},
{
id: "proactiveness",
label: "Proactiveness",
description: "Scheduled digests and unprompted actions",
icon: <ProactivenessIcon className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
@ -166,7 +149,7 @@ export function SettingsContent({
const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
const isCompanyBrain = useHasCompanyBrain()
// Company Brain orgs manage tools inside Company Brain; hide the generic tabs.
// Company Brain orgs manage tools in the Configure view; hide the generic tabs.
const navItems = isCompanyBrain
? NAV_ITEMS.filter(
(item) => item.id !== "integrations" && item.id !== "connections",
@ -498,8 +481,6 @@ export function SettingsContent({
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "company-brain" && <CompanyBrainConnections />}
{activeTab === "proactiveness" && <Proactiveness />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>

View file

@ -15,10 +15,10 @@ import { useRouter } from "next/navigation"
import {
LogOut,
Settings,
Settings2,
RotateCcw,
HelpCircle,
LifeBuoy,
Building2,
Sun,
} from "lucide-react"
import { cn } from "@lib/utils"
@ -26,7 +26,6 @@ import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useViewMode } from "@/lib/view-mode-context"
@ -165,20 +164,13 @@ export function UserProfileMenu({
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings("company-brain")}
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"
>
<Building2 className="size-4 text-[#737373]" />
Company Brain
</DropdownMenuItem>
{isCompanyBrain ? (
<DropdownMenuItem
onClick={() => openSettings("proactiveness")}
onClick={() => void setViewMode("configure")}
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"
>
<ProactivenessIcon className="size-4 text-[#737373]" />
Proactiveness
<Settings2 className="size-4 text-[#737373]" />
Configure
</DropdownMenuItem>
) : null}
{isCompanyBrain ? (

View file

@ -0,0 +1,68 @@
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 BrainReasoningEffort = "low" | "medium" | "high" | "xhigh"
export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort"
export type BrainModelConfig = Record<BrainModelRole, string> &
Partial<Record<BrainReasoningKey, BrainReasoningEffort>>
export type BrainModelsResponse = {
resolved: BrainModelConfig
defaults: BrainModelConfig
choices: Record<BrainModelRole, string[]> &
Partial<Record<BrainReasoningKey, BrainReasoningEffort[]>>
}
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

@ -390,6 +390,7 @@ export function useDocumentMutations({
urls: string[]
project: string
}): Promise<{ success: number; failed: number }> => {
const entityContext = await resolveEntityContext(project)
let success = 0
let failed = 0
@ -400,7 +401,7 @@ export function useDocumentMutations({
documents: chunk.map((url) => ({
content: url,
containerTags: [project],
entityContext,
...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
})),
},

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

@ -222,14 +222,7 @@ export const analytics = {
// settings / spaces / docs analytics
settingsTabChanged: (props: {
tab:
| "account"
| "billing"
| "integrations"
| "connections"
| "company-brain"
| "proactiveness"
| "support"
tab: "account" | "billing" | "integrations" | "connections" | "support"
}) => safeCapture("settings_tab_changed", props),
spaceCreated: () => safeCapture("space_created"),

View file

@ -28,6 +28,7 @@ const viewLiterals = [
"graph",
"list",
"integrations",
"configure",
"chat",
"digests",
// Integration sub-views — each card is its own view

View file

@ -332,7 +332,7 @@
},
"packages/tools": {
"name": "@supermemory/tools",
"version": "2.1.0",
"version": "2.1.1",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.25",
"@ai-sdk/openai": "^2.0.23",

View file

@ -9,7 +9,13 @@ following the same pattern as the built-in Mem0 integration.
from typing import Any, Literal, Optional
from agent_framework import BaseContextProvider
try:
from agent_framework import BaseContextProvider
except ImportError:
# Renamed in agent-framework-core 1.0.0 stable; the interface is
# unchanged (source_id __init__, before_run/after_run hooks with
# identical keyword-only signatures).
from agent_framework import ContextProvider as BaseContextProvider
from .connection import AgentSupermemory
from .utils import (

View file

@ -1,7 +1,7 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.1.0",
"version": "2.1.1",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",

View file

@ -4,12 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
// can be exercised deterministically without any network access. We only need
// `search.execute` to return a single document with known multi-line content.
const searchExecute = vi.fn()
const addMock = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = { execute: searchExecute }
add = vi.fn()
add = addMock
memories = { forget: vi.fn() }
},
}
@ -83,3 +84,100 @@ describe("ClaudeMemoryTool view_range", () => {
expect(result.content).not.toContain("line5")
})
})
describe("ClaudeMemoryTool exact-file matching", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchExecute.mockReset()
addMock.mockReset()
tool = new ClaudeMemoryTool("test-api-key")
})
it("view finds the exact file even when a neighbour ranks first", async () => {
searchExecute.mockResolvedValue({
results: [
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
{ documentId: "memories_notes_txt", content: FILE_CONTENT },
],
})
const result = await tool.handleCommand({
command: "view",
path: FILE_PATH,
})
expect(result.success).toBe(true)
expect(result.content).toContain("line1")
expect(result.content).not.toContain("backup stuff")
})
it("view reports not-found instead of returning a different file", async () => {
// Semantic search can surface a similarly-named file; that must not
// be served as the requested one.
searchExecute.mockResolvedValue({
results: [
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
],
})
const result = await tool.handleCommand({
command: "view",
path: FILE_PATH,
})
expect(result.success).toBe(false)
expect(result.error).toContain("File not found")
})
it("str_replace refuses to modify a different file than requested", async () => {
searchExecute.mockResolvedValue({
results: [
{ documentId: "memories_notes_backup_txt", content: "backup stuff" },
],
})
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "backup",
new_str: "primary",
})
expect(result.success).toBe(false)
expect(addMock).not.toHaveBeenCalled()
})
})
describe("ClaudeMemoryTool str_replace replacement literalness", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchExecute.mockReset()
addMock.mockReset()
searchExecute.mockResolvedValue({
results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }],
})
tool = new ClaudeMemoryTool("test-api-key")
})
it.each([
"$&",
"$'",
"$`",
"$$",
])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => {
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "line3",
new_str: `price is ${dollarSequence} today`,
})
expect(result.success).toBe(true)
expect(addMock).toHaveBeenCalledTimes(1)
const stored = addMock.mock.calls[0]?.[0]?.content as string
expect(stored).toContain(`price is ${dollarSequence} today`)
expect(stored).not.toContain("line3")
})
})

View file

@ -262,29 +262,21 @@ export class ClaudeMemoryTool {
viewRange?: [number, number],
): Promise<MemoryResponse> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
const response = await this.client.search.execute({
q: normalizedId,
containerTags: this.containerTags,
limit: 1,
includeFullDocs: true,
})
// Try to find exact match by customId
const exactMatch = response.results?.find(
(r) => r.documentId === normalizedId,
)
const document = exactMatch || response.results?.[0]
if (!document) {
// Same lookup as every mutating command: limit 5 so the exact
// customId match is findable among semantic near-neighbours.
// With the old limit of 1, a similarly-named file ranking first
// made this return the wrong file's contents as a success.
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
success: false,
error: `File not found: ${filePath}`,
error: readResult.error || `File not found: ${filePath}`,
}
}
let content = document.content || ""
const document = readResult.document
let content: string = document.raw || document.content || ""
// Apply line range if specified
if (viewRange) {
@ -393,8 +385,10 @@ export class ClaudeMemoryTool {
}
}
// Replace the string
const newContent = originalContent.replace(oldStr, newStr)
// Replace the string. The function replacer keeps `$` sequences
// in the replacement literal — a bare string here would expand
// patterns like $&, $', and $` and silently corrupt the file.
const newContent = originalContent.replace(oldStr, () => newStr)
// Update the document
const normalizedId = this.normalizePathToCustomId(filePath)
@ -590,11 +584,12 @@ export class ClaudeMemoryTool {
includeFullDocs: true,
})
// Try to find exact match by customId first
const exactMatch = response.results?.find(
// Only accept the exact customId match. Falling back to the top
// semantic hit would let callers read — and worse, modify or
// delete — a different file than the one they asked for.
const document = response.results?.find(
(r) => r.documentId === normalizedId,
)
const document = exactMatch || response.results?.[0]
if (!document) {
return {

View file

@ -399,7 +399,7 @@ const addMemoryTool = async (
* @param options.customId - Optional conversation ID to group messages for contextual memory generation
* @param options.verbose - Enable detailed logging of memory operations (default: false)
* @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile")
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "never")
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always")
* @returns Object with `wrapClient` and `createClient` methods
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
*