diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 1dbff145ee..cf1ba11d7b 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -225,6 +225,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="base" pairId={pairId} initialThreadId={baseThreadId} + syncActiveThreadId={false} > @@ -242,6 +243,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="lora" pairId={pairId} initialThreadId={loraThreadId} + syncActiveThreadId={false} > @@ -343,6 +345,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model1" pairId={pairId} initialThreadId={model1ThreadId} + syncActiveThreadId={false} > @@ -376,6 +379,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model2" pairId={pairId} initialThreadId={model2ThreadId} + syncActiveThreadId={false} > @@ -479,11 +483,19 @@ function TopBarActions({ ); } +function getInitialSingleChatView(): ChatView { + const id = useChatRuntimeStore.getState().activeThreadId; + if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) { + return { mode: "single", threadId: id }; + } + return { mode: "single" }; +} + export function ChatPage(): ReactElement { - const [view, setView] = useState({ - mode: "single", - newThreadNonce: crypto.randomUUID(), - }); + // Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch + // and create spurious threads when navigating (e.g. Recipes / Export). New Chat + // explicitly sets a nonce in handleNewThread. + const [view, setView] = useState(getInitialSingleChatView); const [settingsOpen, setSettingsOpen] = useState(false); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); @@ -587,9 +599,20 @@ export function ChatPage(): ReactElement { void ejectModel(); }, [ejectModel]); const handleNewThread = useCallback(() => { + // Skip if we are already on a fresh unsaved draft with no messages sent. + // Once the user sends a message, append() sets activeThreadId in the store, + // so we check the store to know whether the current draft has been sent. + if ( + view.mode === "single" && + !view.threadId && + !useChatRuntimeStore.getState().activeThreadId + ) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(null); setView({ mode: "single", newThreadNonce: crypto.randomUUID() }); - }, []); + }, [view]); const handleNewCompare = useCallback(() => { setView({ mode: "compare", pairId: crypto.randomUUID() }); // Clear activeThreadId so compare panes do not inherit the single-chat @@ -922,7 +945,7 @@ export function ChatPage(): ReactElement { {view.mode === "single" ? ( diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 7e6cd8e1dd..024543edb9 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -596,6 +596,15 @@ function ThreadHistoryProvider({ async append({ parentId, message }: ExportedMessageRepositoryItem) { const { remoteId } = await aui.threadListItem().initialize(); + // 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); + if (thread?.modelType === "base" && !thread.pairId) { + const store = useChatRuntimeStore.getState(); + if (store.activeThreadId !== remoteId) { + store.setActiveThreadId(remoteId); + } + } const content = cloneContent(message.content); const attachments = message.role === "user" ? cloneAttachments(message.attachments) : []; @@ -658,7 +667,11 @@ function useRuntimeHook(): ReturnType { function ThreadAutoSwitch({ threadId, -}: { threadId: string }): ReactElement | null { + syncActiveThreadId = true, +}: { + threadId: string; + syncActiveThreadId?: boolean; +}): ReactElement | null { const aui = useAui(); const isLoading = useAuiState(({ threads }) => threads.isLoading); const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); @@ -669,6 +682,13 @@ function ThreadAutoSwitch({ } }, [aui, isLoading, mainThreadId, threadId]); + useEffect(() => { + if (!syncActiveThreadId || isLoading || mainThreadId !== threadId) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(threadId); + }, [isLoading, mainThreadId, syncActiveThreadId, threadId]); + return null; } @@ -682,30 +702,10 @@ function ThreadNewChatSwitch({ if (isLoading) { return; } - - let cancelled = false; - // Clear immediately so the adapter never picks up a stale thread ID - // from a previous chat while we initialize the new one. + // Switch to a fresh local thread without persisting it yet. + // Persistence still happens on first message append. + void aui.threads().switchToNewThread(); useChatRuntimeStore.getState().setActiveThreadId(null); - - void (async () => { - try { - aui.threads().switchToNewThread(); - const { remoteId } = await aui.threadListItem().initialize(); - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(remoteId); - } - } catch (error) { - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(null); - } - console.error("Failed to initialize new chat thread", error); - } - })(); - - return () => { - cancelled = true; - }; }, [aui, isLoading, nonce]); return null; @@ -733,12 +733,14 @@ export function ChatRuntimeProvider({ pairId, initialThreadId, newThreadNonce, + syncActiveThreadId = true, }: { children: ReactNode; modelType?: ModelType; pairId?: string; initialThreadId?: string; newThreadNonce?: string; + syncActiveThreadId?: boolean; }): ReactElement { const runtime = useRemoteThreadListRuntime({ runtimeHook: useRuntimeHook, @@ -754,8 +756,15 @@ export function ChatRuntimeProvider({ return ( - - {initialThreadId && } + + {initialThreadId && ( + + )} {!initialThreadId && newThreadNonce && ( )} diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index ba97d2ee6e..62246cdad5 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -22,6 +22,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { db, useLiveQuery } from "./db"; +import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import type { ChatView, ThreadRecord } from "./types"; interface SidebarItem { @@ -76,12 +77,17 @@ export function ThreadSidebar({ onNewCompare: () => void; showCompare: boolean; }) { - const allThreads = useLiveQuery( - () => db.threads.orderBy("createdAt").reverse().toArray(), - [], - ); + const allThreads = useLiveQuery(async () => { + const threadIdsWithMessage = new Set( + (await db.messages.orderBy("threadId").uniqueKeys()) as string[], + ); + const rows = await db.threads.orderBy("createdAt").reverse().toArray(); + return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id)); + }, []); const items = groupThreads(allThreads ?? []); - const activeId = view.mode === "single" ? view.threadId : view.pairId; + const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const activeId = + view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId; function viewForItem(item: SidebarItem): ChatView { return item.type === "single" @@ -101,7 +107,11 @@ export function ThreadSidebar({ } } if (activeId === item.id) { - onSelect({ mode: "single" }); + // Directly set a new view with a nonce rather than going through + // onNewThread(), which may return early if the guard sees no + // threadId and no activeThreadId (after we just cleared it). + useChatRuntimeStore.getState().setActiveThreadId(null); + onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() }); } } diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 9ee2ade3db..d8fe92739a 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.4.3" +__version__ = "2026.4.4" __all__ = [ "SUPPORTS_BFLOAT16",