From e4b755503ddb156e3caf7512aec5a3a20b82bf45 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 07:35:38 -0500 Subject: [PATCH] Polish memory override settings UX Co-Authored-By: Paperclip --- ui/src/components/AgentMemoryTab.tsx | 335 +++++++++--------- ui/src/components/EmptyState.tsx | 38 +- ui/src/components/InheritanceChain.tsx | 53 +++ ui/src/components/MemoryOverrideCard.tsx | 265 ++++++++++++++ ui/src/components/MemoryRecordRow.tsx | 175 +++++++++ .../components/ProjectMemorySettings.test.tsx | 136 ++++++- ui/src/components/ProjectMemorySettings.tsx | 130 ++----- ui/src/components/StatusBadge.tsx | 11 +- ui/src/pages/DesignGuide.tsx | 16 + ui/src/pages/ProjectDetail.tsx | 6 +- 10 files changed, 901 insertions(+), 264 deletions(-) create mode 100644 ui/src/components/InheritanceChain.tsx create mode 100644 ui/src/components/MemoryOverrideCard.tsx create mode 100644 ui/src/components/MemoryRecordRow.tsx diff --git a/ui/src/components/AgentMemoryTab.tsx b/ui/src/components/AgentMemoryTab.tsx index 28885c1c415..cac0d21b5a6 100644 --- a/ui/src/components/AgentMemoryTab.tsx +++ b/ui/src/components/AgentMemoryTab.tsx @@ -1,45 +1,78 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { MemoryBinding, MemoryOperation, MemoryRecord } from "@paperclipai/shared"; +import type { MemoryOperation } from "@paperclipai/shared"; +import { AlertCircle, Database, RefreshCw } from "lucide-react"; import { memoryApi } from "../api/memory"; +import { EmptyState } from "./EmptyState"; +import { MemoryRecordRow } from "./MemoryRecordRow"; +import { MemoryOverrideCard } from "./MemoryOverrideCard"; +import { StatusBadge } from "./StatusBadge"; +import { Badge } from "@/components/ui/badge"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { formatCents, relativeTime } from "../lib/utils"; -import { Button } from "@/components/ui/button"; - -function describeResolvedSource(source: string | null | undefined) { - if (source === "agent_override") return "Agent override"; - if (source === "project_override") return "Project override"; - if (source === "company_default") return "Company default"; - if (source === "binding_key") return "Direct binding key"; - return "Unconfigured"; -} - -function describeRecordSource(record: MemoryRecord) { - if (!record.source?.kind) return "memory"; - return record.source.kind.replace(/_/g, " "); -} - -function describeBinding(binding: MemoryBinding | null) { - if (!binding) return "No memory binding resolves for this agent yet."; - return `${binding.name ?? binding.key} (${binding.providerKey})`; -} - -function summarizeRecord(record: MemoryRecord) { - const body = (record.summary ?? record.content).replace(/\s+/g, " ").trim(); - return body.length > 280 ? `${body.slice(0, 277)}...` : body; -} - -function governanceLabel(record: MemoryRecord) { - const scope = `${record.scopeType}${record.scopeId ? `:${record.scopeId.slice(0, 8)}` : ""}`; - return `${scope} • ${record.sensitivityLabel} • ${record.retentionState}`; -} +import { Link } from "@/lib/router"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; function operationCost(operation: MemoryOperation) { const total = operation.usage.reduce((sum, item) => sum + item.costCents, 0); return total > 0 ? formatCents(total) : "-"; } +const OPERATION_WINDOWS = { + "24h": { label: "Last 24h", ms: 24 * 60 * 60 * 1000 }, + "7d": { label: "7d", ms: 7 * 24 * 60 * 60 * 1000 }, + "30d": { label: "30d", ms: 30 * 24 * 60 * 60 * 1000 }, +} as const; + +type OperationWindow = keyof typeof OPERATION_WINDOWS; + +function filterOperationsByWindow(operations: MemoryOperation[], window: OperationWindow) { + const cutoff = Date.now() - OPERATION_WINDOWS[window].ms; + return operations.filter((operation) => new Date(operation.occurredAt).getTime() >= cutoff); +} + +function MemoryRowsSkeleton() { + return ( +
+ {[0, 1, 2].map((index) => ( +
+
+ + + +
+
+ + +
+
+ + +
+
+ ))} +
+ ); +} + +function OperationRowsSkeleton() { + return ( +
+ {[0, 1, 2, 3].map((index) => ( +
+ + + + + +
+ ))} +
+ ); +} + export function AgentMemoryTab({ companyId, agentId, @@ -49,7 +82,7 @@ export function AgentMemoryTab({ }) { const { pushToast } = useToast(); const queryClient = useQueryClient(); - const [selectedBindingId, setSelectedBindingId] = useState("__inherit__"); + const [operationWindow, setOperationWindow] = useState("24h"); const bindingsQuery = useQuery({ queryKey: queryKeys.memory.bindings(companyId), @@ -67,28 +100,17 @@ export function AgentMemoryTab({ }); const operationsQuery = useQuery({ - queryKey: queryKeys.memory.operations(companyId, { agentId, limit: 20 }), - queryFn: () => memoryApi.listOperations(companyId, { agentId, limit: 20 }), + queryKey: queryKeys.memory.operations(companyId, { agentId, limit: 100 }), + queryFn: () => memoryApi.listOperations(companyId, { agentId, limit: 100 }), }); - useEffect(() => { - const resolved = resolvedBindingQuery.data; - if (!resolved) return; - if (resolved.targetType === "agent" && resolved.binding) { - setSelectedBindingId(resolved.binding.id); - return; - } - setSelectedBindingId("__inherit__"); - }, [resolvedBindingQuery.data]); - const saveOverride = useMutation({ - mutationFn: () => - memoryApi.setAgentBinding(agentId, selectedBindingId === "__inherit__" ? null : selectedBindingId), - onSuccess: async () => { + mutationFn: (bindingId: string | null) => memoryApi.setAgentBinding(agentId, bindingId), + onSuccess: async (_target, bindingId) => { await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); pushToast({ title: "Agent memory binding updated", - body: selectedBindingId === "__inherit__" + body: bindingId === null ? "This agent now inherits the company default binding." : "The agent override is active for subsequent memory operations.", tone: "success", @@ -103,134 +125,129 @@ export function AgentMemoryTab({ }, }); - const currentSelection = useMemo(() => { - const resolved = resolvedBindingQuery.data; - if (!resolved) return "__inherit__"; - if (resolved.targetType === "agent" && resolved.binding) return resolved.binding.id; - return "__inherit__"; - }, [resolvedBindingQuery.data]); - - const loading = - bindingsQuery.isLoading - || resolvedBindingQuery.isLoading - || recordsQuery.isLoading - || operationsQuery.isLoading; - const error = - bindingsQuery.error - ?? resolvedBindingQuery.error - ?? recordsQuery.error - ?? operationsQuery.error - ?? null; + const overrideLoading = bindingsQuery.isLoading || resolvedBindingQuery.isLoading; + const overrideError = bindingsQuery.error ?? resolvedBindingQuery.error ?? null; + const recordsLoading = recordsQuery.isLoading; + const operationsLoading = operationsQuery.isLoading; + const recordsError = recordsQuery.error ?? null; + const operationsError = operationsQuery.error ?? null; + const bindingsById = useMemo( + () => new Map((bindingsQuery.data ?? []).map((binding) => [binding.id, binding])), + [bindingsQuery.data], + ); + const filteredOperations = useMemo( + () => filterOperationsByWindow(operationsQuery.data ?? [], operationWindow), + [operationWindow, operationsQuery.data], + ); + const fullFeedHref = `/memories?agentId=${encodeURIComponent(agentId)}`; return (
-
-
-
-
Resolved memory
- {loading ? ( -

Loading memory state...

- ) : error ? ( -

{error.message}

- ) : ( - <> -
{describeBinding(resolvedBindingQuery.data?.binding ?? null)}
-
- Source: {describeResolvedSource(resolvedBindingQuery.data?.source ?? null)} -
-
- Automatic capture hooks write recent run summaries and issue context into the resolved binding. -
-
- Hydration can read org memory plus this agent, issue, project, workspace, and run scopes when present. Restricted records are withheld from agent prompts unless explicitly allowed. -
- - )} -
- -
-
Agent override
- -
- -
-
-
-
+ { + void bindingsQuery.refetch(); + void resolvedBindingQuery.refetch(); + }} + onSave={(bindingId) => saveOverride.mutate(bindingId)} + />
-
+

Recent memory records

-
Filtered to this agent scope
+
+ Showing 20 most recent + + Open full feed + +
- {loading ? ( -

Loading records...

+ {recordsLoading ? ( + + ) : recordsError ? ( +
+ void recordsQuery.refetch()} + tone="destructive" + /> +
) : (recordsQuery.data ?? []).length === 0 ? ( -
- No memory has been captured for this agent yet. +
+
) : ( -
+
+
{(recordsQuery.data ?? []).map((record) => ( -
-
-
-
{record.title ?? describeRecordSource(record)}
-
- {describeRecordSource(record)} • {governanceLabel(record)} • {relativeTime(record.createdAt)} -
- {record.citation?.label || record.citation?.sourceTitle ? ( -
- Citation: {record.citation.label ?? record.citation.sourceTitle} -
- ) : null} -
-
- {record.scope.issueId ? `Issue ${record.scope.issueId.slice(0, 8)}` : "No issue scope"} - {record.scope.runId ? ` • Run ${record.scope.runId.slice(0, 8)}` : ""} -
-
-

{summarizeRecord(record)}

-
+ ))} +
)}
-
-

Recent memory operations

-
Queries, captures, and forgets for this agent
+
+
+

Recent memory operations

+
Queries, captures, and forgets for this agent
+
+ setOperationWindow(value as OperationWindow)}> + + {(Object.entries(OPERATION_WINDOWS) as Array<[OperationWindow, { label: string; ms: number }]>).map(([value, option]) => ( + {option.label} + ))} + +
- {loading ? ( -

Loading operations...

- ) : (operationsQuery.data ?? []).length === 0 ? ( -
- No memory operations logged yet. + {operationsLoading ? ( + + ) : operationsError ? ( +
+ void operationsQuery.refetch()} + tone="destructive" + /> +
+ ) : filteredOperations.length === 0 ? ( +
+
) : (
- - - +
+ + @@ -240,7 +257,7 @@ export function AgentMemoryTab({ - {(operationsQuery.data ?? []).map((operation) => ( + {filteredOperations.map((operation) => ( - - - + + + diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 2e2612ab8fb..82fe1f22318 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -4,24 +4,48 @@ import { Button } from "@/components/ui/button"; interface EmptyStateProps { icon: LucideIcon; + title?: string; message: string; action?: string; onAction?: () => void; + actionHref?: string; + actionIcon?: LucideIcon; + tone?: "default" | "destructive"; } -export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { +export function EmptyState({ + icon: Icon, + title, + message, + action, + onAction, + actionHref, + actionIcon: ActionIcon = Plus, + tone = "default", +}: EmptyStateProps) { + const isDestructive = tone === "destructive"; + return (
-
- +
+
+ {title ?

{title}

: null}

{message}

- {action && onAction && ( - + ) : null} + {action && onAction && !actionHref ? ( + - )} + ) : null}
); } diff --git a/ui/src/components/InheritanceChain.tsx b/ui/src/components/InheritanceChain.tsx new file mode 100644 index 00000000000..099559acc60 --- /dev/null +++ b/ui/src/components/InheritanceChain.tsx @@ -0,0 +1,53 @@ +import { ChevronRight } from "lucide-react"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +export type InheritanceChainItem = { + label: string; + detail?: string | null; + href?: string | null; + active?: boolean; + unavailable?: boolean; +}; + +export function InheritanceChain({ items }: { items: InheritanceChainItem[] }) { + return ( + + ); +} + +function InheritanceChainStep({ item }: { item: InheritanceChainItem }) { + const className = cn( + "inline-flex min-h-8 max-w-full flex-col justify-center rounded-md border px-2.5 py-1 text-left text-xs transition-colors", + item.active + ? "border-primary/50 bg-primary/10 text-primary" + : item.unavailable + ? "border-dashed border-border bg-muted/30 text-muted-foreground" + : "border-border bg-background text-foreground", + item.href && !item.unavailable ? "hover:bg-accent/60" : null, + ); + + const content = ( + <> + {item.label} + {item.detail ? {item.detail} : null} + + ); + + if (item.href && !item.unavailable) { + return ( + + {content} + + ); + } + + return {content}; +} diff --git a/ui/src/components/MemoryOverrideCard.tsx b/ui/src/components/MemoryOverrideCard.tsx new file mode 100644 index 00000000000..9f7b93fe579 --- /dev/null +++ b/ui/src/components/MemoryOverrideCard.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useState } from "react"; +import type { MemoryBinding, MemoryResolvedBinding } from "@paperclipai/shared"; +import { AlertCircle, Database } from "lucide-react"; +import { EmptyState } from "./EmptyState"; +import { InheritanceChain } from "./InheritanceChain"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; + +const INHERIT_VALUE = "__inherit__"; + +type MemoryOverrideTargetType = "agent" | "project"; + +interface MemoryOverrideCardProps { + targetType: MemoryOverrideTargetType; + resolvedBinding: MemoryResolvedBinding | null | undefined; + bindings: MemoryBinding[]; + source: MemoryResolvedBinding["source"] | null | undefined; + onSave: (bindingId: string | null) => void; + loading?: boolean; + error?: Error | null; + saving?: boolean; + saveError?: Error | null; + onRetry?: () => void; + companySettingsHref?: string; + agentOverrideCount?: number; +} + +function targetLabel(targetType: MemoryOverrideTargetType) { + return targetType === "agent" ? "agent" : "project"; +} + +function titleLabel(targetType: MemoryOverrideTargetType) { + return targetType === "agent" ? "Agent memory" : "Project memory"; +} + +function overrideLabel(targetType: MemoryOverrideTargetType) { + return targetType === "agent" ? "Agent override" : "Project override"; +} + +function inheritLabel(targetType: MemoryOverrideTargetType) { + return targetType === "agent" ? "Inherit project/company default" : "Inherit company default"; +} + +function sourceLabel(source: MemoryResolvedBinding["source"] | null | undefined) { + if (source === "agent_override") return "Agent override"; + if (source === "project_override") return "Project override"; + if (source === "company_default") return "Company default"; + if (source === "binding_key") return "Direct binding key"; + return "Unconfigured"; +} + +function describeBinding(binding: MemoryBinding | null | undefined, targetType: MemoryOverrideTargetType) { + if (!binding) return `No memory binding resolves for this ${targetLabel(targetType)} yet.`; + return `${binding.name ?? binding.key} (${binding.providerKey})`; +} + +function selectedBindingLabel(binding: MemoryBinding | undefined, targetType: MemoryOverrideTargetType) { + if (!binding) return inheritLabel(targetType); + return `${binding.name ?? binding.key} (${binding.providerKey})`; +} + +function bindingPreviewLabel(binding: MemoryBinding | null | undefined, fallback: string) { + return binding ? `${binding.providerKey}:${binding.key}` : fallback; +} + +function agentOverrideSummary(count: number | undefined) { + if (typeof count !== "number") return null; + return `${count} agent override${count === 1 ? "" : "s"} configured.`; +} + +function bindingSelectionFromResolved( + resolvedBinding: MemoryResolvedBinding | null | undefined, + targetType: MemoryOverrideTargetType, +) { + if (resolvedBinding?.targetType === targetType && resolvedBinding.binding) { + return resolvedBinding.binding.id; + } + return INHERIT_VALUE; +} + +export function MemoryOverrideCard({ + targetType, + resolvedBinding, + bindings, + source, + onSave, + loading = false, + error = null, + saving = false, + saveError = null, + onRetry, + companySettingsHref = "/company/settings#memory", + agentOverrideCount, +}: MemoryOverrideCardProps) { + const [selectedBindingId, setSelectedBindingId] = useState(INHERIT_VALUE); + + const currentSelection = useMemo( + () => bindingSelectionFromResolved(resolvedBinding, targetType), + [resolvedBinding, targetType], + ); + + useEffect(() => { + setSelectedBindingId(currentSelection); + }, [currentSelection]); + + const selectedBinding = bindings.find((binding) => binding.id === selectedBindingId); + const hasUnsavedChange = selectedBindingId !== currentSelection; + const chainSummary = agentOverrideSummary(agentOverrideCount); + const nextBinding = selectedBindingId === INHERIT_VALUE ? null : selectedBinding; + const inheritanceItems = [ + { + label: "Company default", + detail: source === "company_default" ? resolvedBinding?.binding?.providerKey : null, + href: companySettingsHref, + active: source === "company_default", + }, + { + label: "Project override", + detail: targetType === "agent" ? "Issue/project scope" : "This project", + active: source === "project_override", + }, + { + label: targetType === "project" ? "Agent overrides" : "Agent override", + detail: targetType === "project" ? chainSummary : "This agent", + active: source === "agent_override", + unavailable: targetType === "project" && !agentOverrideCount, + }, + ]; + + if (loading) { + return ( +
+
+ +
+
+ + + +
+ +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +
+
+
+
+

{titleLabel(targetType)}

+

{describeBinding(resolvedBinding?.binding, targetType)}

+

Source: {sourceLabel(source)}

+
+ +
+ +
+
+ + {bindings.length === 0 ? ( +
+
+ +
+

+ Create a memory binding before setting an override. +

+ +
+ ) : ( +
+
+
+ + +
+ +
+ + {hasUnsavedChange ? ( +
+ New runs will use + + {bindingPreviewLabel(nextBinding, selectedBindingLabel(selectedBinding, targetType))} + + instead of + + {bindingPreviewLabel(resolvedBinding?.binding, "no binding")} + + Switching will not affect existing records. +
+ ) : null} + + {saveError ? ( +

{saveError.message}

+ ) : null} +
+ )} +
+
+
+ ); +} + +function BindingSelectLabel({ binding }: { binding: MemoryBinding }) { + return ( +
+ + {binding.name ?? binding.key} + {binding.providerKey} + + + {binding.enabled ? "Enabled" : "Disabled"} + +
+ ); +} diff --git a/ui/src/components/MemoryRecordRow.tsx b/ui/src/components/MemoryRecordRow.tsx new file mode 100644 index 00000000000..bf62c3bdc50 --- /dev/null +++ b/ui/src/components/MemoryRecordRow.tsx @@ -0,0 +1,175 @@ +import type { Agent, Issue, MemoryBinding, MemoryRecord, Project } from "@paperclipai/shared"; +import type { KeyboardEvent } from "react"; +import { Link } from "@/lib/router"; +import { agentUrl, cn, issueUrl, projectUrl, relativeTime } from "../lib/utils"; +import { Checkbox } from "@/components/ui/checkbox"; +import { StatusBadge } from "./StatusBadge"; + +export function labelizeMemoryValue(value: string) { + return value.replace(/_/g, " "); +} + +export function summarizeMemoryRecord(record: MemoryRecord, maxLength = 360) { + const text = (record.summary ?? record.content).replace(/\s+/g, " ").trim(); + return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; +} + +export function getMemoryRecordTitle(record: MemoryRecord) { + return record.title ?? record.citation?.sourceTitle ?? labelizeMemoryValue(record.source?.kind ?? "memory"); +} + +export function memoryRecordSourceLabel(record: MemoryRecord, issuesById?: ReadonlyMap) { + const issueId = record.source?.issueId ?? record.scope.issueId ?? null; + const issue = issueId ? issuesById?.get(issueId) : null; + if (issue) return issue.identifier ?? issue.title; + if (record.source?.runId ?? record.scope.runId) return `run ${(record.source?.runId ?? record.scope.runId)!.slice(0, 8)}`; + if (record.source?.externalRef) return record.source.externalRef; + return labelizeMemoryValue(record.source?.kind ?? record.scopeType); +} + +export function memoryRecordSourceHref(record: MemoryRecord, issuesById?: ReadonlyMap) { + const issueId = record.source?.issueId ?? record.scope.issueId ?? null; + const issue = issueId ? issuesById?.get(issueId) : null; + if (issue) { + const base = issueUrl(issue); + if (record.source?.commentId) return `${base}#comment-${record.source.commentId}`; + if (record.source?.documentKey) return `${base}#document-${record.source.documentKey}`; + return base; + } + const runId = record.source?.runId ?? record.scope.runId ?? null; + const agentId = record.scope.agentId ?? null; + if (runId && agentId) return `/agents/${agentId}/runs/${runId}`; + return null; +} + +export function memoryRecordReviewStatus(record: MemoryRecord) { + return record.revokedAt || record.retentionState === "revoked" ? "revoked" : record.reviewState; +} + +export function MemoryRecordRow({ + record, + binding, + issuesById, + agentsById, + projectsById, + onSelect, + selected = false, + onSelectedChange, + variant = "feed", +}: { + record: MemoryRecord; + binding?: MemoryBinding | null; + issuesById?: ReadonlyMap; + agentsById?: ReadonlyMap; + projectsById?: ReadonlyMap; + onSelect?: () => void; + selected?: boolean; + onSelectedChange?: (checked: boolean) => void; + variant?: "feed" | "compact"; +}) { + const sourceUrl = memoryRecordSourceHref(record, issuesById); + const agent = record.scope.agentId ? agentsById?.get(record.scope.agentId) : null; + const project = record.scope.projectId ? projectsById?.get(record.scope.projectId) : null; + const bindingLabel = binding?.name ?? binding?.key ?? record.providerKey; + const summaryLength = variant === "compact" ? 280 : 360; + const handleRowKeyDown = (event: KeyboardEvent) => { + if (!onSelect || event.currentTarget !== event.target) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }; + + const content = ( + <> + {onSelectedChange ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + onSelectedChange(checked === true)} + aria-label={`Select ${getMemoryRecordTitle(record)}`} + /> +
+ ) : null} +
+
+ + {getMemoryRecordTitle(record)} + + {bindingLabel} + +
+

{summarizeMemoryRecord(record, summaryLength)}

+
+
+
Source
+
{memoryRecordSourceLabel(record, issuesById)}
+
{labelizeMemoryValue(record.source?.kind ?? record.scopeType)}
+ {record.citation?.label || record.citation?.sourceTitle ? ( +
{record.citation.label ?? record.citation.sourceTitle}
+ ) : null} + {sourceUrl ? ( + onSelect ? ( + event.stopPropagation()} + > + Open source + + ) : ( +
Open source
+ ) + ) : null} +
+
+
Context
+ {project ? ( + onSelect ? ( +
{project.name}
+ ) : ( + + {project.name} + + ) + ) : null} + {agent ? ( + onSelect ? ( +
{agent.name}
+ ) : ( + + {agent.name} + + ) + ) : null} +
{relativeTime(record.createdAt)}
+
{record.sensitivityLabel}
+
+ + ); + + const className = cn( + "grid w-full gap-3 px-4 py-4 text-left", + onSelectedChange + ? variant === "compact" + ? "md:grid-cols-[32px_1fr_190px_140px]" + : "md:grid-cols-[32px_1fr_220px_160px]" + : variant === "compact" + ? "md:grid-cols-[1fr_190px_140px]" + : "md:grid-cols-[1fr_220px_160px]", + onSelect ? "transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" : null, + ); + + if (onSelect) { + return ( +
+ {content} +
+ ); + } + + return
{content}
; +} diff --git a/ui/src/components/ProjectMemorySettings.test.tsx b/ui/src/components/ProjectMemorySettings.test.tsx index 6c428dc06fa..a394188c6fb 100644 --- a/ui/src/components/ProjectMemorySettings.test.tsx +++ b/ui/src/components/ProjectMemorySettings.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act } from "react"; +import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { MemoryBinding, MemoryResolvedBinding } from "@paperclipai/shared"; @@ -10,6 +11,7 @@ import { ProjectMemorySettings } from "./ProjectMemorySettings"; const memoryApiMocks = vi.hoisted(() => ({ listBindings: vi.fn(), + listTargets: vi.fn(), getProjectBinding: vi.fn(), setProjectBinding: vi.fn(), })); @@ -18,8 +20,17 @@ vi.mock("../api/memory", () => ({ memoryApi: memoryApiMocks, })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( + + {children} + + ), +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +Element.prototype.scrollIntoView = vi.fn(); function tick() { return new Promise((resolve) => window.setTimeout(resolve, 0)); @@ -59,8 +70,10 @@ describe("ProjectMemorySettings", () => { container = document.createElement("div"); document.body.appendChild(container); memoryApiMocks.listBindings.mockReset(); + memoryApiMocks.listTargets.mockReset(); memoryApiMocks.getProjectBinding.mockReset(); memoryApiMocks.setProjectBinding.mockReset(); + memoryApiMocks.listTargets.mockResolvedValue([]); }); afterEach(() => { @@ -71,6 +84,17 @@ describe("ProjectMemorySettings", () => { it("shows inherited company memory and saves a project override", async () => { const binding = createBinding(); memoryApiMocks.listBindings.mockResolvedValue([binding]); + memoryApiMocks.listTargets.mockResolvedValue([ + { + id: "target-agent", + companyId: "company-1", + bindingId: binding.id, + targetType: "agent", + targetId: "agent-1", + createdAt: new Date("2026-04-20T00:00:00.000Z"), + updatedAt: new Date("2026-04-20T00:00:00.000Z"), + }, + ]); memoryApiMocks.getProjectBinding.mockResolvedValue(createResolvedBinding()); memoryApiMocks.setProjectBinding.mockResolvedValue({ id: "target-project", @@ -94,7 +118,7 @@ describe("ProjectMemorySettings", () => { root.render( - + , ); @@ -107,15 +131,34 @@ describe("ProjectMemorySettings", () => { expect(container.textContent).toContain("QMD default (qmd_memory)"); expect(container.textContent).toContain("Source: Company default"); + expect(container.textContent).toContain("Company default"); + expect(container.textContent).toContain("Project override"); + expect(container.textContent).toContain("Agent overrides"); + expect(container.textContent).toContain("1 agent override configured."); + expect(container.querySelector('a[href="/PAP/memories?projectId=project-1"]')?.textContent).toContain( + "See memories for this project", + ); - const select = container.querySelector("select"); - expect(select).not.toBeNull(); + const selectTrigger = container.querySelector('[data-slot="select-trigger"]'); + expect(selectTrigger).not.toBeNull(); await act(async () => { - select!.value = binding.id; - select!.dispatchEvent(new Event("change", { bubbles: true })); + selectTrigger!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ); + await tick(); + const bindingItem = Array.from(document.body.querySelectorAll('[data-slot="select-item"]')).find((item) => + item.textContent?.includes("QMD default") + ); + expect(bindingItem).toBeTruthy(); + bindingItem!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + bindingItem!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await tick(); }); + expect(container.textContent).toContain("New runs will use"); + expect(container.textContent).toContain("Switching will not affect existing records."); + const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Save override") ); @@ -132,4 +175,87 @@ describe("ProjectMemorySettings", () => { root.unmount(); }); }); + + it("shows a company settings CTA when no bindings exist", async () => { + memoryApiMocks.listBindings.mockResolvedValue([]); + memoryApiMocks.getProjectBinding.mockResolvedValue( + createResolvedBinding({ + targetType: null, + targetId: null, + binding: null, + source: "unconfigured", + }), + ); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + + await act(async () => { + await tick(); + await tick(); + }); + + expect(container.textContent).toContain("Create a memory binding before setting an override."); + const companySettingsLinks = Array.from(container.querySelectorAll('a[href="/PAP/company/settings#memory"]')); + expect(companySettingsLinks.some((link) => link.textContent?.includes("Create a binding in Company Settings"))) + .toBe(true); + expect(memoryApiMocks.setProjectBinding).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows an error state with retry", async () => { + memoryApiMocks.listBindings.mockRejectedValue(new Error("Bindings unavailable")); + memoryApiMocks.getProjectBinding.mockResolvedValue(createResolvedBinding()); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + + await act(async () => { + await tick(); + await tick(); + }); + + expect(container.textContent).toContain("Bindings unavailable"); + const retryButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Retry") + ); + expect(retryButton).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/ProjectMemorySettings.tsx b/ui/src/components/ProjectMemorySettings.tsx index 4ec5037f0d0..90abd52214f 100644 --- a/ui/src/components/ProjectMemorySettings.tsx +++ b/ui/src/components/ProjectMemorySettings.tsx @@ -1,29 +1,26 @@ -import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database } from "lucide-react"; import { memoryApi } from "../api/memory"; +import { MemoryOverrideCard } from "./MemoryOverrideCard"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; -function sourceLabel(source: string | null | undefined) { - if (source === "project_override") return "Project override"; - if (source === "company_default") return "Company default"; - if (source === "agent_override") return "Agent override"; - if (source === "binding_key") return "Direct binding key"; - return "Unconfigured"; +function companyPath(companyPrefix: string | null | undefined, path: string) { + if (!companyPrefix) return path; + return `/${companyPrefix}${path}`; } export function ProjectMemorySettings({ companyId, projectId, + companyPrefix, }: { companyId: string; projectId: string; + companyPrefix?: string | null; }) { const { pushToast } = useToast(); const queryClient = useQueryClient(); - const [selectedBindingId, setSelectedBindingId] = useState("__inherit__"); const bindingsQuery = useQuery({ queryKey: queryKeys.memory.bindings(companyId), @@ -35,31 +32,18 @@ export function ProjectMemorySettings({ queryFn: () => memoryApi.getProjectBinding(projectId), }); - useEffect(() => { - const resolved = resolvedBindingQuery.data; - if (!resolved) return; - if (resolved.targetType === "project" && resolved.binding) { - setSelectedBindingId(resolved.binding.id); - return; - } - setSelectedBindingId("__inherit__"); - }, [resolvedBindingQuery.data]); - - const currentSelection = useMemo(() => { - const resolved = resolvedBindingQuery.data; - if (!resolved) return "__inherit__"; - if (resolved.targetType === "project" && resolved.binding) return resolved.binding.id; - return "__inherit__"; - }, [resolvedBindingQuery.data]); + const targetsQuery = useQuery({ + queryKey: queryKeys.memory.targets(companyId), + queryFn: () => memoryApi.listTargets(companyId), + }); const saveOverride = useMutation({ - mutationFn: () => - memoryApi.setProjectBinding(projectId, selectedBindingId === "__inherit__" ? null : selectedBindingId), - onSuccess: async () => { + mutationFn: (bindingId: string | null) => memoryApi.setProjectBinding(projectId, bindingId), + onSuccess: async (_target, bindingId) => { await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }); pushToast({ title: "Project memory binding updated", - body: selectedBindingId === "__inherit__" + body: bindingId === null ? "This project now inherits the company default binding." : "The project override is active for subsequent memory operations.", tone: "success", @@ -74,71 +58,35 @@ export function ProjectMemorySettings({ }, }); - const loading = bindingsQuery.isLoading || resolvedBindingQuery.isLoading; - const error = bindingsQuery.error ?? resolvedBindingQuery.error ?? null; - const resolvedBinding = resolvedBindingQuery.data?.binding ?? null; + const loading = bindingsQuery.isLoading || resolvedBindingQuery.isLoading || targetsQuery.isLoading; + const error = bindingsQuery.error ?? resolvedBindingQuery.error ?? targetsQuery.error ?? null; + const agentOverrideCount = (targetsQuery.data ?? []).filter((target) => target.targetType === "agent").length; + const companySettingsHref = companyPath(companyPrefix, "/company/settings#memory"); + const projectMemoriesHref = companyPath(companyPrefix, `/memories?projectId=${encodeURIComponent(projectId)}`); return (
-
-
-
- -
-
-
-

Project memory

- {loading ? ( -

Loading memory state...

- ) : error ? ( -

{error.message}

- ) : ( - <> -

- {resolvedBinding ? `${resolvedBinding.name ?? resolvedBinding.key} (${resolvedBinding.providerKey})` : "No memory binding resolves for this project yet."} -

-

- Source: {sourceLabel(resolvedBindingQuery.data?.source ?? null)} -

- - )} -
- -
-
- - -
- -
- - {saveOverride.isError ? ( -

- {saveOverride.error instanceof Error ? saveOverride.error.message : "Failed to update project memory binding"} -

- ) : null} -
-
-
+ { + void bindingsQuery.refetch(); + void resolvedBindingQuery.refetch(); + void targetsQuery.refetch(); + }} + onSave={(bindingId) => saveOverride.mutate(bindingId)} + /> +
); } - diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx index 2d31e0db1c9..075d37d8a0d 100644 --- a/ui/src/components/StatusBadge.tsx +++ b/ui/src/components/StatusBadge.tsx @@ -1,12 +1,21 @@ import { cn } from "../lib/utils"; import { statusBadge, statusBadgeDefault } from "../lib/status-colors"; +const statusBadgeVisualStatus: Record = { + pending: "idle", + accepted: "approved", + rejected: "rejected", + revoked: "archived", +}; + export function StatusBadge({ status }: { status: string }) { + const visualStatus = statusBadgeVisualStatus[status] ?? status; + return ( {status.replace("_", " ")} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 9d4ab867f4d..635f1e200d2 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -123,6 +123,7 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar"; import { InlineEditor } from "@/components/InlineEditor"; import { PageSkeleton } from "@/components/PageSkeleton"; import { Identity } from "@/components/Identity"; +import { InheritanceChain } from "@/components/InheritanceChain"; /* ------------------------------------------------------------------ */ /* Section wrapper */ @@ -468,6 +469,21 @@ export function DesignGuide() { + {/* ============================================================ */} + {/* INHERITANCE CHAIN */} + {/* ============================================================ */} +
+ + + +
+ {/* ============================================================ */} {/* FORM ELEMENTS */} {/* ============================================================ */} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 45ff582080f..61a014d7746 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -700,7 +700,11 @@ export function ProjectDetail() { )} {activeTab === "memory" && resolvedCompanyId ? ( - + ) : null} {activeTab === "budget" && resolvedCompanyId ? (
Operation Hook Status
{operation.operationType}
@@ -249,9 +266,9 @@ export function AgentMemoryTab({
{operation.hookKind ?? operation.triggerKind} {operation.status}{operation.recordCount}{operationCost(operation)}{operation.recordCount}{operationCost(operation)} {relativeTime(operation.occurredAt)}