fix(web): align workspace prompt settings contract

This commit is contained in:
Vorflux AI 2026-07-21 16:26:29 +00:00
parent 78017586e8
commit 562e721539
3 changed files with 39 additions and 61 deletions

View file

@ -1,11 +1,10 @@
"use client"
import { LoaderIcon } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { 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"
const DESCRIPTION_ID = "workspace-prompt-description"
const COUNTER_ID = "workspace-prompt-counter"
@ -36,65 +35,54 @@ function PromptHeader() {
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Set persistent guidance for operating preferences, priorities, source
and tool choices, workflows, terminology, formatting, and communication
style. This guidance cannot override fixed safety, access, or approval
constraints.
Set persistent guidance for how Company Brain works across your
workspace.
</p>
</div>
)
}
export function WorkspacePrompt() {
const { org } = useAuth()
const settingsQuery = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [prompt, setPrompt] = useState("")
const syncedPrompt = useRef<{ orgId: string; value: string } | null>(null)
const [draft, setDraft] = useState<string | null>(null)
const savedPrompt = settingsQuery.data?.workspacePrompt ?? ""
const dirty = prompt.trim() !== savedPrompt.trim()
useEffect(() => {
if (!settingsQuery.data) return
const nextPrompt = settingsQuery.data.workspacePrompt ?? ""
const orgId = org?.id ?? ""
const previous = syncedPrompt.current
setPrompt((current) => {
if (!previous || previous.orgId !== orgId) return nextPrompt
return current === previous.value ? nextPrompt : current
})
syncedPrompt.current = { orgId, value: nextPrompt }
}, [org?.id, settingsQuery.data])
const prompt = draft ?? savedPrompt
const dirty = draft !== null && draft.trim() !== savedPrompt.trim()
const handleSave = () => {
updateSettings.mutate({
workspacePrompt: prompt.trim() ? prompt.trim() : null,
})
updateSettings.mutate(
{
workspacePrompt: prompt.trim() ? prompt.trim() : null,
},
{ onSuccess: () => setDraft(null) },
)
}
return (
<section
id="workspace-prompt"
aria-labelledby={HEADING_ID}
aria-busy={settingsQuery.isLoading || updateSettings.isPending}
className="flex flex-col gap-3 px-1"
>
<PromptHeader />
{settingsQuery.isLoading ? (
<div
<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 className="size-3 animate-spin" />
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
Loading workspace prompt…
</div>
</output>
) : settingsQuery.isError ? (
<div
role="alert"
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
>
<p className="text-[13px] text-[#A3A3A3]">
@ -104,10 +92,10 @@ export function WorkspacePrompt() {
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"
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 className="size-3 animate-spin" />
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
)}
Try again
</button>
@ -118,10 +106,10 @@ export function WorkspacePrompt() {
aria-labelledby={HEADING_ID}
aria-describedby={`${DESCRIPTION_ID} ${COUNTER_ID}`}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Add persistent guidance for how Company Brain should operate across your workspace..."
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-[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"
className="min-h-[96px] w-full resize-none 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]"
/>
<div className="flex items-center justify-between">
<span
@ -134,11 +122,11 @@ export function WorkspacePrompt() {
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPrompt(savedPrompt)}
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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
"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
@ -149,11 +137,14 @@ export function WorkspacePrompt() {
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",
"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 className="size-3 animate-spin" />
<LoaderIcon
aria-hidden="true"
className="size-3 animate-spin"
/>
)}
Save
</button>

View file

@ -11,10 +11,6 @@ export type OrgSettings = {
excludeItems?: string[] | null
}
type OrgSettingsResponse = {
settings?: Partial<OrgSettings>
} & Partial<OrgSettings>
export function useOrgSettings() {
const { org } = useAuth()
const orgId = org?.id ?? ""
@ -22,14 +18,11 @@ 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,
@ -63,7 +56,7 @@ export function useUpdateOrgSettings() {
onMutate: () => ({ orgId }),
onSuccess: async (data, _settings, mutationContext) => {
const queryKey = ["settings", "org", mutationContext.orgId] as const
const canonicalSettings = data?.settings
const canonicalSettings = data?.updated
if (canonicalSettings) {
queryClient.setQueryData<OrgSettings>(queryKey, (current) =>
current ? { ...current, ...canonicalSettings } : current,

View file

@ -29,16 +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)).nullable().optional(),
filterPrompt: z.string().max(750).nullable().optional(),
includeItems: z.array(z.string().min(1).max(20)).nullable().optional(),
shouldLLMFilter: z.boolean().optional(),
workspacePrompt: z.string().max(1500).nullable().optional(),
}),
const UpdateSettingsResponseSchema = z.object({
orgId: z.string(),
orgSlug: z.string(),
updated: SettingsRequestSchema,
})
// Analytics request schema - custom to console
@ -196,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() }),