diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 6156a00031..139615a9ba 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -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" }> = ({
setToolsEnabled(!toolsEnabled)}
+ disabled={searchMenuDisabled}
+ className={searchMenuActive ? "text-primary font-medium" : undefined}
+ onSelect={toggleSearchTools}
>
Web search
- {toolsEnabled ? : null}
+ {searchMenuActive ? : null}
setCodeToolsEnabled(!codeToolsEnabled)}
+ disabled={codeMenuDisabled}
+ className={codeMenuActive ? "text-primary font-medium" : undefined}
+ onSelect={toggleCodeTools}
>
Code
- {codeToolsEnabled ? : null}
+ {codeMenuActive ? : null}
{showImageMenuItem ? (
(null);
+ const [viewBeforeCompare, setViewBeforeCompare] = useState(
+ 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(
() =>
@@ -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;
diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts
index 61ff727667..25a126fcb6 100644
--- a/studio/frontend/src/features/chat/index.ts
+++ b/studio/frontend/src/features/chat/index.ts
@@ -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,
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index 3e45fb8c70..40748d29b0 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -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({
)}
setToolsEnabled(!toolsEnabled)}
+ onSelect={toggleSearchTools}
>
Web search
- {toolsEnabled ? : null}
+ {searchToolsActive ? : null}
setCodeToolsEnabled(!codeToolsEnabled)}
+ onSelect={toggleCodeTools}
>
Code
- {codeToolsEnabled ? : null}
+ {codeToolsActive ? : null}
{showImageMenuItem ? (
{
- 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({