diff --git a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts index cab91da44f..d9c27dc436 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-search-index.ts @@ -105,6 +105,9 @@ async function buildIndex(): Promise { const arr = byThreadId.get(tid); if (arr) merged.push(...arr); } + if (merged.length === 0) { + continue; + } merged.sort((a, b) => b.createdAt - a.createdAt); let preview = ""; diff --git a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts index 33cdd88081..69ed162780 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts @@ -4,6 +4,7 @@ import { db, useLiveQuery } from "../db"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; import type { ThreadRecord } from "../types"; +import { markChatThreadDeleted } from "../utils/chat-thread-tombstones"; export interface SidebarItem { type: "single" | "compare"; @@ -80,6 +81,7 @@ export async function deleteChatItem( // Stop any in-flight streams before deleting, so the model doesn't keep // generating against a thread that no longer exists. for (const id of threadIds) cancelIfRunning(id); + for (const id of threadIds) markChatThreadDeleted(id); await db.transaction("rw", db.threads, db.messages, async () => { for (const id of threadIds) { diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 105f2fb4b1..546cc21ed2 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -16,19 +16,32 @@ import { WebSpeechDictationAdapter, type unstable_RemoteThreadListAdapter, useAui, + useAuiEvent, useAuiState, useLocalRuntime, unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, } from "@assistant-ui/react"; import { createAssistantStream } from "assistant-stream"; import mammoth from "mammoth"; -import { type ReactElement, type ReactNode, useEffect, useMemo } from "react"; +import { + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, +} from "react"; import { extractText, getDocumentProxy } from "unpdf"; import { authFetch } from "@/features/auth"; import { createOpenAIStreamAdapter } from "./api/chat-adapter"; import { db } from "./db"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import type { MessageRecord, ModelType } from "./types"; +import { + isChatThreadDeleted, + markChatThreadDeleted, +} from "./utils/chat-thread-tombstones"; +import { syncExportedRepositoryToDexie } from "./utils/delete-thread-message"; const DEFAULT_SUGGESTIONS = [ { @@ -383,6 +396,55 @@ function toThreadMessage(m: MessageRecord): ThreadMessage { }; } +async function ensureThreadRecord({ + threadId, + modelType, + pairId, +}: { + threadId: string; + modelType: ModelType; + pairId?: string; +}): Promise { + if (isChatThreadDeleted(threadId)) { + return; + } + const existing = await db.threads.get(threadId); + if (existing) { + return; + } + + const currentModelId = + useChatRuntimeStore.getState().params.checkpoint ?? ""; + const record = { + id: threadId, + title: "New Chat", + modelType, + modelId: currentModelId, + pairId, + archived: false, + createdAt: Date.now(), + }; + + try { + await db.threads.add(record); + } catch (error) { + // assistant-ui can issue overlapping first-message persistence calls. + // If another call created the same thread while this one was waiting, + // treat initialization as successful and let the message write continue. + if (await db.threads.get(threadId)) { + return; + } + throw error; + } +} + +async function deleteThreadRows(threadId: string): Promise { + await db.transaction("rw", db.threads, db.messages, async () => { + await db.messages.where("threadId").equals(threadId).delete(); + await db.threads.delete(threadId); + }); +} + function createDexieAdapter( modelType: ModelType, pairId?: string, @@ -418,17 +480,7 @@ function createDexieAdapter( }, async initialize(threadId: string) { - const currentModelId = - useChatRuntimeStore.getState().params.checkpoint ?? ""; - await db.threads.add({ - id: threadId, - title: "New Chat", - modelType, - modelId: currentModelId, - pairId, - archived: false, - createdAt: Date.now(), - }); + await ensureThreadRecord({ threadId, modelType, pairId }); return { remoteId: threadId, externalId: undefined }; }, @@ -445,8 +497,8 @@ function createDexieAdapter( }, async delete(remoteId: string) { - await db.messages.where("threadId").equals(remoteId).delete(); - await db.threads.delete(remoteId); + markChatThreadDeleted(remoteId); + await deleteThreadRows(remoteId); }, async generateTitle(remoteId: string, messages: readonly ThreadMessage[]) { @@ -599,6 +651,10 @@ function ThreadHistoryProvider({ async append({ parentId, message }: ExportedMessageRepositoryItem) { const { remoteId } = await aui.threadListItem().initialize(); + if (isChatThreadDeleted(remoteId)) { + await deleteThreadRows(remoteId); + return; + } // Keep single-chat runtime state in sync once a new chat is first // persisted. Compare panes intentionally do not write global activeThreadId. const thread = await db.threads.get(remoteId); @@ -765,6 +821,66 @@ function CancelRegistrar(): ReactElement | null { return null; } +function ThreadDexieAutosave({ + modelType, + pairId, +}: { + modelType: ModelType; + pairId?: string; +}): ReactElement | null { + const aui = useAui(); + const saveChainRef = useRef(Promise.resolve()); + + const saveThread = useCallback(async (threadId: string): Promise => { + const runtime = aui.threads().__internal_getAssistantRuntime?.(); + if (!runtime) { + return; + } + const exported = runtime.threads.getById(threadId).export(); + if (exported.messages.length === 0) { + return; + } + + const { remoteId } = await runtime.threads.getItemById(threadId).initialize(); + if (isChatThreadDeleted(remoteId)) { + await deleteThreadRows(remoteId); + return; + } + await syncExportedRepositoryToDexie(remoteId, exported); + if (isChatThreadDeleted(remoteId)) { + await deleteThreadRows(remoteId); + return; + } + + if (modelType === "base" && !pairId) { + const store = useChatRuntimeStore.getState(); + const activeThreadId = runtime.threads.getState().mainThreadId; + if (activeThreadId === threadId && store.activeThreadId !== remoteId) { + store.setActiveThreadId(remoteId); + } + } + }, [aui, modelType, pairId]); + + const queueSave = useCallback((threadId: string): void => { + saveChainRef.current = saveChainRef.current + .catch(() => {}) + .then(() => saveThread(threadId)) + .catch((error) => { + console.error("Failed to autosave chat thread", error); + }); + }, [saveThread]); + + useAuiEvent("thread.runEnd", ({ threadId }) => { + queueSave(threadId); + }); + + useAuiEvent("thread.runStart", ({ threadId }) => { + queueSave(threadId); + }); + + return null; +} + export function ChatRuntimeProvider({ children, modelType = "base", @@ -797,6 +913,7 @@ export function ChatRuntimeProvider({ + {initialThreadId && ( (); + +export function markChatThreadDeleted(threadId: string): void { + deletedThreadIds.add(threadId); +} + +export function isChatThreadDeleted(threadId: string): boolean { + return deletedThreadIds.has(threadId); +} diff --git a/studio/frontend/src/features/chat/utils/delete-thread-message.ts b/studio/frontend/src/features/chat/utils/delete-thread-message.ts index 204c0d0064..e13da60630 100644 --- a/studio/frontend/src/features/chat/utils/delete-thread-message.ts +++ b/studio/frontend/src/features/chat/utils/delete-thread-message.ts @@ -19,8 +19,8 @@ import type { * surface area. */ import { MessageRepository } from "@assistant-ui/core/internal"; -import { db } from "@/features/chat/db"; -import type { MessageRecord } from "@/features/chat/types"; +import { db } from "../db"; +import type { MessageRecord } from "../types"; function cloneContent(content: ThreadMessage["content"]): ThreadMessage["content"] { if (typeof content === "string") { @@ -74,7 +74,7 @@ function exportedItemToRecord( * Persist the exact message list represented by `exp` for this thread, removing * Dexie rows that are no longer present (e.g. after a delete). */ -async function syncExportedRepositoryToDexie( +export async function syncExportedRepositoryToDexie( remoteId: string, exp: ExportedMessageRepository, ): Promise {