fix(chat): harden composer tool and compare flows

This commit is contained in:
wasimysaid 2026-05-30 22:02:08 +02:00
commit def605d5b6
6 changed files with 144 additions and 54 deletions

View file

@ -46,6 +46,7 @@ import {
} from "@/components/ui/dropdown-menu";
import {
applyQwenThinkingParams,
createCompareId,
deleteThreadMessage,
getExternalReasoningCapabilities,
parseExternalModelId,
@ -1463,9 +1464,29 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const supportsBuiltinWebSearch = useChatRuntimeStore(
(s) => s.supportsBuiltinWebSearch,
);
const supportsBuiltinCodeExecution = useChatRuntimeStore(
(s) => s.supportsBuiltinCodeExecution,
);
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const connectionsEnabled = useExternalProvidersStore(
(s) => s.connectionsEnabled,
);
const externalProvidersAll = useExternalProvidersStore((s) => s.providers);
const externalProviders = connectionsEnabled ? externalProvidersAll : [];
const externalSelection = parseExternalModelId(checkpoint);
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const supportsBuiltinImageGeneration = useChatRuntimeStore(
(s) => s.supportsBuiltinImageGeneration,
);
@ -1486,17 +1507,39 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled;
const imageMenuDisabled = modelLoaded && !supportsBuiltinImageGeneration;
const fetchMenuDisabled = modelLoaded && !supportsBuiltinWebFetch;
const searchMenuDisabled =
modelLoaded && !(supportsTools || supportsBuiltinWebSearch);
const codeMenuDisabled =
modelLoaded && !(supportsTools || supportsBuiltinCodeExecution);
const searchMenuActive = toolsEnabled && !searchMenuDisabled;
const codeMenuActive = codeToolsEnabled && !codeMenuDisabled;
const toggleSearchTools = useCallback(() => {
if (searchMenuDisabled) return;
const next = !toolsEnabled;
setToolsEnabled(next);
if (isKimiExternal) {
setReasoningEnabled(!next);
applyQwenThinkingParams(!next);
}
}, [
isKimiExternal,
searchMenuDisabled,
setReasoningEnabled,
setToolsEnabled,
toolsEnabled,
]);
const toggleCodeTools = useCallback(() => {
if (codeMenuDisabled) return;
setCodeToolsEnabled(!codeToolsEnabled);
}, [codeMenuDisabled, codeToolsEnabled, setCodeToolsEnabled]);
const startCompare = useCallback(() => {
const store = useChatRuntimeStore.getState();
store.setActiveThreadId(null);
store.setContextUsage(null);
// crypto.randomUUID is undefined in non-secure contexts (HTTP over a LAN IP).
const compareId =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
navigate({ to: "/chat", search: { compare: compareId } });
navigate({ to: "/chat", search: { compare: createCompareId() } });
}, [navigate]);
return (
@ -1545,20 +1588,22 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
<ComposerAudioMenuItem />
<DropdownMenuSeparator />
<DropdownMenuItem
className={toolsEnabled ? "text-primary font-medium" : undefined}
onSelect={() => setToolsEnabled(!toolsEnabled)}
disabled={searchMenuDisabled}
className={searchMenuActive ? "text-primary font-medium" : undefined}
onSelect={toggleSearchTools}
>
<GlobeIcon />
Web search
{toolsEnabled ? <CheckIcon className="ml-auto" /> : null}
{searchMenuActive ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
className={codeToolsEnabled ? "text-primary font-medium" : undefined}
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
disabled={codeMenuDisabled}
className={codeMenuActive ? "text-primary font-medium" : undefined}
onSelect={toggleCodeTools}
>
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
Code
{codeToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
{codeMenuActive ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
{showImageMenuItem ? (
<DropdownMenuItem

View file

@ -12,13 +12,15 @@ import {
import { Thread } from "@/components/assistant-ui/thread";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import {
NativeModelChip,
NativeModelDropOverlay,
type NativeIntent,
useChooseNativeModel,
useNativeIntentStore,
useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -85,6 +87,7 @@ import {
listStoredChatMessages,
listStoredChatThreads,
} from "./utils/chat-history-storage";
import { createCompareId } from "./utils/compare-id";
type LoraCandidate = {
id: string;
@ -632,7 +635,9 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatSearch | null>(
null,
);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore(
@ -1199,20 +1204,20 @@ export function ChatPage(): ReactElement {
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => {
viewBeforeCompareRef.current = { ...search };
setViewBeforeCompare({ ...search });
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
navigate({ to: "/chat", search: { compare: createCompareId() } });
}, [navigate, search]);
const exitCompare = useCallback(() => {
const saved = viewBeforeCompareRef.current;
const saved = viewBeforeCompare;
// No saved view (compare opened by direct URL); fall back to a fresh chat.
if (!saved) {
navigate({ to: "/chat" });
return;
}
viewBeforeCompareRef.current = null;
setViewBeforeCompare(null);
navigate({ to: "/chat", search: saved });
// Restore usage from the last assistant message, but only if it
// matches the currently active checkpoint. Without this guard the
@ -1259,7 +1264,7 @@ export function ChatPage(): ReactElement {
}
});
}
}, [navigate]);
}, [navigate, viewBeforeCompare]);
const models = useMemo<ModelOption[]>(
() =>
@ -1415,7 +1420,7 @@ export function ChatPage(): ReactElement {
if (canceled) return;
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
navigate({ to: "/chat", search: { compare: createCompareId() } });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;

View file

@ -7,6 +7,7 @@ export { parseExternalModelId } from "./external-providers";
export { getExternalReasoningCapabilities } from "./provider-capabilities";
export { useExternalProvidersStore } from "./stores/external-providers-store";
export { deleteThreadMessage } from "./utils/delete-thread-message";
export { createCompareId } from "./utils/compare-id";
export { applyQwenThinkingParams } from "./utils/qwen-params";
export {
getInferenceStatus,

View file

@ -610,6 +610,30 @@ export function SharedComposer({
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = modelLoaded && !supportsBuiltinWebFetch;
const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled;
const searchToolsActive = toolsEnabled && !searchDisabled;
const codeToolsActive = codeToolsEnabled && !codeDisabled;
const toggleSearchTools = useCallback(() => {
if (searchDisabled) return;
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled
// (https://platform.kimi.ai/docs/guide/use-web-search). Toggle
// the Think pill off when Search is on, mirroring the backend.
if (isKimiExternal) {
setReasoningEnabled(!next, { persist: false });
applyQwenThinkingParams(!next);
}
}, [
isKimiExternal,
searchDisabled,
setReasoningEnabled,
setToolsEnabled,
toolsEnabled,
]);
const toggleCodeTools = useCallback(() => {
if (codeDisabled) return;
setCodeToolsEnabled(!codeToolsEnabled);
}, [codeDisabled, codeToolsEnabled, setCodeToolsEnabled]);
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
@ -657,10 +681,18 @@ export function SharedComposer({
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
void fileToBase64(file)
.then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
})
.catch(() => {
setPendingAudio(null);
clearPendingAudioStore();
toast.error("Failed to read audio file", {
description: "Try a different audio file or add it again.",
});
});
continue;
}
// Handle image files
@ -877,10 +909,6 @@ export function SharedComposer({
const handle1 = handlesRef.current["model1"];
const handle2 = handlesRef.current["model2"];
// Show user messages immediately on both sides
if (handle1) handle1.appendMessage(content);
if (handle2) handle2.appendMessage(content);
const name1 = model1?.id ? modelDisplayName(model1.id) : "";
const name2 = model2?.id ? modelDisplayName(model2.id) : "";
const toastId = toast("Comparing models…", { duration: Infinity });
@ -900,6 +928,7 @@ export function SharedComposer({
description: `${name1} (${status1})`,
duration: Infinity,
});
handle1.appendMessage(content);
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
@ -923,6 +952,7 @@ export function SharedComposer({
description: `${name2} (${status2})`,
duration: Infinity,
});
handle2.appendMessage(content);
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
@ -1132,24 +1162,26 @@ export function SharedComposer({
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={searchDisabled}
className={
toolsEnabled ? "text-primary font-medium" : undefined
searchToolsActive ? "text-primary font-medium" : undefined
}
onSelect={() => setToolsEnabled(!toolsEnabled)}
onSelect={toggleSearchTools}
>
<GlobeIcon />
Web search
{toolsEnabled ? <CheckIcon className="ml-auto" /> : null}
{searchToolsActive ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
disabled={codeDisabled}
className={
codeToolsEnabled ? "text-primary font-medium" : undefined
codeToolsActive ? "text-primary font-medium" : undefined
}
onSelect={() => setCodeToolsEnabled(!codeToolsEnabled)}
onSelect={toggleCodeTools}
>
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
Code
{codeToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
{codeToolsActive ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
{showImageMenuItem ? (
<DropdownMenuItem
@ -1236,19 +1268,9 @@ export function SharedComposer({
<button
type="button"
disabled={searchDisabled}
onClick={() => {
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled
// (https://platform.kimi.ai/docs/guide/use-web-search). Toggle
// the Think pill off when Search is on, mirroring the backend.
if (isKimiExternal) {
setReasoningEnabled(!next, { persist: false });
applyQwenThinkingParams(!next);
}
}}
onClick={toggleSearchTools}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
data-active={searchToolsActive ? "true" : "false"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
@ -1262,9 +1284,9 @@ export function SharedComposer({
<button
type="button"
disabled={codeDisabled}
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
onClick={toggleCodeTools}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
data-active={codeToolsActive ? "true" : "false"}
aria-label={
codeToolsEnabled
? "Disable code execution"

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function createCompareId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

View file

@ -0,0 +1,8 @@
export { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";