From 4e392cad669bf20bb039fae5370d5f29fa8ba411 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 07:35:58 -0500 Subject: [PATCH] Refine company memory settings UX Co-Authored-By: Paperclip --- ui/src/components/CompanyMemorySettings.tsx | 1181 +++++++++++-------- 1 file changed, 678 insertions(+), 503 deletions(-) diff --git a/ui/src/components/CompanyMemorySettings.tsx b/ui/src/components/CompanyMemorySettings.tsx index ddf2bdc8b2e..c45cfc6b754 100644 --- a/ui/src/components/CompanyMemorySettings.tsx +++ b/ui/src/components/CompanyMemorySettings.tsx @@ -1,19 +1,44 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + Agent, MemoryBinding, - MemoryListRecordsQuery, + MemoryBindingTarget, MemoryProviderDescriptor, - MemoryRecord, + Project, } from "@paperclipai/shared"; -import { MEMORY_RETENTION_STATES, MEMORY_SCOPE_TYPES, MEMORY_SENSITIVITY_LABELS } from "@paperclipai/shared"; +import { Bot, FolderKanban, GitBranch, Plus, Star } from "lucide-react"; +import { agentsApi } from "../api/agents"; import { memoryApi } from "../api/memory"; +import { projectsApi } from "../api/projects"; +import { EmptyState } from "../components/EmptyState"; +import { StatusBadge } from "../components/StatusBadge"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; +import { Link, useBeforeUnload } from "@/lib/router"; import { getSuggestedMemoryConfig, prettyMemoryConfig, validateMemoryProviderConfig } from "../lib/memory-config-schema"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { MemoryProviderConfigForm } from "./MemoryProviderConfigForm"; const DEFAULT_LOCAL_BASIC_CONFIG = { @@ -24,21 +49,7 @@ const DEFAULT_LOCAL_BASIC_CONFIG = { maxHydrateSnippets: 5, }; -function describeBindingConfig(binding: MemoryBinding) { - if (binding.providerKey !== "local_basic") { - const keys = Object.keys(binding.config ?? {}); - return keys.length > 0 ? `${keys.length} config field${keys.length === 1 ? "" : "s"}` : "Default config"; - } - - const config = { ...DEFAULT_LOCAL_BASIC_CONFIG, ...binding.config }; - return [ - config.enablePreRunHydrate ? "pre-run hydrate on" : "pre-run hydrate off", - config.enablePostRunCapture ? "post-run capture on" : "post-run capture off", - config.enableIssueDocumentCapture ? "issue docs on" : "issue docs off", - config.enableIssueCommentCapture ? "comments on" : "comments off", - `top ${String(config.maxHydrateSnippets)} snippets`, - ].join(" • "); -} +const BINDING_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function providerLabel(provider: MemoryProviderDescriptor | undefined, binding: MemoryBinding) { return provider?.displayName ?? binding.providerKey; @@ -48,25 +59,145 @@ function providerDescription(provider: MemoryProviderDescriptor | undefined) { return provider?.description ?? "Memory provider"; } -function summarizeRecord(record: MemoryRecord) { - const text = (record.summary ?? record.content).replace(/\s+/g, " ").trim(); - return text.length > 220 ? `${text.slice(0, 217)}...` : text; +function shortId(id: string) { + return id.slice(0, 8); } -function recordScopeLabel(record: MemoryRecord) { - return `${record.scopeType}${record.scopeId ? `:${record.scopeId.slice(0, 8)}` : ""}`; +function formatConfigValue(value: unknown) { + if (typeof value === "boolean") return value ? "on" : "off"; + if (value === null || value === undefined || value === "") return "unset"; + if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; + if (typeof value === "object") return "configured"; + return String(value); +} + +function bindingConfigSummary(binding: MemoryBinding) { + if (binding.providerKey === "local_basic") { + const config = { ...DEFAULT_LOCAL_BASIC_CONFIG, ...binding.config }; + return [ + { label: "Pre-run", value: formatConfigValue(config.enablePreRunHydrate) }, + { label: "Post-run", value: formatConfigValue(config.enablePostRunCapture) }, + { label: "Top N", value: formatConfigValue(config.maxHydrateSnippets) }, + { label: "Issue docs", value: formatConfigValue(config.enableIssueDocumentCapture) }, + ]; + } + + const entries = Object.entries(binding.config ?? {}); + if (entries.length === 0) return [{ label: "Config", value: "default" }]; + return entries.slice(0, 4).map(([label, value]) => ({ + label, + value: formatConfigValue(value), + })); +} + +function bindingKeyError(key: string) { + const trimmed = key.trim(); + if (!trimmed) return "Binding key is required."; + if (!BINDING_KEY_PATTERN.test(trimmed)) { + return "Use kebab-case: lowercase letters, numbers, and single hyphens."; + } + return null; +} + +function ProviderSelect({ + value, + providers, + provider, + disabled = false, + onValueChange, +}: { + value: string; + providers: MemoryProviderDescriptor[]; + provider?: MemoryProviderDescriptor; + disabled?: boolean; + onValueChange?: (value: string) => void; +}) { + const availableProviders = providers.some((candidate) => candidate.key === value) + ? providers + : [ + ...providers, + { + key: value, + displayName: value, + description: "Memory provider", + kind: "builtin", + pluginId: null, + capabilities: { + browse: false, + correction: false, + asyncIngestion: false, + providerManagedExtraction: false, + }, + configSchema: null, + configMetadata: null, + } satisfies MemoryProviderDescriptor, + ]; + + return ( +
+ +

+ {providerDescription(provider)} +

+
+ ); +} + +function BindingRowsSkeleton() { + return ( +
+ {Array.from({ length: 3 }, (_, index) => ( +
+
+
+ + + +
+ +
+
+ ))} +
+ ); +} + +function ConfigSummary({ binding }: { binding: MemoryBinding }) { + return ( +
+ {bindingConfigSummary(binding).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); } function MemoryBindingCard({ binding, isDefault, overrideCount, + providers, provider, onSetDefault, }: { binding: MemoryBinding; isDefault: boolean; overrideCount: number; + providers: MemoryProviderDescriptor[]; provider?: MemoryProviderDescriptor; onSetDefault: (bindingId: string) => void; }) { @@ -89,6 +220,14 @@ function MemoryBindingCard({ || enabled !== binding.enabled || prettyMemoryConfig(config) !== prettyMemoryConfig(binding.config ?? {}); + useBeforeUnload( + useCallback((event) => { + if (!dirty) return; + event.preventDefault(); + event.returnValue = ""; + }, [dirty]), + ); + const updateBinding = useMutation({ mutationFn: async () => { const validation = validateMemoryProviderConfig(provider, config); @@ -119,30 +258,19 @@ function MemoryBindingCard({ return (
-
+
-

{binding.name ?? binding.key}

- - {providerLabel(provider, binding)} - - - {binding.enabled ? "Enabled" : "Disabled"} - +

{binding.name ?? binding.key}

+ {isDefault && ( - + + Company default - + )}
Key: {binding.key}
-
{providerDescription(provider)}
-
{describeBindingConfig(binding)}
+
{overrideCount > 0 ? `${overrideCount} override${overrideCount === 1 ? "" : "s"}` : "No project or agent overrides"}
@@ -153,31 +281,45 @@ function MemoryBindingCard({ disabled={isDefault} onClick={() => onSetDefault(binding.id)} > - {isDefault ? "Default" : "Set as default"} + {isDefault ? "Default" : "Make default"}
-
-
+
+
- + setName(event.target.value)} placeholder="Optional label" />
-
+ +
+ setEnabled(checked === true)} + /> + +
+
-
Provider config
+
Provider config
) : null}
-
+
+ {dirty ?
Unsaved changes will prompt before leaving.
: null} + + + + ) : ( + <> + + Add binding + + Create a memory provider binding, then choose whether it should become the company default. + + + +
+
+
+ + { + setKey(event.target.value); + setCreateError(null); + }} + placeholder="default-memory" + aria-invalid={keyError ? true : undefined} + /> + {keyError ?

{keyError}

: null} +
+
+ + setName(event.target.value)} + placeholder="Default memory" + /> +
+
+ +
+
+ + { + const nextProvider = providersByKey.get(nextKey); + setProviderKey(nextKey); + setConfig(getSuggestedMemoryConfig(nextProvider)); + setConfigValid(true); + setCreateError(null); + }} + /> +
+
+ setEnabled(checked === true)} + /> + +
+
+ +
+
Provider config
+ { + setConfig(nextConfig); + setCreateError(null); + }} + onValidationChange={setConfigValid} + /> + {createError ?

{createError}

: null} +
+
+ + + + + + + )} + + + ); +} + +function InheritanceBindingSummary({ + binding, + provider, +}: { + binding: MemoryBinding | null; + provider?: MemoryProviderDescriptor; +}) { + if (!binding) { + return No binding configured; + } + + return ( +
+ {binding.name ?? binding.key} + {providerLabel(provider, binding)} + +
+ ); +} + +function OverrideRows({ + targets, + bindingsById, + providersByKey, + agentsById, + projectsById, +}: { + targets: MemoryBindingTarget[]; + bindingsById: Map; + providersByKey: Map; + agentsById: Map; + projectsById: Map; +}) { + if (targets.length === 0) { + return ( +
+ No overrides configured. +
+ ); + } + + return ( +
+ {targets.map((target) => { + const binding = bindingsById.get(target.bindingId) ?? null; + const provider = binding ? providersByKey.get(binding.providerKey) : undefined; + const project = target.targetType === "project" ? projectsById.get(target.targetId) : null; + const agent = target.targetType === "agent" ? agentsById.get(target.targetId) : null; + const label = project?.name ?? agent?.name ?? `${target.targetType} ${shortId(target.targetId)}`; + const href = target.targetType === "project" + ? `/projects/${target.targetId}/memory` + : `/agents/${target.targetId}/memory`; + + return ( +
+
+ + {label} + + +
+
+ ); + })} +
+ ); +} + +export function CompanyMemorySettings({ companyId }: { companyId: string }) { + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); const [providerKey, setProviderKey] = useState("local_basic"); const [config, setConfig] = useState>(DEFAULT_LOCAL_BASIC_CONFIG); const [configValid, setConfigValid] = useState(true); - const [enabled, setEnabled] = useState(true); - const [makeDefault, setMakeDefault] = useState(true); - const [createError, setCreateError] = useState(null); - const [recordScopeType, setRecordScopeType] = useState(""); - const [recordSensitivity, setRecordSensitivity] = useState(""); - const [recordRetentionState, setRecordRetentionState] = useState(""); - const [includeRevoked, setIncludeRevoked] = useState(false); - const [includeExpired, setIncludeExpired] = useState(false); - const [revokeReasonById, setRevokeReasonById] = useState>({}); - const [correctionRecord, setCorrectionRecord] = useState(null); - const [correctionContent, setCorrectionContent] = useState(""); - const [correctionReason, setCorrectionReason] = useState(""); const providersQuery = useQuery({ queryKey: queryKeys.memory.providers(companyId), @@ -241,90 +677,64 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) { queryFn: () => memoryApi.listTargets(companyId), }); - const recordsFilters: Partial = { - includeDeleted: false, - scopeType: recordScopeType || undefined, - sensitivityLabel: recordSensitivity || undefined, - retentionState: recordRetentionState || undefined, - includeRevoked, - includeExpired, - includeSuperseded: false, - limit: 25, - } as Partial; - - const recordsQuery = useQuery({ - queryKey: queryKeys.memory.records(companyId, recordsFilters), - queryFn: () => memoryApi.listRecords(companyId, recordsFilters), + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(companyId), + queryFn: () => agentsApi.list(companyId), }); + const projectsQuery = useQuery({ + queryKey: queryKeys.projects.list(companyId), + queryFn: () => projectsApi.list(companyId), + }); + + const providers = providersQuery.data ?? []; const providersByKey = useMemo( - () => new Map((providersQuery.data ?? []).map((provider) => [provider.key, provider])), - [providersQuery.data], + () => new Map(providers.map((provider) => [provider.key, provider])), + [providers], ); const selectedProvider = providersByKey.get(providerKey); - const defaultBindingId = - targetsQuery.data?.find((target) => target.targetType === "company" && target.targetId === companyId)?.bindingId ?? null; + const bindings = bindingsQuery.data ?? []; + const bindingsById = useMemo( + () => new Map(bindings.map((binding) => [binding.id, binding])), + [bindings], + ); + + const targets = targetsQuery.data ?? []; + const companyDefaultTarget = targets.find((target) => target.targetType === "company" && target.targetId === companyId) ?? null; + const defaultBindingId = companyDefaultTarget?.bindingId ?? null; + const defaultBinding = defaultBindingId ? bindingsById.get(defaultBindingId) ?? null : null; + const projectTargets = targets.filter((target) => target.targetType === "project"); + const agentTargets = targets.filter((target) => target.targetType === "agent"); + + const agentsById = useMemo( + () => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent])), + [agentsQuery.data], + ); + const projectsById = useMemo( + () => new Map((projectsQuery.data ?? []).map((project) => [project.id, project])), + [projectsQuery.data], + ); const overrideCountByBindingId = useMemo(() => { const result = new Map(); - for (const target of targetsQuery.data ?? []) { + for (const target of targets) { if (target.targetType !== "agent" && target.targetType !== "project") continue; result.set(target.bindingId, (result.get(target.bindingId) ?? 0) + 1); } return result; - }, [targetsQuery.data]); + }, [targets]); useEffect(() => { - if (!providersQuery.data?.length) return; - if (providersQuery.data.some((provider) => provider.key === providerKey)) return; - const nextProvider = providersQuery.data[0]!; + if (!providers.length) return; + if (providers.some((provider) => provider.key === providerKey)) return; + const nextProvider = providers[0]!; setProviderKey(nextProvider.key); setConfig(getSuggestedMemoryConfig(nextProvider)); setConfigValid(true); - }, [providerKey, providersQuery.data]); + }, [providerKey, providers]); - const createBinding = useMutation({ - mutationFn: async () => { - const validation = validateMemoryProviderConfig(selectedProvider, config); - if (!validation.valid) throw new Error("Provider config has invalid fields"); - const created = await memoryApi.createBinding(companyId, { - key: key.trim(), - name: name.trim() || null, - providerKey, - config, - enabled, - }); - if (makeDefault) { - await memoryApi.setCompanyDefault(companyId, created.id); - } - return created; - }, - onSuccess: async () => { - setCreateError(null); - setKey("default-memory"); - setName("Default memory"); - setConfig(getSuggestedMemoryConfig(providersByKey.get("local_basic")) ?? DEFAULT_LOCAL_BASIC_CONFIG); - setConfigValid(true); - setEnabled(true); - setMakeDefault(true); - await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); - pushToast({ - title: "Memory binding created", - body: "The new binding is ready for company and agent scopes.", - tone: "success", - }); - }, - onError: (error) => { - pushToast({ - title: "Failed to create memory binding", - body: error instanceof Error ? error.message : "Unknown error", - tone: "error", - }); - }, - }); - - const setCompanyDefault = useMutation({ + const setCompanyDefault = useMutation({ mutationFn: (bindingId: string) => memoryApi.setCompanyDefault(companyId, bindingId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); @@ -337,74 +747,7 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) { onError: (error) => { pushToast({ title: "Failed to update company memory default", - body: error instanceof Error ? error.message : "Unknown error", - tone: "error", - }); - }, - }); - - const revokeRecord = useMutation({ - mutationFn: ({ recordId, reason }: { recordId: string; reason: string }) => - memoryApi.revoke(companyId, { selector: { recordIds: [recordId] }, reason }), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); - pushToast({ - title: "Memory record revoked", - body: "The record is now hidden from browse and prompt hydration by default.", - tone: "success", - }); - }, - onError: (error) => { - pushToast({ - title: "Failed to revoke memory record", - body: error instanceof Error ? error.message : "Unknown error", - tone: "error", - }); - }, - }); - - const correctRecord = useMutation({ - mutationFn: () => { - if (!correctionRecord) throw new Error("No memory record selected"); - return memoryApi.correctRecord(companyId, correctionRecord.id, { - content: correctionContent, - reason: correctionReason, - }); - }, - onSuccess: async () => { - setCorrectionRecord(null); - setCorrectionContent(""); - setCorrectionReason(""); - await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); - pushToast({ - title: "Memory record corrected", - body: "The original record was superseded and the correction is now the active memory.", - tone: "success", - }); - }, - onError: (error) => { - pushToast({ - title: "Failed to correct memory record", - body: error instanceof Error ? error.message : "Unknown error", - tone: "error", - }); - }, - }); - - const sweepRetention = useMutation({ - mutationFn: () => memoryApi.sweepRetention(companyId), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); - pushToast({ - title: "Retention sweep completed", - body: `${result.expiredRecordIds.length} expired record${result.expiredRecordIds.length === 1 ? "" : "s"} marked.`, - tone: "success", - }); - }, - onError: (error) => { - pushToast({ - title: "Failed to sweep memory retention", - body: error instanceof Error ? error.message : "Unknown error", + body: error.message, tone: "error", }); }, @@ -412,322 +755,154 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) { const isLoading = providersQuery.isLoading || bindingsQuery.isLoading || targetsQuery.isLoading; const error = providersQuery.error ?? bindingsQuery.error ?? targetsQuery.error ?? null; + const labelError = agentsQuery.error ?? projectsQuery.error ?? null; return ( -
-
- Memory +
+
+
Memory
+

Company memory settings

+

+ Configure provider bindings and inspect how company defaults resolve into project and agent overrides. +

-
-
-

Company memory bindings

-

- Bindings determine where agent memory is hydrated from and where run summaries, issue documents, and other captured context are written. -

-
- {isLoading ? ( -

Loading memory settings...

- ) : error ? ( -

{error.message}

- ) : ( - <> + + + Bindings + Inheritance + + + +
+
+

Bindings

+

+ Bindings determine where agent memory is hydrated from and where captured context is written. +

+
+ +
+ + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : bindings.length === 0 ? ( + setCreateDialogOpen(true)} + /> + ) : (
-
-
- - setKey(event.target.value)} placeholder="default-memory" /> -
-
- - setName(event.target.value)} placeholder="Default memory" /> -
-
-
-
- - -
- - -
-
-
Provider config
- { - setConfig(nextConfig); - setCreateError(null); - }} - onValidationChange={setConfigValid} + {bindings.map((binding) => ( + setCompanyDefault.mutate(bindingId)} /> - {createError &&

{createError}

} -
-
- -
+ ))}
+ )} +
-
-
-

Existing bindings

-
- {defaultBindingId ? "A company default is configured." : "No company default yet."} -
-
- {(bindingsQuery.data ?? []).length === 0 ? ( -
- Create a binding to enable memory hydration and capture for this company. -
- ) : ( -
- {(bindingsQuery.data ?? []).map((binding) => ( - setCompanyDefault.mutate(bindingId)} - /> - ))} -
- )} -
- - )} -
- -
-
+
-

Governed records

+

Inheritance

- Inspect memory by scope, sensitivity, and retention state. Revoked, expired, and superseded records stay auditable but are excluded from hydration by default. + Resolution starts at the company default, then project and agent overrides take precedence when present.

- -
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
- - {recordsQuery.isLoading ? ( -

Loading governed records...

- ) : recordsQuery.error ? ( -

{recordsQuery.error.message}

- ) : (recordsQuery.data ?? []).length === 0 ? ( -
- No memory records match the current filters. -
- ) : ( -
- {(recordsQuery.data ?? []).map((record) => { - const revokeReason = revokeReasonById[record.id] ?? ""; - const canMutate = record.retentionState === "active" && !record.revokedAt && !record.supersededByRecordId; - return ( -
-
-
-
{record.title ?? record.source?.kind ?? "Memory record"}
-
- {recordScopeLabel(record)} • {record.sensitivityLabel} • {record.retentionState} - {record.expiresAt ? ` • expires ${new Date(record.expiresAt).toLocaleDateString()}` : ""} -
- {record.citation?.label || record.citation?.sourceTitle ? ( -
- Citation: {record.citation.label ?? record.citation.sourceTitle} -
- ) : null} -
-
- -
-
-

{summarizeRecord(record)}

- {canMutate ? ( -
- - setRevokeReasonById((current) => ({ ...current, [record.id]: event.target.value })) - } - placeholder="Reason required to revoke" - /> - -
- ) : null} -
- ); - })} -
- )} - - {correctionRecord ? ( -
-
-
-

Correct memory record

-

- This creates a superseding record and keeps the original for audit history. + {isLoading ? ( + + ) : error ? ( +

{error.message}

+ ) : ( + <> + {labelError ? ( +

+ Override labels could not be fully loaded: {labelError.message}

+ ) : null} + +
+
+
+
+
Company default
+
Applies when no narrower override exists.
+
+ +
+
+ +
+
+
+ +
Project overrides
+
+ {projectTargets.length} +
+ +
+ +
+
+
+ +
Agent overrides
+
+ {agentTargets.length} +
+ +
- -
-