Studio: Chat thread autosave persistence (#5256)

* fix: chat thread autosave persistence

* fix: guard autosave deletion race

* fix: let run-start autosave persist chats

* fix: scope chat autosave to event thread

* fix: clean up tombstoned chat append rows
This commit is contained in:
Lee Jackson 2026-05-03 19:25:23 +01:00 committed by GitHub
commit 4d9a6ac63a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 151 additions and 17 deletions

View file

@ -105,6 +105,9 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
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 = "";

View file

@ -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) {

View file

@ -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<void> {
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<void> {
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<void> => {
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({
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
/>
<ThreadDexieAutosave modelType={modelType} pairId={pairId} />
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const deletedThreadIds = new Set<string>();
export function markChatThreadDeleted(threadId: string): void {
deletedThreadIds.add(threadId);
}
export function isChatThreadDeleted(threadId: string): boolean {
return deletedThreadIds.has(threadId);
}

View file

@ -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<void> {