cbc343963c
Three mechanisms, merged in ThreadList: a 10s heartbeat polling the new ?status=busy filter (out-of-band runs: scheduled tasks, other clients), a module-level running store fed by useChat's own run lifecycle (zero latency for this client's runs), and a spinner replacing the status dot while either source marks the thread busy. The BFF list route now validates and forwards the status filter, which also unbreaks the previously ignored status dropdown. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
import { useMemo, useSyncExternalStore } from "react";
|
|
import useSWR from "swr";
|
|
import useSWRInfinite from "swr/infinite";
|
|
import { patchConversation } from "@/lib/conversationApi";
|
|
import type { ConversationThreadSummary } from "@/lib/conversationSummary";
|
|
import type { ThreadModelSelection } from "@/lib/modelRegistry";
|
|
|
|
export interface ThreadItem {
|
|
id: string;
|
|
updatedAt: Date;
|
|
status: string;
|
|
title: string;
|
|
description: string;
|
|
assistantId?: string;
|
|
pinned: boolean;
|
|
needsUserInput: boolean;
|
|
}
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
function toThreadItem(summary: ConversationThreadSummary): ThreadItem {
|
|
return {
|
|
id: summary.thread_id,
|
|
updatedAt: new Date(summary.updated_at),
|
|
status: summary.status,
|
|
title: summary.title,
|
|
description: summary.description,
|
|
assistantId: summary.assistant_id,
|
|
pinned: summary.pinned,
|
|
needsUserInput: summary.needsUserInput,
|
|
};
|
|
}
|
|
|
|
export function useThreads(props: { status?: string; limit?: number }) {
|
|
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
|
return useSWRInfinite(
|
|
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
|
if (previousPageData && previousPageData.length === 0) return null;
|
|
return { pageIndex, pageSize, status: props.status };
|
|
},
|
|
async ({
|
|
pageIndex,
|
|
pageSize,
|
|
status,
|
|
}: {
|
|
pageIndex: number;
|
|
pageSize: number;
|
|
status?: string;
|
|
}) => {
|
|
const params = new URLSearchParams({
|
|
limit: String(pageSize),
|
|
offset: String(pageIndex * pageSize),
|
|
});
|
|
if (status) params.set("status", status);
|
|
const response = await fetch(`/api/conversations?${params}`, {
|
|
cache: "no-store",
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as {
|
|
threads?: ConversationThreadSummary[];
|
|
error?: string;
|
|
} | null;
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error || "Failed to load conversations.");
|
|
}
|
|
return (payload?.threads ?? []).map(toThreadItem);
|
|
},
|
|
{ revalidateFirstPage: true, revalidateOnFocus: true }
|
|
);
|
|
}
|
|
|
|
// --- Running-thread indicators ----------------------------------------------
|
|
// Two sources, merged by the list UI: a 10s heartbeat for runs started
|
|
// out-of-band (scheduled tasks, other clients) and a local store fed by this
|
|
// client's own run lifecycle for zero-latency updates.
|
|
|
|
const BUSY_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
|
|
export function useBusyThreadIds(): ReadonlySet<string> {
|
|
const { data } = useSWR(
|
|
"conversations:busy",
|
|
async () => {
|
|
const response = await fetch("/api/conversations?status=busy&limit=100", {
|
|
cache: "no-store",
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as {
|
|
threads?: ConversationThreadSummary[];
|
|
error?: string;
|
|
} | null;
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error || "Failed to load running threads.");
|
|
}
|
|
return (payload?.threads ?? []).map((thread) => thread.thread_id);
|
|
},
|
|
{ refreshInterval: BUSY_HEARTBEAT_INTERVAL_MS }
|
|
);
|
|
return useMemo(() => new Set(data ?? []), [data]);
|
|
}
|
|
|
|
const runningThreadIds = new Set<string>();
|
|
const runningListeners = new Set<() => void>();
|
|
let runningSnapshot: ReadonlySet<string> = runningThreadIds;
|
|
|
|
export function setThreadRunning(threadId: string, running: boolean): void {
|
|
const changed = running
|
|
? !runningThreadIds.has(threadId)
|
|
: runningThreadIds.delete(threadId);
|
|
if (!changed) return;
|
|
if (running) runningThreadIds.add(threadId);
|
|
runningSnapshot = new Set(runningThreadIds);
|
|
for (const listener of runningListeners) listener();
|
|
}
|
|
|
|
export function subscribeRunningThreads(listener: () => void): () => void {
|
|
runningListeners.add(listener);
|
|
return () => {
|
|
runningListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
export function getRunningThreadIds(): ReadonlySet<string> {
|
|
return runningSnapshot;
|
|
}
|
|
|
|
export function useLocalRunningThreadIds(): ReadonlySet<string> {
|
|
return useSyncExternalStore(subscribeRunningThreads, getRunningThreadIds);
|
|
}
|
|
|
|
export async function deleteThread(id: string): Promise<void> { const response = await fetch(`/api/conversations/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(body?.error || "Failed to delete conversation.");
|
|
}
|
|
}
|
|
|
|
export async function renameThread(id: string, title: string): Promise<void> {
|
|
await patchConversation(id, { title });
|
|
}
|
|
|
|
export async function pinThread(id: string, pinned: boolean): Promise<void> {
|
|
await patchConversation(id, { pinned });
|
|
}
|
|
|
|
/** CAS write of the thread's model selection (design doc 7.2). Throws
|
|
* `ConversationApiError` with code THREAD_MODEL_SELECTION_CONFLICT on a
|
|
* revision mismatch. */
|
|
export async function setThreadModelSelection(
|
|
id: string,
|
|
selection: ThreadModelSelection,
|
|
expectedRevision: number
|
|
): Promise<void> {
|
|
await patchConversation(id, {
|
|
model_selection: selection,
|
|
expected_selection_revision: expectedRevision,
|
|
});
|
|
}
|
|
|
|
function slugifyForFilename(input: string): string {
|
|
return input
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[\\/:*?"<>|\u0000-\u001F]+/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, "-")
|
|
.slice(0, 80);
|
|
}
|
|
|
|
export async function exportThread(
|
|
id: string,
|
|
filenameHint?: string
|
|
): Promise<void> {
|
|
const response = await fetch(`/api/conversations/${encodeURIComponent(id)}`, {
|
|
cache: "no-store",
|
|
});
|
|
const thread = await response.json().catch(() => null);
|
|
if (!response.ok) throw new Error("Failed to export conversation.");
|
|
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 link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${safeName}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
}
|