Add Nova workspace prompt settings (#1323)

Adds a dedicated Workspace Prompt editor for Company Brain organizations while preserving the existing Organization Context ingestion-filter controls for every organization manager.

## Changes

- Keeps Organization Context byte-for-byte unchanged and available independently to all organization managers.
- Adds Workspace Prompt as a separate Company-Brain-only section below it, using the established settings styling and contextual divider.
- Describes Workspace Prompt as persistent guidance that can shape operating preferences, priorities, source/tool choices, workflows, terminology, formatting, and communication style.
- Adds nullable, 1,500-character `workspacePrompt` support to shared request, GET response, and PATCH response contracts.
- Aligns PATCH validation with the real `{ orgId, orgSlug, updated }` API response.
- Merges canonical `updated` settings into the submitting organization’s cache, then exactly refetches that organization.
- Preserves drafts during background refetches, isolates organization switches, retains actionable errors, accessibility, empty `filterPrompt` compatibility, and `X-App-Source: nova`.

## Testing

- Passed focused Biome checks on all changed files.
- Passed `packages/lib` and `packages/validation` TypeScript checks.
- Verified GET/PATCH settings response contracts, partial/null/limit validation, canonical cache merge, and exact organization-bound invalidation.
- Verified Organization Context remains unchanged and Workspace Prompt is separately Company-Brain/manager-gated.
- Confirmed no remaining Workspace Persona identifiers.
- Public preview returns HTTP 200; authenticated settings interactions remain unavailable without a saved OAuth session.
- Full web type-check remains blocked by unrelated baseline diagnostics; none reference changed files.
- No dedicated tests were added, per requester instruction.

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/7544b72b-aeca-48e2-81c3-514df21cd081)
- Requested by: Soham Daga (soham@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
vorflux 2026-07-29 21:13:59 +00:00
parent 5fa0535a64
commit 8071a7b085
6 changed files with 233 additions and 41 deletions

View file

@ -1,15 +1,21 @@
"use client"
import { cn } from "@lib/utils"
import { Blocks, CalendarClock, Cpu } from "lucide-react"
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 Proactiveness from "@/components/settings/proactiveness"
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
import { ErrorBoundary } from "@/components/error-boundary"
import { useAuth } from "@lib/auth-context"
import { dmSans125ClassName } from "@/lib/fonts"
type ConfigureSection = "company-brain" | "models" | "automations"
type ConfigureSection =
| "company-brain"
| "models"
| "workspace-prompt"
| "automations"
const SECTIONS: {
id: ConfigureSection
@ -31,6 +37,13 @@ const SECTIONS: {
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
icon: Cpu,
},
{
id: "workspace-prompt",
label: "Workspace Prompt",
description:
"Persistent guidance for how your brain works across the workspace. Fixed safety, access, and approval constraints still apply.",
icon: ScrollText,
},
{
id: "automations",
label: "Automations",
@ -41,6 +54,7 @@ const SECTIONS: {
]
export function ConfigureView() {
const { org } = useAuth()
const [activeSection, setActiveSection] =
useState<ConfigureSection>("company-brain")
const active = SECTIONS.find((section) => section.id === activeSection)
@ -118,6 +132,8 @@ export function ConfigureView() {
<CompanyBrainConnections />
) : activeSection === "models" ? (
<CompanyBrainModels showHeading={false} />
) : activeSection === "workspace-prompt" ? (
<WorkspacePrompt key={org?.id} showHeading={false} />
) : (
<Proactiveness />
)}

View file

@ -0,0 +1,186 @@
"use client"
import { LoaderIcon } from "lucide-react"
import { useState } from "react"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
const DESCRIPTION_ID = "workspace-prompt-description"
const COUNTER_ID = "workspace-prompt-counter"
const HEADING_ID = "workspace-prompt-heading"
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h2
id={HEADING_ID}
className={cn(
dmSans125ClassName(),
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
</h2>
)
}
function PromptHeader() {
return (
<div className="min-w-0">
<SectionHeading>Workspace Prompt</SectionHeading>
<p
id={DESCRIPTION_ID}
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Set persistent guidance for how Company Brain works across your
workspace.
</p>
</div>
)
}
export function WorkspacePrompt({
showHeading = true,
}: {
showHeading?: boolean
}) {
const isCompanyBrain = useHasCompanyBrain()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const settingsQuery = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [draft, setDraft] = useState<string | null>(null)
const savedPrompt = settingsQuery.data?.workspacePrompt ?? ""
const prompt = draft ?? savedPrompt
const dirty = draft !== null && draft.trim() !== savedPrompt.trim()
const canClear = !dirty && savedPrompt.length > 0 && isAdmin
const handleSave = () => {
updateSettings.mutate(
{
workspacePrompt: prompt.trim() ? prompt.trim() : null,
},
{ onSuccess: () => setDraft(null) },
)
}
if (!isCompanyBrain) return null
return (
<section
id="workspace-prompt"
aria-label={showHeading ? undefined : "Workspace prompt"}
aria-labelledby={showHeading ? HEADING_ID : undefined}
aria-busy={settingsQuery.isLoading || updateSettings.isPending}
className="flex w-full max-w-3xl flex-col gap-3 px-1"
>
{showHeading ? <PromptHeader /> : null}
{settingsQuery.isLoading ? (
<output
aria-live="polite"
className={cn(
dmSansClassName(),
"flex min-h-[96px] items-center justify-center gap-2 rounded-[12px] border border-white/[0.08] bg-[#0D121A] text-[12px] text-[#737373]",
)}
>
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
Loading workspace prompt
</output>
) : settingsQuery.isError ? (
<div
role="alert"
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
>
<p className="text-[13px] text-[#A3A3A3]">
Workspace prompt 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
{settingsQuery.isFetching && (
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
)}
Try again
</button>
</div>
) : (
<div className={cn(dmSansClassName(), "flex flex-col gap-4")}>
<textarea
aria-label={showHeading ? undefined : "Workspace prompt"}
aria-labelledby={showHeading ? HEADING_ID : undefined}
aria-describedby={
showHeading ? `${DESCRIPTION_ID} ${COUNTER_ID}` : COUNTER_ID
}
disabled={!isAdmin}
value={prompt}
onChange={(event) => setDraft(event.target.value)}
placeholder="Describe operating preferences, priorities, source and tool choices, workflows, terminology, formatting, and communication style. Fixed safety, access, and approval constraints still apply."
maxLength={1500}
className="min-h-[160px] 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-visible:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex items-center justify-between">
<span
id={COUNTER_ID}
className="text-[11px] text-[#737373] tabular-nums"
>
{prompt.length}/1500
</span>
{canClear && (
<button
type="button"
onClick={() => setDraft("")}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#E5484D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer",
)}
>
Clear prompt
</button>
)}
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setDraft(null)}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateSettings.isPending && (
<LoaderIcon
aria-hidden="true"
className="size-3 animate-spin"
/>
)}
Save
</button>
</div>
)}
</div>
</div>
)}
</section>
)
}

View file

@ -3,19 +3,14 @@ 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
workspacePrompt: string | null
includeItems?: string[] | null
excludeItems?: string[] | null
}
type OrgSettingsResponse = {
settings?: Partial<OrgSettings>
} & Partial<OrgSettings>
export function useOrgSettings() {
const { org } = useAuth()
const orgId = org?.id ?? ""
@ -23,17 +18,15 @@ export function useOrgSettings() {
return useQuery({
queryKey: ["settings", "org", orgId],
queryFn: async (): Promise<OrgSettings> => {
const response = await $fetch("@get/settings", {
disableValidation: true,
})
const response = await $fetch("@get/settings")
if (response.error) {
throw new Error(response.error.message || "Failed to load settings")
}
const data = response.data as OrgSettingsResponse | null
const settings = data?.settings ?? data ?? {}
const settings = response.data ?? {}
return {
shouldLLMFilter: settings.shouldLLMFilter ?? false,
filterPrompt: settings.filterPrompt ?? null,
workspacePrompt: settings.workspacePrompt ?? null,
includeItems: settings.includeItems ?? null,
excludeItems: settings.excludeItems ?? null,
}
@ -50,25 +43,26 @@ 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", {
cause: response.error,
})
}
return res.json()
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
onMutate: () => ({ orgId }),
onSuccess: async (data, _settings, mutationContext) => {
const queryKey = ["settings", "org", mutationContext.orgId] as const
const canonicalSettings = data?.updated
if (canonicalSettings) {
queryClient.setQueryData<OrgSettings>(queryKey, (current) =>
current ? { ...current, ...canonicalSettings } : current,
)
}
await queryClient.invalidateQueries({ queryKey, exact: true })
toast.success("Settings saved")
},
onError: (error) => {

View file

@ -29,15 +29,10 @@ import {
UpdateContainerTagSettingsRequestSchema,
} from "../validation/api"
// Settings response schema - this is custom to console (not in shared validation)
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(),
shouldLLMFilter: z.boolean().optional(),
}),
const UpdateSettingsResponseSchema = z.object({
orgId: z.string(),
orgSlug: z.string(),
updated: SettingsRequestSchema,
})
// Analytics request schema - custom to console
@ -195,11 +190,11 @@ export const apiSchema = createSchema({
// Settings operations
"@get/settings": {
output: z.object({}).passthrough(),
output: SettingsRequestSchema,
},
"@patch/settings": {
input: SettingsRequestSchema,
output: SettingsResponseSchema,
output: UpdateSettingsResponseSchema,
},
"@post/settings/reset": {
input: z.object({ confirmation: z.string() }),

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(),
workspacePrompt: z.string().max(1500).nullable().optional(),
// Google Drive custom keys
googleDriveCustomKeyEnabled: z.boolean().default(false),