// Conversation threads — multiple named chats, each with its own history, // active agent, and pinned message indices. Persisted in localStorage. import { useEffect, useSyncExternalStore } from "react" export type ThreadMessage = { role: "user" | "assistant" | "tool" content: string /** Persona that authored this assistant message (omitted for user msgs). */ agentId?: string /** Native tool calls attached to an assistant message. */ toolCalls?: { id: string; name: string; arguments: string }[] /** Tool role only — id of the matching assistant tool_call. */ toolCallId?: string /** Tool role only — function name. */ name?: string } export type Thread = { id: string title: string agentId: string messages: ThreadMessage[] pinned: number[] // indices into messages[] createdAt: number updatedAt: number } const THREADS_KEY = "crema.assistant.threads" const ACTIVE_KEY = "crema.assistant.activeThreadId" const SNAPSHOT_KEY_PREFIX = "crema.assistant.thread.snapshot." const CHANGE_EVENT = "crema:threads-change" const MAX_BYTES = 800_000 export function newThreadId(): string { return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` } function isThread(v: unknown): v is Thread { if (!v || typeof v !== "object") return false const t = v as Thread return ( typeof t.id === "string" && typeof t.title === "string" && typeof t.agentId === "string" && Array.isArray(t.messages) && Array.isArray(t.pinned) && typeof t.createdAt === "number" && typeof t.updatedAt === "number" ) } function readFromStorage(): Thread[] { if (typeof window === "undefined") return [] try { const raw = localStorage.getItem(THREADS_KEY) if (!raw) return [] const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.filter(isThread) } catch { return [] } } function writeToStorage(threads: Thread[]) { if (typeof window === "undefined") return let serialized = JSON.stringify(threads) // Trim oldest threads if quota gets tight. let trimmed = threads while (serialized.length > MAX_BYTES && trimmed.length > 1) { trimmed = trimmed.slice(0, -1) serialized = JSON.stringify(trimmed) } try { localStorage.setItem(THREADS_KEY, serialized) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } catch { /* quota — bail */ } } export function loadThreads(): Thread[] { return readFromStorage() } export function saveThreads(threads: Thread[]) { writeToStorage(threads) } export function loadActiveThreadId(): string | null { if (typeof window === "undefined") return null return localStorage.getItem(ACTIVE_KEY) } export function saveActiveThreadId(id: string | null) { if (typeof window === "undefined") return if (id) localStorage.setItem(ACTIVE_KEY, id) else localStorage.removeItem(ACTIVE_KEY) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } let cached: Thread[] | null = null function subscribe(cb: () => void): () => void { const onChange = () => { cached = null cb() } window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener("storage", (e) => { if (e.key === THREADS_KEY || e.key === ACTIVE_KEY) onChange() }) return () => { window.removeEventListener(CHANGE_EVENT, onChange) } } function getSnapshot(): Thread[] { if (!cached) cached = readFromStorage() return cached } function getServerSnapshot(): Thread[] { return [] } export function useThreads(): Thread[] { const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) useEffect(() => { cached = null }, []) return value } export function ensureThread( threads: Thread[], fallbackAgentId: string, ): { threads: Thread[]; activeId: string } { const stored = loadActiveThreadId() if (stored && threads.some((t) => t.id === stored)) return { threads, activeId: stored } if (threads.length > 0) { saveActiveThreadId(threads[0].id) return { threads, activeId: threads[0].id } } const id = newThreadId() const now = Date.now() const fresh: Thread = { id, title: "New conversation", agentId: fallbackAgentId, messages: [], pinned: [], createdAt: now, updatedAt: now, } saveActiveThreadId(id) saveThreads([fresh, ...threads]) return { threads: [fresh, ...threads], activeId: id } } export function updateThread(id: string, patch: Partial) { const threads = readFromStorage() const next = threads.map((t) => t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t, ) writeToStorage(next) } export function createThread(agentId: string, title = "New conversation"): Thread { const threads = readFromStorage() const id = newThreadId() const now = Date.now() const fresh: Thread = { id, title, agentId, messages: [], pinned: [], createdAt: now, updatedAt: now, } writeToStorage([fresh, ...threads]) saveActiveThreadId(id) return fresh } export function deleteThread(id: string) { const threads = readFromStorage() const next = threads.filter((t) => t.id !== id) writeToStorage(next) if (loadActiveThreadId() === id) { saveActiveThreadId(next[0]?.id ?? null) } } export function snapshotThread(id: string) { const threads = readFromStorage() const t = threads.find((x) => x.id === id) if (!t) return try { localStorage.setItem(SNAPSHOT_KEY_PREFIX + id, JSON.stringify(t)) } catch { /* quota */ } } export function loadThreadSnapshot(id: string): Thread | null { if (typeof window === "undefined") return null try { const raw = localStorage.getItem(SNAPSHOT_KEY_PREFIX + id) if (!raw) return null const parsed = JSON.parse(raw) return isThread(parsed) ? parsed : null } catch { return null } } export function clearThreadSnapshot(id: string) { if (typeof window === "undefined") return localStorage.removeItem(SNAPSHOT_KEY_PREFIX + id) } export function deriveTitleFromFirstMessage(text: string): string { const trimmed = text.trim().split(/\s+/).slice(0, 8).join(" ") return trimmed.length > 60 ? trimmed.slice(0, 57) + "…" : trimmed || "New conversation" }