From d652f03b6e1e6b83fe6b178a1c8a0ac90aff2176 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 28 May 2026 10:26:12 +0400 Subject: [PATCH 1/3] Studio: restore draft thread (and its RAG docs) across page reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assistant-ui mints a fresh __LOCALID_* draft id on every page load, so RAG docs uploaded under the previous draft id were orphaned after a refresh — the doc panel queries useThreadDocuments(activeThreadId) and the new id had nothing. Persist activeThreadId in localStorage and, on the first settled render after load, have ActiveThreadSync ask aui to switchToThread(persisted) when it differs from the freshly-minted draft. Because the draft was already persisted to the backend by initialize()/ensureThreadRecord when its first doc was uploaded, the adapter's fetch() resolves it and aui adopts it as mainThreadId. That keeps aui's mainThreadId and our activeThreadId unified, so the earlier divergence (uploads under the persisted id vs chat-completion reading aui's fresh id) can't recur — unlike the reverted localStorage-only attempt, the chat-adapter's unstable_threadId now equals the persisted id after the switch. A one-shot ref ensures we only re-adopt on initial load; user-driven new-chat / thread switches still flow through normally. If the persisted draft was never initialized (no doc/message, not in the backend), switchToThread rejects and we fall back to the fresh draft. --- .../src/features/chat/runtime-provider.tsx | 40 +++++++++++++++++-- .../chat/stores/chat-runtime-store.ts | 13 ++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index d01383b309..64d3f8eed0 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -1008,17 +1008,51 @@ function ThreadNewChatSwitch({ function ActiveThreadSync({ enabled, }: { enabled: boolean }): ReactElement | null { + const aui = useAui(); + const isLoading = useAuiState(({ threads }) => threads.isLoading); const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); const setActiveThreadId = useChatRuntimeStore( (state) => state.setActiveThreadId, ); + // One-shot guard: we only attempt to re-adopt a persisted draft on the + // first settled render after a page load, never again for the lifetime + // of this provider (so user-driven new-chat / switches aren't fought). + const restoreAttemptedRef = useRef(false); useEffect(() => { - if (!enabled) { + if (!enabled || isLoading || !mainThreadId) { return; } - setActiveThreadId(mainThreadId ?? null); - }, [enabled, mainThreadId, setActiveThreadId]); + const persisted = useChatRuntimeStore.getState().activeThreadId; + // On a fresh page load aui mints a new `__LOCALID_*` draft. If we have + // a different persisted draft id, ask aui to switch to it so any RAG + // docs uploaded under that id reattach. The thread was persisted to + // the backend when its first doc/message ran initialize(), so the + // adapter's fetch() can resolve it. If it can't (a draft that never + // got a doc/message), switchToThread rejects and we fall back to the + // fresh draft. + if ( + !restoreAttemptedRef.current + && persisted + && persisted !== mainThreadId + && persisted.startsWith("__LOCALID_") + && mainThreadId.startsWith("__LOCALID_") + ) { + restoreAttemptedRef.current = true; + const result = aui.threads().switchToThread(persisted) as unknown; + if ( + result + && typeof (result as Promise).catch === "function" + ) { + void (result as Promise).catch(() => { + setActiveThreadId(mainThreadId); + }); + } + return; + } + restoreAttemptedRef.current = true; + setActiveThreadId(mainThreadId); + }, [aui, enabled, isLoading, mainThreadId, setActiveThreadId]); return null; } diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 1d1df695eb..ad89d52249 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -36,6 +36,7 @@ export const CHAT_RAG_TOOL_ENABLED_KEY = "unsloth_chat_rag_tool_enabled"; // localStorage persistence here the user's external pick is silently // reset to the default on every page refresh. const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint"; +const CHAT_ACTIVE_THREAD_KEY = "unsloth_chat_active_thread_id"; function loadLastExternalCheckpoint(): string | null { if (typeof window === "undefined") return null; @@ -610,7 +611,11 @@ export const useChatRuntimeStore = create((set, get) => ({ defaultChatTemplate: null, chatTemplateOverride: null, loadedChatTemplateOverride: null, - activeThreadId: null, + // Persisted so a draft thread's RAG docs reattach after a page reload. + // On reload ActiveThreadSync asks assistant-ui to switch to this id + // (the thread was persisted to the backend when its first doc/message + // initialized it), keeping aui's mainThreadId and this value unified. + activeThreadId: loadString(CHAT_ACTIVE_THREAD_KEY, "") || null, settingsPanelOpen: false, pendingAudioBase64: null, pendingAudioName: null, @@ -755,8 +760,10 @@ export const useChatRuntimeStore = create((set, get) => ({ activeGgufVariant: ggufVariant ?? null, }; }), - setActiveThreadId: (activeThreadId) => - set({ activeThreadId, contextUsage: null }), + setActiveThreadId: (activeThreadId) => { + saveString(CHAT_ACTIVE_THREAD_KEY, activeThreadId ?? ""); + set({ activeThreadId, contextUsage: null }); + }, setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }), clearCheckpoint: () => { // Mirror setCheckpoint's persistence behavior: dropping the From 3117cb5f99c62452a06047db93894f9613d1b129 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 28 May 2026 11:32:22 +0400 Subject: [PATCH 2/3] Revert "Studio: restore draft thread (and its RAG docs) across page reloads" This reverts commit d652f03b6e1e6b83fe6b178a1c8a0ac90aff2176. --- .../src/features/chat/runtime-provider.tsx | 40 ++----------------- .../chat/stores/chat-runtime-store.ts | 13 ++---- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 64d3f8eed0..d01383b309 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -1008,51 +1008,17 @@ function ThreadNewChatSwitch({ function ActiveThreadSync({ enabled, }: { enabled: boolean }): ReactElement | null { - const aui = useAui(); - const isLoading = useAuiState(({ threads }) => threads.isLoading); const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); const setActiveThreadId = useChatRuntimeStore( (state) => state.setActiveThreadId, ); - // One-shot guard: we only attempt to re-adopt a persisted draft on the - // first settled render after a page load, never again for the lifetime - // of this provider (so user-driven new-chat / switches aren't fought). - const restoreAttemptedRef = useRef(false); useEffect(() => { - if (!enabled || isLoading || !mainThreadId) { + if (!enabled) { return; } - const persisted = useChatRuntimeStore.getState().activeThreadId; - // On a fresh page load aui mints a new `__LOCALID_*` draft. If we have - // a different persisted draft id, ask aui to switch to it so any RAG - // docs uploaded under that id reattach. The thread was persisted to - // the backend when its first doc/message ran initialize(), so the - // adapter's fetch() can resolve it. If it can't (a draft that never - // got a doc/message), switchToThread rejects and we fall back to the - // fresh draft. - if ( - !restoreAttemptedRef.current - && persisted - && persisted !== mainThreadId - && persisted.startsWith("__LOCALID_") - && mainThreadId.startsWith("__LOCALID_") - ) { - restoreAttemptedRef.current = true; - const result = aui.threads().switchToThread(persisted) as unknown; - if ( - result - && typeof (result as Promise).catch === "function" - ) { - void (result as Promise).catch(() => { - setActiveThreadId(mainThreadId); - }); - } - return; - } - restoreAttemptedRef.current = true; - setActiveThreadId(mainThreadId); - }, [aui, enabled, isLoading, mainThreadId, setActiveThreadId]); + setActiveThreadId(mainThreadId ?? null); + }, [enabled, mainThreadId, setActiveThreadId]); return null; } diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index ad89d52249..1d1df695eb 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -36,7 +36,6 @@ export const CHAT_RAG_TOOL_ENABLED_KEY = "unsloth_chat_rag_tool_enabled"; // localStorage persistence here the user's external pick is silently // reset to the default on every page refresh. const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint"; -const CHAT_ACTIVE_THREAD_KEY = "unsloth_chat_active_thread_id"; function loadLastExternalCheckpoint(): string | null { if (typeof window === "undefined") return null; @@ -611,11 +610,7 @@ export const useChatRuntimeStore = create((set, get) => ({ defaultChatTemplate: null, chatTemplateOverride: null, loadedChatTemplateOverride: null, - // Persisted so a draft thread's RAG docs reattach after a page reload. - // On reload ActiveThreadSync asks assistant-ui to switch to this id - // (the thread was persisted to the backend when its first doc/message - // initialized it), keeping aui's mainThreadId and this value unified. - activeThreadId: loadString(CHAT_ACTIVE_THREAD_KEY, "") || null, + activeThreadId: null, settingsPanelOpen: false, pendingAudioBase64: null, pendingAudioName: null, @@ -760,10 +755,8 @@ export const useChatRuntimeStore = create((set, get) => ({ activeGgufVariant: ggufVariant ?? null, }; }), - setActiveThreadId: (activeThreadId) => { - saveString(CHAT_ACTIVE_THREAD_KEY, activeThreadId ?? ""); - set({ activeThreadId, contextUsage: null }); - }, + setActiveThreadId: (activeThreadId) => + set({ activeThreadId, contextUsage: null }), setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }), clearCheckpoint: () => { // Mirror setCheckpoint's persistence behavior: dropping the From 7e3e69db3fe55ab50858ccfdbde266b39177bfab Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 28 May 2026 11:41:39 +0400 Subject: [PATCH 3/3] Studio: raise ingestion toast stack above the settings sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast stack and the chat settings sheet were both z-50, so an open side panel (rendered later in the DOM) covered the indexing toast. Bump the stack to z-[9999] — comfortably above the sheet's z-50 — so the ingestion toast stays visible like the Sonner reranker toast does. --- .../src/features/rag/components/ingestion-toast-stack.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx b/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx index 570199fd42..5499eb2411 100644 --- a/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx +++ b/studio/frontend/src/features/rag/components/ingestion-toast-stack.tsx @@ -55,7 +55,7 @@ export function IngestionToastStack() { if (visible.length === 0) return null; return ( -
+
{visible.map(([jobId, event]) => { const isTerminal =