mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Polish memory override settings UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0bdee434b3
commit
e4b755503d
10 files changed
+901
-264
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("_", " ")}
|
||||
|
||||
@@ -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 */}
|
||||
{/* ============================================================ */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user