mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): Company Brain proactivity settings UI (#1374)
Part of ENG-1135
This commit is contained in:
parent
8071a7b085
commit
1034e337ba
4 changed files with 390 additions and 21 deletions
|
|
@ -5,7 +5,9 @@ import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react"
|
|||
import { useState } from "react"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -15,13 +17,14 @@ type ConfigureSection =
|
|||
| "company-brain"
|
||||
| "models"
|
||||
| "workspace-prompt"
|
||||
| "proactivity"
|
||||
| "automations"
|
||||
|
||||
const SECTIONS: {
|
||||
id: ConfigureSection
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Blocks
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}[] = [
|
||||
{
|
||||
id: "company-brain",
|
||||
|
|
@ -44,6 +47,13 @@ const SECTIONS: {
|
|||
"Persistent guidance for how your brain works across the workspace. Fixed safety, access, and approval constraints still apply.",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
id: "proactivity",
|
||||
label: "Proactivity",
|
||||
description:
|
||||
"When Company Brain speaks up in Slack without being asked. Quiet channels are still read and remembered.",
|
||||
icon: ProactivenessIcon,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
|
|
@ -62,16 +72,13 @@ export function ConfigureView() {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
|
||||
)}
|
||||
className={cn(dmSans125ClassName(), "flex min-h-full w-full 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">
|
||||
<div className="mx-auto flex w-full max-w-[88rem] 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"
|
||||
|
|
@ -134,6 +141,8 @@ export function ConfigureView() {
|
|||
<CompanyBrainModels showHeading={false} />
|
||||
) : activeSection === "workspace-prompt" ? (
|
||||
<WorkspacePrompt key={org?.id} showHeading={false} />
|
||||
) : activeSection === "proactivity" ? (
|
||||
<CompanyBrainProactivity />
|
||||
) : (
|
||||
<Proactiveness />
|
||||
)}
|
||||
|
|
|
|||
292
apps/web/components/settings/company-brain-proactivity.tsx
Normal file
292
apps/web/components/settings/company-brain-proactivity.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Check, Loader2, Lock, X } from "lucide-react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
type BrainChannelProactivity,
|
||||
type BrainProactivityDefault,
|
||||
useBrainSettings,
|
||||
useUpdateBrainSettings,
|
||||
} from "@/hooks/use-brain-settings"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Channel = { id: string; name: string; isPrivate: boolean }
|
||||
|
||||
const HOME_CHANNEL_NAME = "company-brain"
|
||||
const ADD_PLACEHOLDER = "__add__"
|
||||
|
||||
const MODES: {
|
||||
id: BrainProactivityDefault
|
||||
label: string
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
id: "all_channels",
|
||||
label: "All channels",
|
||||
description: "Joins any conversation it's been added to when it can help.",
|
||||
},
|
||||
{
|
||||
id: "own_channel_only",
|
||||
label: "Only its own channel",
|
||||
description: "Speaks only in #company-brain unless @mentioned or DMed.",
|
||||
},
|
||||
]
|
||||
|
||||
const fieldLabel = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
|
||||
)
|
||||
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 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"
|
||||
|
||||
export default function CompanyBrainProactivity() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
const { org } = useAuth()
|
||||
|
||||
const settingsQuery = useBrainSettings(isCompanyBrain)
|
||||
const update = useUpdateBrainSettings()
|
||||
|
||||
const slackStatusQuery = useQuery({
|
||||
queryKey: ["brain", "slack-status", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load Slack status")
|
||||
return (await res.json()) as { connected: boolean }
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Same key + endpoint as the automations picker so react-query dedupes.
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "channels", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/automations/channels`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load channels")
|
||||
return ((await res.json()) as { channels: Channel[] }).channels ?? []
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (!isCompanyBrain) {
|
||||
return (
|
||||
<div className="px-1 pt-2">
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
|
||||
Company Brain isn't enabled for this organization.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const proactivity = settingsQuery.data?.proactivity
|
||||
const activeMode = proactivity?.default ?? "all_channels"
|
||||
const overrides = proactivity?.channels ?? {}
|
||||
const channels = channelsQuery.data ?? []
|
||||
const channelName = (id: string) =>
|
||||
channels.find((ch) => ch.id === id)?.name ?? id
|
||||
const addable = channels.filter(
|
||||
(ch) => !overrides[ch.id] && ch.name !== HOME_CHANNEL_NAME,
|
||||
)
|
||||
const disabled = !isAdmin || settingsQuery.isLoading || update.isPending
|
||||
|
||||
const setMode = (mode: BrainProactivityDefault) => {
|
||||
if (mode === activeMode) return
|
||||
update.mutate({ proactivity: { default: mode } })
|
||||
}
|
||||
const setOverride = (
|
||||
channelId: string,
|
||||
value: BrainChannelProactivity | null,
|
||||
) => {
|
||||
update.mutate({ proactivity: { channels: { [channelId]: value } } })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-1">
|
||||
{settingsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading proactivity…
|
||||
</div>
|
||||
) : settingsQuery.isError ? (
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
|
||||
Couldn't load proactivity settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{MODES.map((mode) => {
|
||||
const isActive = mode.id === activeMode
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => setMode(mode.id)}
|
||||
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]">
|
||||
{mode.label}
|
||||
{mode.id === "all_channels" ? (
|
||||
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
|
||||
Default
|
||||
</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]">
|
||||
{mode.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Channel exceptions</span>
|
||||
{Object.entries(overrides).map(([channelId, value]) => (
|
||||
<div
|
||||
key={channelId}
|
||||
className="flex items-center justify-between gap-3 rounded-xl bg-[#14161A] px-4 py-2.5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
#{channelName(channelId)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex 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)]">
|
||||
{(["proactive", "quiet"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={value === option}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (value !== option) setOverride(channelId, option)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-7 cursor-pointer rounded-full px-3 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
value === option
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{option === "proactive" ? "Proactive" : "Quiet"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOverride(channelId, null)}
|
||||
className="cursor-pointer text-[#6B6B6B] transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove exception for #${channelName(channelId)}`}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{addable.length > 0 ? (
|
||||
<Select
|
||||
value={ADD_PLACEHOLDER}
|
||||
disabled={disabled}
|
||||
onValueChange={(channelId) => {
|
||||
if (channelId === ADD_PLACEHOLDER) return
|
||||
setOverride(
|
||||
channelId,
|
||||
activeMode === "all_channels" ? "quiet" : "proactive",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn(controlClass, "sm:w-72")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
<SelectItem
|
||||
value={ADD_PLACEHOLDER}
|
||||
className={selectItemClass}
|
||||
>
|
||||
Add a channel exception…
|
||||
</SelectItem>
|
||||
{addable.map((ch) => (
|
||||
<SelectItem
|
||||
key={ch.id}
|
||||
value={ch.id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{ch.isPrivate ? "🔒 " : "# "}
|
||||
{ch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : channelsQuery.isLoading || slackStatusQuery.isLoading ? null : (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{slackStatusQuery.data?.connected === false
|
||||
? "Connect Slack to set per-channel exceptions."
|
||||
: "Invite Company Brain to a Slack channel to list it here."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<Lock className="size-3.5" />
|
||||
Only organization admins can change these.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,24 +6,16 @@ export function ProactivenessIcon({ className }: { className?: string }) {
|
|||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13.5 4H11C6.75736 4 4.63604 4 3.31802 5.31802C2 6.63604 2 8.75736 2 13C2 17.2426 2 19.364 3.31802 20.682C4.63604 22 6.75736 22 11 22C15.2426 22 17.364 22 18.682 20.682C20 19.364 20 17.2426 20 13V10.5" />
|
||||
<path d="M6 8L16 18" />
|
||||
<path d="M6 14L10 18" />
|
||||
<path d="M12 8L16 12" />
|
||||
<path d="M19.5 2.9375V4.5M19.5 4.5V6.0625M19.5 4.5H18.25M19.5 4.5H20.75M22 4.5L20.9156 4.13852C20.4179 3.97263 20.0274 3.58211 19.8615 3.08443L19.5 2L19.1385 3.08443C18.9726 3.58211 18.5821 3.97263 18.0844 4.13852L17 4.5L18.0844 4.86148C18.5821 5.02737 18.9726 5.41789 19.1385 5.91557L19.5 7L19.8615 5.91557C20.0274 5.41789 20.4179 5.02737 20.9156 4.86148L22 4.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
76
apps/web/hooks/use-brain-settings.ts
Normal file
76
apps/web/hooks/use-brain-settings.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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/settings`
|
||||
|
||||
export type BrainProactivityDefault = "all_channels" | "own_channel_only"
|
||||
export type BrainChannelProactivity = "proactive" | "quiet"
|
||||
|
||||
export type BrainSettingsResponse = {
|
||||
proactivity: {
|
||||
default: BrainProactivityDefault
|
||||
channels: Record<string, BrainChannelProactivity>
|
||||
}
|
||||
choices: {
|
||||
proactivityDefault: BrainProactivityDefault[]
|
||||
channelProactivity: BrainChannelProactivity[]
|
||||
}
|
||||
}
|
||||
|
||||
// null clears: default -> reset, channels[id] -> remove that override.
|
||||
export type BrainSettingsPatch = {
|
||||
proactivity?: {
|
||||
default?: BrainProactivityDefault | null
|
||||
channels?: Record<string, BrainChannelProactivity | null> | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function useBrainSettings(enabled: boolean) {
|
||||
const { org } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["brain", "settings", org?.id],
|
||||
queryFn: async (): Promise<BrainSettingsResponse> => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to load settings")
|
||||
return res.json()
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBrainSettings() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: BrainSettingsPatch) => {
|
||||
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 these settings.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
throw new Error(b.message ?? b.error ?? "Failed to save settings")
|
||||
}
|
||||
return res.json() as Promise<BrainSettingsResponse>
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["brain", "settings", org?.id], data)
|
||||
toast.success("Proactivity saved")
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to save settings",
|
||||
),
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue