Incorporate PR #4304 toast UX improvements

Merge the toast UX refactor from PR #4304 (by @Shine1i):
- Toast duration 5s default with close button (X) for manual dismiss
- Inline progress bar component (ModelLoadInlineStatus) shown in the
  header after toast is dismissed
- Model switch warning only for image compatibility (not generic)
- activeThreadId tracked in store via ActiveThreadSync
- Loading state cleanup via resetLoadingUi helper
- Toast uses Infinity duration during loading with onDismiss handler

Re-applied non-GGUF download progress additions on top:
- getDownloadProgress for all models (not just GGUF)
- hasShownProgress flag, loadingModelRef race condition checks
- First poll at 500ms, bytes-only fallback when expected size unknown
This commit is contained in:
Daniel Han 2026-03-16 06:51:52 +00:00
commit 9cbeecc16a
6 changed files with 359 additions and 167 deletions

View file

@ -16,11 +16,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
duration={10000}
icons={{
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
duration={5000}
icons={{
success: (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}

View file

@ -8,7 +8,6 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
@ -39,6 +38,7 @@ import {
import { toast } from "sonner";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
@ -111,23 +111,6 @@ function messageHasImage(message: MessageRecord): boolean {
return false;
}
async function resolveActiveSingleThreadId(view: ChatView): Promise<string | undefined> {
if (view.mode !== "single") {
return undefined;
}
if (view.threadId) {
return view.threadId;
}
// New-thread flow keeps threadId undefined in local view state.
// Fall back to most recent regular base thread.
const candidates = await db.threads.where("modelType").equals("base").toArray();
const latest = candidates
.filter((thread) => !thread.archived && !thread.pairId)
.sort((a, b) => b.createdAt - a.createdAt)[0];
return latest?.id;
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -321,7 +304,16 @@ export function ChatPage(): ReactElement {
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const { refresh, selectModel, ejectModel, cancelLoading, loadingModel } =
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const {
refresh,
selectModel,
ejectModel,
cancelLoading,
loadingModel,
loadProgress,
loadToastDismissed,
} =
useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@ -343,42 +335,27 @@ export function ChatPage(): ReactElement {
const currentVariant = store.activeGgufVariant;
if (!value || (value === currentCheckpoint && (meta?.ggufVariant ?? null) === (currentVariant ?? null))) return;
void (async () => {
let switchNote: string | undefined;
const activeThreadId = await resolveActiveSingleThreadId(view);
if (activeThreadId) {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
const thread = await db.threads.get(activeThreadId);
if (thread?.modelId && thread.modelId !== value) {
const messages = await db.messages
.where("threadId")
.equals(activeThreadId)
.toArray();
if (messages.length === 0) {
// No history -- just switch silently
await db.threads.update(activeThreadId, { modelId: value });
await selectModel({
id: value,
isLora: meta?.isLora,
ggufVariant: meta?.ggufVariant,
isDownloaded: meta?.isDownloaded,
expectedBytes: meta?.expectedBytes,
});
return;
if (messages.length > 0) {
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
showImageCompatibilityWarning =
hasImage && targetModel?.isVision === false;
}
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
const nonVisionWithImages = hasImage && targetModel?.isVision === false;
switchNote = nonVisionWithImages
? "Full chat history will be sent to the new model. This chat has images; text-only models may fail."
: hasImage
? "Full chat history will be sent to the new model. This chat includes images."
: "Full chat history will be sent to the new model.";
}
}
if (switchNote) {
toast.warning("Model changed for this chat", {
description: switchNote,
if (showImageCompatibilityWarning) {
toast.warning("Selected model may not handle earlier images", {
description:
"This chat already includes images. Text-only models can ignore them or fail on follow-up replies.",
duration: 6000,
});
}
@ -391,13 +368,16 @@ export function ChatPage(): ReactElement {
});
})();
},
[modelsFromStore, selectModel, view],
[activeThreadId, modelsFromStore, selectModel, view],
);
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(
() => setView({ mode: "single", newThreadNonce: crypto.randomUUID() }),
() => {
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
},
[],
);
const handleNewCompare = useCallback(
@ -618,25 +598,22 @@ export function ChatPage(): ReactElement {
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
/>
{loadingModel ? (
<div
className="flex items-center gap-1.5 text-muted-foreground"
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded
? "Loading model…"
: "Downloading model…"
}
title={loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: `Loading ${loadingModel.displayName}. This may include downloading.`}
>
<Spinner className="size-3.5 shrink-0" />
<span className="text-xs">
{loadingModel.isDownloaded ? "Loading model…" : "Downloading model…"}
</span>
<button
type="button"
onClick={cancelLoading}
className="ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
>
Cancel
</button>
</div>
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
</div>
{modelsError && (

View file

@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
type ModelLoadDescriptionProps = {
message?: string | null;
progressPercent?: number | null;
progressLabel?: string | null;
onStop?: () => void;
stopLabel?: string;
};
function clampProgress(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function ModelLoadDescription({
message,
progressPercent,
progressLabel,
onStop,
stopLabel = "Stop loading",
}: ModelLoadDescriptionProps) {
const hasProgress = typeof progressPercent === "number";
return (
<div className="flex items-center gap-1.5">
<div className="min-w-0 flex-1">
{hasProgress ? (
<div className="w-[12.5rem] max-w-full">
<div className="flex items-center justify-between text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">
<span>{progressLabel}</span>
<span>{Math.round(clampProgress(progressPercent))}%</span>
</div>
<Progress value={clampProgress(progressPercent)} className="h-1 bg-foreground/[0.08]" />
</div>
) : message ? (
<p className="text-xs leading-relaxed text-muted-foreground">{message}</p>
) : null}
</div>
{onStop ? (
<Button
type="button"
size="xs"
variant="ghost"
className="h-5 shrink-0 px-2 text-[10px] text-muted-foreground hover:text-foreground"
onClick={onStop}
>
{stopLabel}
</Button>
) : null}
</div>
);
}
type ModelLoadInlineStatusProps = {
label: string;
title: string;
progressPercent?: number | null;
progressLabel?: string | null;
onStop?: () => void;
};
export function ModelLoadInlineStatus({
label,
title,
progressPercent,
progressLabel,
onStop,
}: ModelLoadInlineStatusProps) {
const hasProgress = typeof progressPercent === "number";
return (
<div className="flex min-w-[20rem] items-center gap-2.5 text-muted-foreground" title={title}>
<div className="flex items-center gap-1.5 shrink-0">
<Spinner className="size-3.5 shrink-0" />
<span className="text-xs">{label}</span>
</div>
{hasProgress ? (
<div className="flex min-w-0 flex-[1.35] items-center gap-2.5">
<div className="min-w-[7rem] flex-1">
<Progress value={clampProgress(progressPercent)} className="h-1 bg-foreground/[0.08]" />
</div>
<div className="flex shrink-0 items-center gap-1 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">
<span>{progressLabel}</span>
<span>{Math.round(clampProgress(progressPercent))}%</span>
</div>
</div>
) : null}
{onStop ? (
<Button
type="button"
size="xs"
variant="ghost"
className="shrink-0 text-[11px] text-muted-foreground hover:text-foreground"
onClick={onStop}
>
Stop
</Button>
) : null}
</div>
);
}

View file

@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useRef, useState } from "react";
import { createElement, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Spinner } from "@/components/ui/spinner";
import { ModelLoadDescription } from "../components/model-load-status";
import {
getDownloadProgress,
getGgufDownloadProgress,
@ -30,6 +32,15 @@ type SelectedModelInput = {
expectedBytes?: number;
};
const MODEL_LOAD_TOAST_CLASSNAMES = {
toast: "items-start gap-2.5 pr-8",
content: "gap-0.5",
title: "leading-5",
description: "mt-0",
closeButton:
"!left-auto !right-1 !top-2 !translate-x-0 !translate-y-0 !border-transparent !bg-transparent !shadow-none hover:!bg-transparent hover:opacity-70",
} as const;
const LORA_SUFFIX_RE = /_(\d{9,})$/;
function parseTrailingEpoch(input: string): number | undefined {
@ -152,11 +163,46 @@ export function useChatModelRuntime() {
displayName: string;
isDownloaded?: boolean;
} | null>(null);
const [_loadAbortController, setLoadAbortController] =
useState<AbortController | null>(null);
const [loadToastDismissed, setLoadToastDismissed] = useState(false);
const [loadProgress, setLoadProgress] = useState<{
percent: number | null;
label: string | null;
phase: "downloading" | "starting";
} | null>(null);
const loadAbortRef = useRef<AbortController | null>(null);
const loadingModelRef = useRef<typeof loadingModel>(null);
const loadToastIdRef = useRef<string | number | null>(null);
const loadToastDismissedRef = useRef(false);
const setLoadToastDismissedState = useCallback((dismissed: boolean) => {
loadToastDismissedRef.current = dismissed;
setLoadToastDismissed(dismissed);
}, []);
const resetLoadingUi = useCallback(() => {
setLoadingModel(null);
setLoadProgress(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
setLoadToastDismissedState(false);
}, [setLoadToastDismissedState]);
const renderLoadDescription = useCallback(
(
message: string,
progressPercent?: number | null,
progressLabel?: string | null,
onStop?: () => void,
) =>
createElement(ModelLoadDescription, {
message,
progressPercent,
progressLabel,
onStop,
}),
[],
);
const refresh = useCallback(async () => {
setModelsError(null);
@ -183,6 +229,26 @@ export function useChatModelRuntime() {
}
}, [setCheckpoint, setLoras, setModels, setModelsError]);
const cancelLoading = useCallback(() => {
const model = loadingModelRef.current;
if (!model) return;
loadAbortRef.current?.abort();
loadAbortRef.current = null;
loadingModelRef.current = null;
const tid = loadToastIdRef.current;
loadToastIdRef.current = null;
setLoadingModel(null);
setLoadProgress(null);
setLoadToastDismissedState(false);
clearCheckpoint();
if (tid != null) toast.dismiss(tid);
toast.info("Stopped loading model", {
description: "The current download may still finish in the background.",
});
// Fire-and-forget: tell backend to stop, don't block UI
unloadModel({ model_path: model.id }).catch(() => {});
}, [clearCheckpoint, setLoadToastDismissedState]);
const selectModel = useCallback(
async (selection: string | SelectedModelInput) => {
const modelId = typeof selection === "string" ? selection : selection.id;
@ -218,21 +284,23 @@ export function useChatModelRuntime() {
const previousIsLora =
previousModel?.isLora ?? (previousLora ? true : false);
const loadingDescription = [
currentCheckpoint ? "Unloading previous model first." : null,
currentCheckpoint ? "Switching models." : null,
extraLoadingDescription ?? null,
isDownloaded
? "Loading cached model into memory."
: "Downloading and loading model. Large models can take a while.",
isDownloaded ? "Loading cached model into memory." : null,
]
.filter(Boolean)
.join(" ");
setModelsError(null);
setLoadToastDismissedState(false);
const loadInfo = { id: modelId, displayName, isDownloaded };
setLoadingModel(loadInfo);
setLoadProgress(
isDownloaded
? { percent: null, label: null, phase: "starting" }
: { percent: 0, label: "Preparing download", phase: "downloading" },
);
loadingModelRef.current = loadInfo;
const abortCtrl = new AbortController();
setLoadAbortController(abortCtrl);
loadAbortRef.current = abortCtrl;
try {
async function performLoad(): Promise<void> {
@ -301,56 +369,37 @@ export function useChatModelRuntime() {
}
}
const toastId = toast.loading(
isDownloaded ? "Loading model…" : "Downloading model…",
const toastId = toast(
isDownloaded ? "Starting model…" : "Downloading model…",
{
description: loadingDescription,
duration: 10000,
action: {
label: "Cancel",
onClick: () => {
abortCtrl.abort();
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
unloadModel({ model_path: modelId }).catch(() => {});
clearCheckpoint();
toast.dismiss(toastId);
toast.info("Model loading cancelled");
},
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
loadingDescription,
isDownloaded ? null : 0,
isDownloaded ? null : "Preparing download",
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) {
return;
}
setLoadToastDismissedState(true);
},
},
);
loadToastIdRef.current = toastId;
// Poll download progress for non-cached models
// Poll download progress for non-cached models (GGUF and non-GGUF)
let progressInterval: ReturnType<typeof setInterval> | null = null;
if (!isDownloaded) {
const expectedBytes =
typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0;
const cancelAction = {
label: "Cancel",
onClick: () => {
abortCtrl.abort();
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
unloadModel({ model_path: modelId }).catch(() => {});
clearCheckpoint();
toast.dismiss(toastId);
toast.info("Model loading cancelled");
},
};
let hasShownProgress = false;
const pollProgress = async () => {
// Stop if cancelled or if loading already finished
if (abortCtrl.signal.aborted || !loadingModelRef.current) {
if (progressInterval) clearInterval(progressInterval);
return;
@ -360,7 +409,6 @@ export function useChatModelRuntime() {
? await getGgufDownloadProgress(modelId, ggufVariant, expectedBytes)
: await getDownloadProgress(modelId);
// Re-check after await -- load may have finished while polling
if (!loadingModelRef.current) return;
if (prog.progress > 0 && prog.progress < 1) {
@ -368,36 +416,69 @@ export function useChatModelRuntime() {
const dlGb = prog.downloaded_bytes / (1024 ** 3);
const totalGb = prog.expected_bytes / (1024 ** 3);
const pct = Math.round(prog.progress * 100);
toast.loading(
`Downloading model... ${pct}%`,
const progressLabel = totalGb > 0
? `${dlGb.toFixed(1)} of ${totalGb.toFixed(1)} GB`
: `${dlGb.toFixed(1)} GB downloaded`;
setLoadProgress({
percent: pct,
label: progressLabel,
phase: "downloading",
});
if (loadToastDismissedRef.current) return;
toast(
"Downloading model…",
{
id: toastId,
description: totalGb > 0
? `${dlGb.toFixed(1)} / ${totalGb.toFixed(1)} GB`
: `${dlGb.toFixed(1)} GB downloaded`,
duration: 10000,
action: cancelAction,
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
loadingDescription,
pct,
progressLabel,
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
setLoadToastDismissedState(true);
},
},
);
} else if (prog.downloaded_bytes > 0 && prog.expected_bytes === 0 && prog.progress === 0) {
// Have bytes but no total size -- show bytes only
hasShownProgress = true;
const dlGb = prog.downloaded_bytes / (1024 ** 3);
toast.loading(
"Downloading model...",
{
id: toastId,
description: `${dlGb.toFixed(1)} GB downloaded`,
duration: 10000,
action: cancelAction,
},
);
setLoadProgress({
percent: null,
label: `${dlGb.toFixed(1)} GB downloaded`,
phase: "downloading",
});
} else if (prog.progress >= 1 && hasShownProgress) {
// Only show "download complete" if we actually showed progress
toast.loading("Loading model...", {
setLoadProgress({
percent: 100,
label: "Download complete",
phase: "starting",
});
if (loadToastDismissedRef.current) {
if (progressInterval) clearInterval(progressInterval);
return;
}
toast("Starting model…", {
id: toastId,
description: "Download complete. Loading into memory...",
duration: 10000,
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
"Download complete. Loading the model into memory.",
100,
"Download complete",
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
setLoadToastDismissedState(true);
},
});
if (progressInterval) clearInterval(progressInterval);
}
@ -406,40 +487,62 @@ export function useChatModelRuntime() {
}
};
// First poll after 500ms, then every 2s
setTimeout(pollProgress, 500);
progressInterval = setInterval(pollProgress, 2000);
}
try {
await performLoad();
toast.success(`${displayName} loaded`, { id: toastId });
if (loadToastDismissedRef.current) {
toast.success(`${displayName} loaded`);
} else {
toast.success(`${displayName} loaded`, {
id: toastId,
description: undefined,
closeButton: false,
duration: 2000,
});
}
} catch (err) {
if (!abortCtrl.signal.aborted) {
toast.error(
err instanceof Error ? err.message : "Failed to load model",
{ id: toastId },
);
const message =
err instanceof Error ? err.message : "Failed to load model";
if (loadToastDismissedRef.current) {
toast.error(message);
} else {
toast.error(message, {
id: toastId,
description: undefined,
closeButton: false,
duration: 5000,
});
}
}
throw err;
} finally {
if (progressInterval) clearInterval(progressInterval);
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
resetLoadingUi();
}
} catch (error) {
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
setLoadingModel(null);
loadingModelRef.current = null;
resetLoadingUi();
const message =
error instanceof Error ? error.message : "Failed to load model";
setModelsError(message);
}
},
[loras, models, params.checkpoint, refresh, setModelsError, setParams],
[
cancelLoading,
loras,
models,
params.checkpoint,
refresh,
renderLoadDescription,
resetLoadingUi,
setLoadToastDismissedState,
setModelsError,
setParams,
],
);
const ejectModel = useCallback(async () => {
@ -468,28 +571,13 @@ export function useChatModelRuntime() {
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);
const cancelLoading = useCallback(() => {
const model = loadingModelRef.current;
if (!model) return;
loadAbortRef.current?.abort();
loadAbortRef.current = null;
loadingModelRef.current = null;
const tid = loadToastIdRef.current;
loadToastIdRef.current = null;
setLoadingModel(null);
setLoadAbortController(null);
clearCheckpoint();
if (tid != null) toast.dismiss(tid);
toast.info("Model loading cancelled");
// Fire-and-forget: tell backend to stop, don't block UI
unloadModel({ model_path: model.id }).catch(() => {});
}, [clearCheckpoint]);
return {
refresh,
selectModel,
ejectModel,
cancelLoading,
loadingModel,
loadProgress,
loadToastDismissed,
};
}

View file

@ -559,6 +559,22 @@ function ThreadNewChatSwitch({
return null;
}
function ActiveThreadSync({
enabled,
}: { enabled: boolean }): ReactElement | null {
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
const setActiveThreadId = useChatRuntimeStore((state) => state.setActiveThreadId);
useEffect(() => {
if (!enabled) {
return;
}
setActiveThreadId(mainThreadId ?? null);
}, [enabled, mainThreadId, setActiveThreadId]);
return null;
}
export function ChatRuntimeProvider({
children,
modelType = "base",
@ -586,6 +602,7 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync enabled={modelType === "base" && !pairId} />
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />

View file

@ -43,6 +43,7 @@ type ChatRuntimeStore = {
autoTitle: boolean;
modelsError: string | null;
activeGgufVariant: string | null;
activeThreadId: string | null;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
setParams: (params: InferenceParams) => void;
@ -52,6 +53,7 @@ type ChatRuntimeStore = {
setAutoTitle: (enabled: boolean) => void;
setModelsError: (error: string | null) => void;
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
clearCheckpoint: () => void;
setPendingAudio: (base64: string, name: string) => void;
clearPendingAudio: () => void;
@ -65,6 +67,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
autoTitle: loadBool(AUTO_TITLE_KEY, false),
modelsError: null,
activeGgufVariant: null,
activeThreadId: null,
pendingAudioBase64: null,
pendingAudioName: null,
setParams: (params) => set({ params }),
@ -94,6 +97,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
},
activeGgufVariant: ggufVariant ?? null,
})),
setActiveThreadId: (activeThreadId) => set({ activeThreadId }),
clearCheckpoint: () =>
set((state) => ({
params: {