Polish memory override settings UX

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-20 07:35:38 -05:00
1 parent 0bdee434b3
commit e4b755503d
10 files changed
+901 -264

No files matched your search

+176 -159
View File
@@ -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 (
<div className="overflow-hidden rounded-md border border-border">
{[0, 1, 2].map((index) => (
<div key={index} className="grid gap-3 border-b border-border px-4 py-4 last:border-b-0 lg:grid-cols-[1fr_190px_140px]">
<div className="space-y-2">
<Skeleton className="h-4 w-48 max-w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-20" />
</div>
<div className="space-y-2 lg:ml-auto">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
);
}
function OperationRowsSkeleton() {
return (
<div className="overflow-hidden rounded-lg border border-border">
{[0, 1, 2, 3].map((index) => (
<div key={index} className="grid grid-cols-6 gap-3 border-b border-border px-3 py-2 last:border-b-0">
<Skeleton className="col-span-2 h-5" />
<Skeleton className="h-5" />
<Skeleton className="h-5" />
<Skeleton className="h-5" />
<Skeleton className="h-5" />
</div>
))}
</div>
);
}
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<OperationWindow>("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 (
<div className="space-y-6">
<div className="rounded-lg border border-border px-4 py-4">
<div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<div className="space-y-2">
<div className="text-sm font-medium">Resolved memory</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading memory state...</p>
) : error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : (
<>
<div className="text-sm">{describeBinding(resolvedBindingQuery.data?.binding ?? null)}</div>
<div className="text-xs text-muted-foreground">
Source: {describeResolvedSource(resolvedBindingQuery.data?.source ?? null)}
</div>
<div className="text-xs text-muted-foreground">
Automatic capture hooks write recent run summaries and issue context into the resolved binding.
</div>
<div className="text-xs text-muted-foreground">
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.
</div>
</>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Agent override</div>
<select
value={selectedBindingId}
onChange={(event) => setSelectedBindingId(event.target.value)}
disabled={loading || Boolean(error)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
<option value="__inherit__">Inherit project/company default</option>
{(bindingsQuery.data ?? []).map((binding) => (
<option key={binding.id} value={binding.id}>
{binding.name ?? binding.key}
</option>
))}
</select>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
disabled={loading || Boolean(error) || saveOverride.isPending || selectedBindingId === currentSelection}
onClick={() => saveOverride.mutate()}
>
{saveOverride.isPending ? "Saving..." : "Save override"}
</Button>
</div>
</div>
</div>
</div>
<MemoryOverrideCard
targetType="agent"
resolvedBinding={resolvedBindingQuery.data}
bindings={bindingsQuery.data ?? []}
source={resolvedBindingQuery.data?.source}
loading={overrideLoading}
error={overrideError}
saving={saveOverride.isPending}
saveError={saveOverride.error instanceof Error ? saveOverride.error : null}
onRetry={() => {
void bindingsQuery.refetch();
void resolvedBindingQuery.refetch();
}}
onSave={(bindingId) => saveOverride.mutate(bindingId)}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-medium">Recent memory records</h3>
<div className="text-xs text-muted-foreground">Filtered to this agent scope</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-muted-foreground">Showing 20 most recent</Badge>
<Link className="text-xs text-primary hover:underline" to={fullFeedHref}>
Open full feed
</Link>
</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading records...</p>
{recordsLoading ? (
<MemoryRowsSkeleton />
) : recordsError ? (
<div className="rounded-md border border-destructive/30">
<EmptyState
icon={AlertCircle}
title="Could not load memory records"
message={recordsError.message}
action="Retry"
actionIcon={RefreshCw}
onAction={() => void recordsQuery.refetch()}
tone="destructive"
/>
</div>
) : (recordsQuery.data ?? []).length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No memory has been captured for this agent yet.
<div className="rounded-md border border-dashed border-border">
<EmptyState
icon={Database}
message="No memory has been captured for this agent yet."
/>
</div>
) : (
<div className="space-y-3">
<div className="overflow-hidden rounded-md border border-border">
<div className="divide-y divide-border">
{(recordsQuery.data ?? []).map((record) => (
<div key={record.id} className="rounded-lg border border-border px-4 py-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{record.title ?? describeRecordSource(record)}</div>
<div className="text-xs text-muted-foreground">
{describeRecordSource(record)} • {governanceLabel(record)} • {relativeTime(record.createdAt)}
</div>
{record.citation?.label || record.citation?.sourceTitle ? (
<div className="text-xs text-muted-foreground">
Citation: {record.citation.label ?? record.citation.sourceTitle}
</div>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{record.scope.issueId ? `Issue ${record.scope.issueId.slice(0, 8)}` : "No issue scope"}
{record.scope.runId ? ` • Run ${record.scope.runId.slice(0, 8)}` : ""}
</div>
</div>
<p className="mt-2 text-sm text-foreground/90">{summarizeRecord(record)}</p>
</div>
<MemoryRecordRow
key={record.id}
record={record}
binding={bindingsById.get(record.bindingId)}
variant="compact"
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent memory operations</h3>
<div className="text-xs text-muted-foreground">Queries, captures, and forgets for this agent</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-medium">Recent memory operations</h3>
<div className="text-xs text-muted-foreground">Queries, captures, and forgets for this agent</div>
</div>
<Tabs value={operationWindow} onValueChange={(value) => setOperationWindow(value as OperationWindow)}>
<TabsList>
{(Object.entries(OPERATION_WINDOWS) as Array<[OperationWindow, { label: string; ms: number }]>).map(([value, option]) => (
<TabsTrigger key={value} value={value}>{option.label}</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading operations...</p>
) : (operationsQuery.data ?? []).length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No memory operations logged yet.
{operationsLoading ? (
<OperationRowsSkeleton />
) : operationsError ? (
<div className="rounded-md border border-destructive/30">
<EmptyState
icon={AlertCircle}
title="Could not load memory operations"
message={operationsError.message}
action="Retry"
actionIcon={RefreshCw}
onAction={() => void operationsQuery.refetch()}
tone="destructive"
/>
</div>
) : filteredOperations.length === 0 ? (
<div className="rounded-md border border-dashed border-border">
<EmptyState
icon={Database}
message={(operationsQuery.data ?? []).length === 0
? "No memory operations logged yet."
: `No memory operations in the ${OPERATION_WINDOWS[operationWindow].label.toLowerCase()} window.`}
/>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-accent/20 text-left text-xs text-muted-foreground">
<table className="w-full text-xs">
<thead className="border-b border-border bg-accent/20">
<tr className="text-left text-muted-foreground">
<th className="px-3 py-2 font-medium">Operation</th>
<th className="px-3 py-2 font-medium">Hook</th>
<th className="px-3 py-2 font-medium">Status</th>
@@ -240,7 +257,7 @@ export function AgentMemoryTab({
</tr>
</thead>
<tbody>
{(operationsQuery.data ?? []).map((operation) => (
{filteredOperations.map((operation) => (
<tr key={operation.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">
<div className="font-medium">{operation.operationType}</div>
@@ -249,9 +266,9 @@ export function AgentMemoryTab({
<td className="px-3 py-2 text-xs text-muted-foreground">
{operation.hookKind ?? operation.triggerKind}
</td>
<td className="px-3 py-2 text-xs">{operation.status}</td>
<td className="px-3 py-2 text-right tabular-nums">{operation.recordCount}</td>
<td className="px-3 py-2 text-right tabular-nums">{operationCost(operation)}</td>
<td className="px-3 py-2"><StatusBadge status={operation.status} /></td>
<td className="px-3 py-2 text-right font-mono tabular-nums">{operation.recordCount}</td>
<td className="px-3 py-2 text-right font-mono tabular-nums">{operationCost(operation)}</td>
<td className="px-3 py-2 text-right text-xs text-muted-foreground">
{relativeTime(operation.occurredAt)}
</td>
+31 -7
View File
@@ -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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted/50 p-4 mb-4">
<Icon className="h-10 w-10 text-muted-foreground/50" />
<div className={isDestructive ? "bg-destructive/10 p-4 mb-4" : "bg-muted/50 p-4 mb-4"}>
<Icon className={isDestructive ? "h-10 w-10 text-destructive/70" : "h-10 w-10 text-muted-foreground/50"} />
</div>
{title ? <h2 className={isDestructive ? "mb-2 text-sm font-semibold text-destructive" : "mb-2 text-sm font-semibold"}>{title}</h2> : null}
<p className="text-sm text-muted-foreground mb-4">{message}</p>
{action && onAction && (
<Button onClick={onAction}>
<Plus className="h-4 w-4 mr-1.5" />
{action && actionHref ? (
<Button variant={isDestructive ? "destructive" : "default"} asChild>
<a href={actionHref}>
<ActionIcon className="h-4 w-4 mr-1.5" />
{action}
</a>
</Button>
) : null}
{action && onAction && !actionHref ? (
<Button variant={isDestructive ? "destructive" : "default"} onClick={onAction}>
<ActionIcon className="h-4 w-4 mr-1.5" />
{action}
</Button>
)}
) : null}
</div>
);
}
+53
View File
@@ -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 (
<nav aria-label="Inheritance chain" className="flex flex-wrap items-center gap-1.5">
{items.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex items-center gap-1.5">
{index > 0 ? <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" /> : null}
<InheritanceChainStep item={item} />
</div>
))}
</nav>
);
}
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 = (
<>
<span className="font-medium leading-tight">{item.label}</span>
{item.detail ? <span className="mt-0.5 truncate text-[11px] leading-tight opacity-75">{item.detail}</span> : null}
</>
);
if (item.href && !item.unavailable) {
return (
<Link to={item.href} className={className}>
{content}
</Link>
);
}
return <span className={className}>{content}</span>;
}
+265
View File
@@ -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 (
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-start gap-3">
<Skeleton className="h-10 w-10 shrink-0" />
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-4 w-72 max-w-full" />
<Skeleton className="h-3 w-52 max-w-full" />
</div>
<Skeleton className="h-9 w-full" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-md border border-border px-4 py-4">
<EmptyState
icon={AlertCircle}
message={error.message || `Could not load ${targetLabel(targetType)} memory settings.`}
action={onRetry ? "Retry" : undefined}
onAction={onRetry}
/>
</div>
);
}
return (
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-accent text-muted-foreground">
<Database className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-2">
<div className="space-y-1">
<h2 className="text-lg font-semibold">{titleLabel(targetType)}</h2>
<p className="text-sm">{describeBinding(resolvedBinding?.binding, targetType)}</p>
<p className="text-xs text-muted-foreground">Source: {sourceLabel(source)}</p>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
<InheritanceChain items={inheritanceItems} />
</div>
</div>
{bindings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="mb-4 bg-muted/50 p-4">
<Database className="h-10 w-10 text-muted-foreground/50" />
</div>
<p className="mb-4 text-sm text-muted-foreground">
Create a memory binding before setting an override.
</p>
<Button asChild>
<a href={companySettingsHref}>Create a binding in Company Settings</a>
</Button>
</div>
) : (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{overrideLabel(targetType)}</label>
<Select value={selectedBindingId} onValueChange={setSelectedBindingId}>
<SelectTrigger className="w-full">
<SelectValue placeholder={inheritLabel(targetType)} />
</SelectTrigger>
<SelectContent>
<SelectItem value={INHERIT_VALUE}>{inheritLabel(targetType)}</SelectItem>
{bindings.map((binding) => (
<SelectItem key={binding.id} value={binding.id}>
<BindingSelectLabel binding={binding} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
disabled={saving || !hasUnsavedChange}
onClick={() => onSave(selectedBindingId === INHERIT_VALUE ? null : selectedBindingId)}
>
{saving ? "Saving..." : "Save override"}
</Button>
</div>
{hasUnsavedChange ? (
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-primary">
<span>New runs will use</span>
<Badge variant="outline" className="border-primary/30 bg-background text-primary">
{bindingPreviewLabel(nextBinding, selectedBindingLabel(selectedBinding, targetType))}
</Badge>
<span>instead of</span>
<Badge variant="outline" className="border-primary/30 bg-background text-primary">
{bindingPreviewLabel(resolvedBinding?.binding, "no binding")}
</Badge>
<span>Switching will not affect existing records.</span>
</div>
) : null}
{saveError ? (
<p className="text-xs text-destructive">{saveError.message}</p>
) : null}
</div>
)}
</div>
</div>
</div>
);
}
function BindingSelectLabel({ binding }: { binding: MemoryBinding }) {
return (
<div className="flex w-full items-center justify-between gap-3">
<span className="min-w-0">
<span className="block truncate">{binding.name ?? binding.key}</span>
<span className="block truncate text-xs text-muted-foreground">{binding.providerKey}</span>
</span>
<span
className={
binding.enabled
? "rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-700 dark:text-emerald-300"
: "rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-700 dark:text-amber-300"
}
>
{binding.enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
}
+175
View File
@@ -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<string, Issue>) {
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<string, Issue>) {
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<string, Issue>;
agentsById?: ReadonlyMap<string, Agent>;
projectsById?: ReadonlyMap<string, Project>;
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<HTMLDivElement>) => {
if (!onSelect || event.currentTarget !== event.target) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
};
const content = (
<>
{onSelectedChange ? (
<div
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectedChange(checked === true)}
aria-label={`Select ${getMemoryRecordTitle(record)}`}
/>
</div>
) : null}
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={memoryRecordReviewStatus(record)} />
<span className="text-sm font-medium">{getMemoryRecordTitle(record)}</span>
<span className="rounded-full bg-accent px-2 py-0.5 text-[11px] text-muted-foreground">
{bindingLabel}
</span>
</div>
<p className="text-sm leading-6 text-foreground/85">{summarizeMemoryRecord(record, summaryLength)}</p>
</div>
<div className="min-w-0 border-t border-border pt-3 text-xs text-muted-foreground md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="mb-1 text-xs font-medium text-muted-foreground md:hidden">Source</div>
<div className="font-medium text-foreground">{memoryRecordSourceLabel(record, issuesById)}</div>
<div>{labelizeMemoryValue(record.source?.kind ?? record.scopeType)}</div>
{record.citation?.label || record.citation?.sourceTitle ? (
<div className="mt-1 truncate">{record.citation.label ?? record.citation.sourceTitle}</div>
) : null}
{sourceUrl ? (
onSelect ? (
<Link
className="mt-1 inline-flex text-primary hover:underline"
to={sourceUrl}
onClick={(event) => event.stopPropagation()}
>
Open source
</Link>
) : (
<div className="mt-1 text-primary">Open source</div>
)
) : null}
</div>
<div className="border-t border-border pt-3 text-xs text-muted-foreground md:border-l md:border-t-0 md:pl-4 md:pt-0 md:text-right">
<div className="mb-1 text-xs font-medium text-muted-foreground md:hidden">Context</div>
{project ? (
onSelect ? (
<div className="truncate">{project.name}</div>
) : (
<Link className="block truncate text-primary hover:underline" to={projectUrl(project)}>
{project.name}
</Link>
)
) : null}
{agent ? (
onSelect ? (
<div className="truncate">{agent.name}</div>
) : (
<Link className="block truncate text-primary hover:underline" to={agentUrl(agent)}>
{agent.name}
</Link>
)
) : null}
<div className="tabular-nums">{relativeTime(record.createdAt)}</div>
<div>{record.sensitivityLabel}</div>
</div>
</>
);
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 (
<div role="button" tabIndex={0} className={className} onClick={onSelect} onKeyDown={handleRowKeyDown}>
{content}
</div>
);
}
return <div className={className}>{content}</div>;
}
@@ -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 }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
// 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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ProjectMemorySettings companyId="company-1" projectId="project-1" />
<ProjectMemorySettings companyId="company-1" projectId="project-1" companyPrefix="PAP" />
</ToastProvider>
</QueryClientProvider>,
);
@@ -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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ProjectMemorySettings companyId="company-1" projectId="project-1" companyPrefix="PAP" />
</ToastProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ProjectMemorySettings companyId="company-1" projectId="project-1" companyPrefix="PAP" />
</ToastProvider>
</QueryClientProvider>,
);
});
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();
});
});
});
+39 -91
View File
@@ -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 (
<div className="max-w-3xl space-y-4">
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-md bg-accent text-muted-foreground">
<Database className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-medium">Project memory</h2>
{loading ? (
<p className="text-sm text-muted-foreground">Loading memory state...</p>
) : error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : (
<>
<p className="text-sm">
{resolvedBinding ? `${resolvedBinding.name ?? resolvedBinding.key} (${resolvedBinding.providerKey})` : "No memory binding resolves for this project yet."}
</p>
<p className="text-xs text-muted-foreground">
Source: {sourceLabel(resolvedBindingQuery.data?.source ?? null)}
</p>
</>
)}
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Project override</label>
<select
value={selectedBindingId}
onChange={(event) => setSelectedBindingId(event.target.value)}
disabled={loading || Boolean(error)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
<option value="__inherit__">Inherit company default</option>
{(bindingsQuery.data ?? []).map((binding) => (
<option key={binding.id} value={binding.id}>
{binding.name ?? binding.key}
</option>
))}
</select>
</div>
<Button
size="sm"
disabled={loading || Boolean(error) || saveOverride.isPending || selectedBindingId === currentSelection}
onClick={() => saveOverride.mutate()}
>
{saveOverride.isPending ? "Saving..." : "Save override"}
</Button>
</div>
{saveOverride.isError ? (
<p className="text-xs text-destructive">
{saveOverride.error instanceof Error ? saveOverride.error.message : "Failed to update project memory binding"}
</p>
) : null}
</div>
</div>
</div>
<MemoryOverrideCard
targetType="project"
resolvedBinding={resolvedBindingQuery.data}
bindings={bindingsQuery.data ?? []}
source={resolvedBindingQuery.data?.source}
loading={loading}
error={error}
saving={saveOverride.isPending}
saveError={saveOverride.error instanceof Error ? saveOverride.error : null}
agentOverrideCount={agentOverrideCount}
companySettingsHref={companySettingsHref}
onRetry={() => {
void bindingsQuery.refetch();
void resolvedBindingQuery.refetch();
void targetsQuery.refetch();
}}
onSave={(bindingId) => saveOverride.mutate(bindingId)}
/>
<Button asChild variant="link" className="h-auto px-0">
<a href={projectMemoriesHref}>See memories for this project</a>
</Button>
</div>
);
}
+10 -1
View File
@@ -1,12 +1,21 @@
import { cn } from "../lib/utils";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
const statusBadgeVisualStatus: Record<string, string> = {
pending: "idle",
accepted: "approved",
rejected: "rejected",
revoked: "archived",
};
export function StatusBadge({ status }: { status: string }) {
const visualStatus = statusBadgeVisualStatus[status] ?? status;
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap shrink-0",
statusBadge[status] ?? statusBadgeDefault
statusBadge[visualStatus] ?? statusBadgeDefault
)}
>
{status.replace("_", " ")}
+16
View File
@@ -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() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* INHERITANCE CHAIN */}
{/* ============================================================ */}
<Section title="Inheritance Chain">
<SubSection title="Resolved scope">
<InheritanceChain
items={[
{ label: "Company default", detail: "local_basic", href: "/company/settings#memory" },
{ label: "Project override", detail: "Issue/project scope" },
{ label: "Agent override", detail: "This agent", active: true },
]}
/>
</SubSection>
</Section>
{/* ============================================================ */}
{/* FORM ELEMENTS */}
{/* ============================================================ */}
+5 -1
View File
@@ -700,7 +700,11 @@ export function ProjectDetail() {
)}
{activeTab === "memory" && resolvedCompanyId ? (
<ProjectMemorySettings companyId={resolvedCompanyId} projectId={project.id} />
<ProjectMemorySettings
companyId={resolvedCompanyId}
projectId={project.id}
companyPrefix={companyPrefix ?? null}
/>
) : null}
{activeTab === "budget" && resolvedCompanyId ? (