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",