feat: download thread to a JSON with a button (#15)

This commit is contained in:
jfilipiuk
2026-06-23 11:07:10 +02:00
committed by GitHub
parent 6bd215a582
commit 3acfa35dea
2 changed files with 95 additions and 1 deletions
+50 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import {
BrainCircuit,
Download,
Loader2,
MessageSquare,
Pencil,
@@ -35,6 +36,7 @@ import {
deleteThread,
renameThread,
pinThread,
exportThread,
} from "@/app/hooks/useThreads";
import { useMemoryActivity } from "@/app/hooks/useMemoryActivity";
import {
@@ -194,6 +196,9 @@ export function ThreadList({
const [actionBusy, setActionBusy] = useState(false);
const [now, setNow] = useState(() => new Date());
const [pinBusyIds, setPinBusyIds] = useState<Set<string>>(() => new Set());
const [exportBusyIds, setExportBusyIds] = useState<Set<string>>(
() => new Set()
);
const threads = useThreads({
status: statusFilter === "all" ? undefined : statusFilter,
@@ -318,6 +323,7 @@ export function ThreadList({
// so a fast second Enter could fire a mutation twice. The ref guards instantly.
const actionBusyRef = useRef(false);
const pinBusyIdsRef = useRef<Set<string>>(new Set());
const exportBusyIdsRef = useRef<Set<string>>(new Set());
// After deleting a thread its row (and the trigger button) is gone, so move
// keyboard focus to a stable target (New Chat) instead of dropping to <body>.
@@ -400,11 +406,34 @@ export function ThreadList({
}
};
const runExport = async (thread: ThreadItem) => {
if (exportBusyIdsRef.current.has(thread.id)) return;
exportBusyIdsRef.current.add(thread.id);
setExportBusyIds((current) => {
const next = new Set(current);
next.add(thread.id);
return next;
});
try {
await exportThread(thread.id, thread.title);
} catch {
toast.error("Couldn't export — try again.");
} finally {
exportBusyIdsRef.current.delete(thread.id);
setExportBusyIds((current) => {
const next = new Set(current);
next.delete(thread.id);
return next;
});
}
};
// A single thread row (select button + per-thread actions). Used by both the
// pinned "Research" section and the time-grouped "Recents" sections; the only
// difference is the Pin ↔ Unpin action, driven by `thread.pinned`.
const renderThreadCard = (thread: ThreadItem) => {
const pinBusy = pinBusyIds.has(thread.id);
const exportBusy = exportBusyIds.has(thread.id);
return (
<div
@@ -417,7 +446,7 @@ export function ThreadList({
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-2 rounded-md py-2 pl-2.5 pr-20 text-left transition-colors duration-200 md:pr-2.5 md:group-focus-within:pr-20 md:group-hover:pr-20",
"grid w-full cursor-pointer items-center gap-2 rounded-md py-2 pl-2.5 pr-28 text-left transition-colors duration-200 md:pr-2.5 md:group-focus-within:pr-28 md:group-hover:pr-28",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
@@ -512,6 +541,26 @@ export function ThreadList({
aria-hidden="true"
/>
</button>
<button
type="button"
aria-label={`Export "${thread.title}" as JSON`}
title="Export JSON"
onClick={() => runExport(thread)}
disabled={exportBusy}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{exportBusy ? (
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<Download
className="size-3.5"
aria-hidden="true"
/>
)}
</button>
<button
type="button"
aria-label={`Delete "${thread.title}"`}
+45
View File
@@ -234,3 +234,48 @@ export async function pinThread(id: string, pinned: boolean): Promise<void> {
if (!client) throw new Error("No EvoScientist deployment configured.");
await updateThreadMetadata(client, id, { pinned });
}
// Strip characters that are unsafe in filenames on Windows/macOS/Linux, then
// collapse whitespace. Keep this lenient — we just need a valid filename, not
// a slug.
function slugifyForFilename(input: string): string {
return (
input
// Stripping control chars is the point — silence the lint rule.
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\u0000-\u001F]+/g, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, 80)
);
}
/**
* Download the full thread state as JSON. We fetch via `client.threads.get`
* which returns the thread plus its current `values` (every message including
* tool calls, sub-agent output, and assistant thinking) — i.e. everything the
* backend has, suitable for debugging.
*/
export async function exportThread(
id: string,
filenameHint?: string
): Promise<void> {
const client = makeThreadsClient();
if (!client) throw new Error("No EvoScientist deployment configured.");
const thread = await client.threads.get(id);
const blob = new Blob([JSON.stringify(thread, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const safeName =
(filenameHint && slugifyForFilename(filenameHint)) ||
`thread-${id.slice(0, 8)}`;
const a = document.createElement("a");
a.href = url;
a.download = `${safeName}.json`;
document.body.appendChild(a);
a.click();
a.remove();
// Defer URL revocation a tick so Safari/Firefox finish the download trigger.
setTimeout(() => URL.revokeObjectURL(url), 0);
}