Add memory copy actions (#1427)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-01 08:05:40 -07:00
committed by GitHub
co-authored by Pinky
parent 177fe5a3bf
commit 6c0e6f0bb7
@@ -1,5 +1,11 @@
import * as React from "react";
import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react";
import {
AlertTriangle,
Brain,
ChevronDown,
Copy,
RefreshCw,
} from "lucide-react";
import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
@@ -8,6 +14,7 @@ import { cn } from "@/shared/lib/cn";
import { Button, type ButtonProps } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { toast } from "sonner";
const MEMORY_LIST_PREVIEW_LIMIT = 3;
@@ -261,6 +268,14 @@ function MemoryGraphView({
</p>
) : null}
<div className="mb-1 flex items-center justify-end">
<MemoryCopyButton
label={`Copy all ${entries.length} memories`}
successMessage="Copied memories to clipboard"
value={formatMemoriesForClipboard(entries)}
/>
</div>
<div className="space-y-2" data-testid="agent-memory-list">
{visibleEntries.map((entry) => (
<MemoryEntryAccordion
@@ -356,6 +371,66 @@ function flattenTreeDescendants(node: MemoryTreeNode): EngramEntry[] {
return entries;
}
function formatMemoryForClipboard(entry: EngramEntry): string {
return `${entry.slug}\n${entry.body}`;
}
function formatMemoriesForClipboard(entries: EngramEntry[]): string {
return entries.map(formatMemoryForClipboard).join("\n\n");
}
function MemoryCopyButton({
label,
successMessage,
value,
}: {
label: string;
successMessage: string;
value: string;
}) {
const [copying, setCopying] = React.useState(false);
const handleCopy = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setCopying(true);
try {
await navigator.clipboard.writeText(value);
toast.success(successMessage);
} catch (error) {
console.error("Failed to copy memory", error);
toast.error("Failed to copy memory");
} finally {
setCopying(false);
}
},
[successMessage, value],
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid="agent-memory-copy"
disabled={copying}
onClick={handleCopy}
size="icon"
type="button"
variant="ghost"
>
<Copy className="h-4 w-4" />
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
const MEMORY_REF_PATTERN = /\[\[([^\]]+)\]\]/g;
function MemoryBodyText({ body }: { body: string }) {
@@ -504,43 +579,33 @@ function MemoryEntryAccordion({
}, [open]);
const content = (
<>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
ref={titleRef}
>
{hasDanglingRefs ? (
<AlertTriangle className="mr-1 inline-block h-4 w-4 align-[-2px] text-warning" />
) : null}
<MemorySlugTitle slug={entry.slug} />
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
ref={bodyRef}
>
{isEmpty ? (
<span className="italic text-foreground/50">(empty)</span>
) : (
<MemoryBodyText body={entry.body} />
)}
</div>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
ref={titleRef}
>
{hasDanglingRefs ? (
<AlertTriangle className="mr-1 inline-block h-4 w-4 align-[-2px] text-warning" />
) : null}
<MemorySlugTitle slug={entry.slug} />
</div>
{canExpand ? (
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
) : null}
</>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
ref={bodyRef}
>
{isEmpty ? (
<span className="italic text-foreground/50">(empty)</span>
) : (
<MemoryBodyText body={entry.body} />
)}
</div>
</div>
);
return (
@@ -549,19 +614,49 @@ function MemoryEntryAccordion({
ref={articleRef}
>
{canExpand ? (
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
>
<div className="flex items-start gap-3">{content}</div>
{hasDanglingRefs && open ? (
<MemoryDanglingRefsHint slugs={danglingRefsForEntry} />
) : null}
</button>
<div className="flex items-start gap-2 px-4 py-3 transition-colors hover:bg-muted/50">
<button
aria-expanded={open}
className="min-w-0 flex-1 text-left"
onClick={() => setOpen((value) => !value)}
type="button"
>
{content}
{hasDanglingRefs && open ? (
<MemoryDanglingRefsHint slugs={danglingRefsForEntry} />
) : null}
</button>
<MemoryCopyButton
label={`Copy ${entry.slug} memory`}
successMessage="Copied memory to clipboard"
value={formatMemoryForClipboard(entry)}
/>
<Button
aria-expanded={open}
aria-label={open ? "Collapse memory" : "Expand memory"}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setOpen((value) => !value)}
size="icon"
type="button"
variant="ghost"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
open && "rotate-180",
)}
/>
</Button>
</div>
) : (
<div className="flex items-start gap-3 px-4 py-3">{content}</div>
<div className="flex items-start gap-2 px-4 py-3">
{content}
<MemoryCopyButton
label={`Copy ${entry.slug} memory`}
successMessage="Copied memory to clipboard"
value={formatMemoryForClipboard(entry)}
/>
</div>
)}
</article>
);