"use client"; import { Archive, ArchiveRestore, ChevronLeft, ChevronRight, Plus, Trash2, } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useThreads } from "@copilotkit/react-core/v2"; import styles from "./threads-drawer.module.css"; export interface ThreadsDrawerProps { agentId: string; threadId: string | undefined; onThreadChange: (threadId: string | undefined) => void; } interface DrawerThread { id: string; name: string | null; updatedAt: string; archived: boolean; lastRunAt?: string; } const THREAD_ENTRY_ANIMATION_MS = 420; const TITLE_ANIMATION_MS = 360; const UNTITLED_THREAD_LABEL = "New thread"; const RUNTIME_BASE_PATH = "/api/copilotkit"; function formatThreadTimestamp(updatedAt: string): string { const timestamp = new Date(updatedAt); if (Number.isNaN(timestamp.getTime())) return "Updated recently"; return new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short", }).format(timestamp); } function cx(...classNames: Array): string { return classNames.filter(Boolean).join(" "); } export default function ThreadsDrawer({ agentId, threadId, onThreadChange, }: ThreadsDrawerProps) { const [showArchived, setShowArchived] = useState(false); // Start collapsed on narrow screens (tablet + phone) so the panel — which // becomes an off-canvas overlay below 1024px — doesn't cover the content + // chat on load. The drawer is client-mounted, so reading window here is safe // and won't cause a hydration mismatch. const [isOpen, setIsOpen] = useState( () => typeof window === "undefined" || window.innerWidth > 1024, ); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string; } | null>(null); const deleteTriggerRef = useRef(null); const { threads, archiveThread, deleteThread, error, isLoading, hasMoreThreads, isFetchingMoreThreads, fetchMoreThreads, } = useThreads({ agentId, includeArchived: showArchived, limit: 20, }); const restoreThread = useCallback( async (id: string) => { const response = await fetch( `${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agentId, archived: false }), }, ); if (!response.ok) { throw new Error( `Restore failed: ${response.status} ${response.statusText}`, ); } }, [agentId], ); const hasMountedRef = useRef(false); const hasLoadedOnceRef = useRef(false); const stableThreadsRef = useRef(threads); const previousThreadIdsRef = useRef>(new Set()); const previousNamesRef = useRef>(new Map()); const entryTimeoutsRef = useRef>(new Map()); const titleTimeoutsRef = useRef>(new Map()); if (!isLoading) { hasLoadedOnceRef.current = true; stableThreadsRef.current = threads; } const displayThreads: DrawerThread[] = isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads; const [enteringThreadIds, setEnteringThreadIds] = useState< Record >({}); const [revealedTitleIds, setRevealedTitleIds] = useState< Record >({}); useEffect(() => { return () => { for (const timeoutId of entryTimeoutsRef.current.values()) { window.clearTimeout(timeoutId); } for (const timeoutId of titleTimeoutsRef.current.values()) { window.clearTimeout(timeoutId); } }; }, []); useEffect(() => { // Skip diffing while the store is refetching (e.g. after a filter change // clears the list). Otherwise every thread would be treated as newly // added once the new page lands. if (isLoading) return; const nextThreadIds = new Set(threads.map((t) => t.id)); if (!hasMountedRef.current) { hasMountedRef.current = true; previousThreadIdsRef.current = nextThreadIds; previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name])); return; } const addedThreadIds = threads .filter((t) => !previousThreadIdsRef.current.has(t.id)) .map((t) => t.id); if (addedThreadIds.length > 0) { setEnteringThreadIds((current) => { const next = { ...current }; for (const id of addedThreadIds) { next[id] = true; const existing = entryTimeoutsRef.current.get(id); if (existing !== undefined) window.clearTimeout(existing); const tid = window.setTimeout(() => { setEnteringThreadIds((s) => { const updated = { ...s }; delete updated[id]; return updated; }); entryTimeoutsRef.current.delete(id); }, THREAD_ENTRY_ANIMATION_MS); entryTimeoutsRef.current.set(id, tid); } return next; }); } const renamedThreadIds = threads .filter((t) => { // Only reveal when an already-tracked thread's name transitions from // null → named. Threads appearing for the first time (e.g. on a // filter switch) already have their final name and should not trigger // the title reveal animation — that would layer a blur/translateY // onto the row's enter animation and produce a visible jitter. if (!previousNamesRef.current.has(t.id)) return false; const prev = previousNamesRef.current.get(t.id) ?? null; return prev === null && t.name !== null; }) .map((t) => t.id); if (renamedThreadIds.length > 0) { setRevealedTitleIds((current) => { const next = { ...current }; for (const id of renamedThreadIds) { next[id] = true; const existing = titleTimeoutsRef.current.get(id); if (existing !== undefined) window.clearTimeout(existing); const tid = window.setTimeout(() => { setRevealedTitleIds((s) => { const updated = { ...s }; delete updated[id]; return updated; }); titleTimeoutsRef.current.delete(id); }, TITLE_ANIMATION_MS); titleTimeoutsRef.current.set(id, tid); } return next; }); } previousThreadIdsRef.current = nextThreadIds; previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name])); }, [threads, isLoading]); const isInitialLoading = isLoading && !hasLoadedOnceRef.current; if (error) { console.error("Unable to load threads", error); } if (!isOpen) { return ( ); } const closeDeleteDialog = () => { setPendingDelete(null); const trigger = deleteTriggerRef.current; deleteTriggerRef.current = null; trigger?.focus?.(); }; return ( <> {pendingDelete && ( { const { id } = pendingDelete; closeDeleteDialog(); if (threadId === id) onThreadChange(undefined); deleteThread(id).catch((err: unknown) => { console.error("Unable to delete thread", err); }); }} /> )} ); } interface ConfirmDialogProps { title: string; description: string; confirmLabel: string; cancelLabel?: string; destructive?: boolean; onConfirm: () => void; onCancel: () => void; } function ConfirmDialog({ title, description, confirmLabel, cancelLabel = "Cancel", destructive = false, onConfirm, onCancel, }: ConfirmDialogProps) { const titleId = useId(); const descId = useId(); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onCancel]); if (typeof document === "undefined") return null; return createPortal(
e.stopPropagation()} >

{title}

{description}

, document.body, ); }