fix(web): harden workspace persona settings

This commit is contained in:
Vorflux AI 2026-07-21 11:31:38 +00:00
parent 7647888b4f
commit d895df1a92
6 changed files with 144 additions and 85 deletions

View file

@ -39,6 +39,7 @@ import { useQueryState } from "nuqs"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { useContainerTags } from "@/hooks/use-container-tags"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { PopoverAnchor } from "@ui/components/popover"
import { WorkspacePersona } from "@/components/settings/workspace-persona"
import { OrgPlanBadge } from "@/components/org-plan-badge"
@ -135,6 +136,7 @@ function isPendingInvitation(invitation: {
export default function Account() {
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
const autumn = useCustomer()
const isCompanyBrain = useHasCompanyBrain()
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id)
@ -614,7 +616,7 @@ export default function Account() {
</div>
</section>
{canManageTeam && <WorkspacePersona />}
{canManageTeam && isCompanyBrain && <WorkspacePersona />}
<DigestPreferences />

View file

@ -1,36 +1,70 @@
"use client"
import { LoaderIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
function SectionTitle({ children }: { children: React.ReactNode }) {
const DESCRIPTION_ID = "workspace-persona-description"
const COUNTER_ID = "workspace-persona-counter"
const HEADING_ID = "workspace-persona-heading"
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<p
<h2
id={HEADING_ID}
className={cn(
dmSans125ClassName(),
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
</p>
</h2>
)
}
function PersonaHeader() {
return (
<div className="min-w-0">
<SectionHeading>Workspace Persona</SectionHeading>
<p
id={DESCRIPTION_ID}
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Guide how Company Brain communicates and works with your workspace.
</p>
</div>
)
}
export function WorkspacePersona() {
const { data: settings, isLoading, isError } = useOrgSettings()
const { org } = useAuth()
const settingsQuery = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [persona, setPersona] = useState("")
const syncedPersona = useRef<{ orgId: string; value: string } | null>(null)
const savedPersona = settings?.workspacePersona ?? ""
const savedPersona = settingsQuery.data?.workspacePersona ?? ""
const dirty = persona.trim() !== savedPersona.trim()
const settingsReady = !isLoading && !isError
useEffect(() => {
setPersona(settings?.workspacePersona ?? "")
}, [settings?.workspacePersona])
if (!settingsQuery.data) return
const nextPersona = settingsQuery.data.workspacePersona ?? ""
const orgId = org?.id ?? ""
const previous = syncedPersona.current
setPersona((current) => {
if (!previous || previous.orgId !== orgId) return nextPersona
return current === previous.value ? nextPersona : current
})
syncedPersona.current = { orgId, value: nextPersona }
}, [org?.id, settingsQuery.data])
const handleSave = () => {
updateSettings.mutate({
@ -39,63 +73,92 @@ export function WorkspacePersona() {
}
return (
<section id="workspace-persona" className="flex flex-col gap-3 px-1">
<div className="min-w-0">
<SectionTitle>Workspace Persona</SectionTitle>
<p
<section
id="workspace-persona"
aria-labelledby={HEADING_ID}
className="flex flex-col gap-3 px-1"
>
<PersonaHeader />
{settingsQuery.isLoading ? (
<div
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
dmSansClassName(),
"flex min-h-[96px] items-center justify-center gap-2 rounded-[12px] border border-white/[0.08] bg-[#0D121A] text-[12px] text-[#737373]",
)}
>
Guide how Company Brain communicates and works with your workspace.
</p>
</div>
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
<textarea
value={persona}
onChange={(event) => setPersona(event.target.value)}
placeholder="Describe your workspace's goals, communication style, and how you prefer Company Brain to collaborate with your team..."
maxLength={1500}
disabled={!settingsReady || updateSettings.isPending}
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#737373] tabular-nums">
{persona.length}/1500
</span>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPersona(savedPersona)}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateSettings.isPending && (
<LoaderIcon className="size-3 animate-spin" />
)}
Save
</button>
</div>
)}
<LoaderIcon className="size-3 animate-spin" />
Loading workspace persona…
</div>
</div>
) : settingsQuery.isError ? (
<div
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
>
<p className="text-[13px] text-[#A3A3A3]">
Workspace persona could not be loaded.
</p>
<button
type="button"
onClick={() => void settingsQuery.refetch()}
disabled={settingsQuery.isFetching}
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
{settingsQuery.isFetching && (
<LoaderIcon className="size-3 animate-spin" />
)}
Try again
</button>
</div>
) : (
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
<textarea
aria-labelledby={HEADING_ID}
aria-describedby={`${DESCRIPTION_ID} ${COUNTER_ID}`}
value={persona}
onChange={(event) => setPersona(event.target.value)}
placeholder="Describe your workspace's goals, communication style, and how you prefer Company Brain to collaborate with your team..."
maxLength={1500}
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none"
/>
<div className="flex items-center justify-between">
<span
id={COUNTER_ID}
className="text-[11px] text-[#737373] tabular-nums"
>
{persona.length}/1500
</span>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPersona(savedPersona)}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateSettings.isPending && (
<LoaderIcon className="size-3 animate-spin" />
)}
Save
</button>
</div>
)}
</div>
</div>
)}
</section>
)
}

View file

@ -3,8 +3,6 @@ import { toast } from "sonner"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
export type OrgSettings = {
shouldLLMFilter: boolean
filterPrompt: string | null
@ -52,25 +50,19 @@ export function useUpdateOrgSettings() {
return useMutation({
mutationFn: async (settings: Partial<OrgSettings>) => {
const res = await fetch(`${API_BASE}/settings`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify(settings),
const response = await $fetch("@patch/settings", {
body: settings,
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(body?.message || "Failed to save settings")
if (response.error) {
throw new Error(response.error.message || "Failed to save settings")
}
return res.json()
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
onSuccess: (_data, settings) => {
queryClient.setQueryData<OrgSettings>(
["settings", "org", orgId],
(current) => (current ? { ...current, ...settings } : current),
)
toast.success("Settings saved")
},
onError: (error) => {

View file

@ -33,10 +33,11 @@ import {
const SettingsResponseSchema = z.object({
message: z.string(),
settings: z.object({
excludeItems: z.array(z.string().min(1).max(20)).optional(),
filterPrompt: z.string().min(1).max(750).optional(),
includeItems: z.array(z.string().min(1).max(20)).optional(),
excludeItems: z.array(z.string().min(1).max(20)).nullable().optional(),
filterPrompt: z.string().min(1).max(750).nullable().optional(),
includeItems: z.array(z.string().min(1).max(20)).nullable().optional(),
shouldLLMFilter: z.boolean().optional(),
workspacePersona: z.string().max(1500).nullable().optional(),
}),
})

View file

@ -801,7 +801,7 @@ export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
id: true,
orgId: true,
updatedAt: true,
})
}).partial()
export const ConnectionResponseSchema = z.object({
createdAt: z.string().datetime(),

View file

@ -315,6 +315,7 @@ export const OrganizationSettingsSchema = z.object({
filterPrompt: z.string().nullable().optional(),
includeItems: z.array(z.string()).nullable().optional(),
excludeItems: z.array(z.string()).nullable().optional(),
workspacePersona: z.string().max(1500).nullable().optional(),
// Google Drive custom keys
googleDriveCustomKeyEnabled: z.boolean().default(false),