diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index d78297824e..056e7bed90 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -314,7 +314,7 @@ function CompareShell({
{children}
-
{composer}
+
{composer}
LLMs can make mistakes. Double-check responses.
@@ -335,15 +335,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => {
let isActive = true;
- listStoredChatThreads({ pairId }).then((threads) => {
- if (!isActive) return;
- setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
- setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
- }).catch((error) => {
- if (!isExpectedBackgroundChatStorageError(error)) {
- throw error;
- }
- });
+ listStoredChatThreads({ pairId })
+ .then((threads) => {
+ if (!isActive) return;
+ setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
+ setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
+ })
+ .catch((error) => {
+ if (!isExpectedBackgroundChatStorageError(error)) {
+ throw error;
+ }
+ });
return () => {
isActive = false;
};
@@ -353,10 +355,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
+
}
>
<>
@@ -491,21 +490,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
- listStoredChatThreads({ pairId }).then((threads) => {
- if (!isActive) return;
- setModel1ThreadId(
- threads.find((t) => t.modelType === "model1" || t.modelType === "base")
- ?.id,
- );
- setModel2ThreadId(
- threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
- ?.id,
- );
- }).catch((error) => {
- if (!isExpectedBackgroundChatStorageError(error)) {
- throw error;
- }
- });
+ listStoredChatThreads({ pairId })
+ .then((threads) => {
+ if (!isActive) return;
+ setModel1ThreadId(
+ threads.find(
+ (t) => t.modelType === "model1" || t.modelType === "base",
+ )?.id,
+ );
+ setModel2ThreadId(
+ threads.find(
+ (t) => t.modelType === "model2" || t.modelType === "lora",
+ )?.id,
+ );
+ })
+ .catch((error) => {
+ if (!isExpectedBackgroundChatStorageError(error)) {
+ throw error;
+ }
+ });
return () => {
isActive = false;
};
@@ -659,8 +662,7 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => {
- const turnedOff =
- prevConnectionsEnabledRef.current && !connectionsEnabled;
+ const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
clearCheckpoint();
if (turnedOff) {
@@ -670,11 +672,7 @@ export function ChatPage(): ReactElement {
}
}
prevConnectionsEnabledRef.current = connectionsEnabled;
- }, [
- clearCheckpoint,
- connectionsEnabled,
- inferenceParams.checkpoint,
- ]);
+ }, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
@@ -693,17 +691,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
- const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
+ const supportsReasoningOff = useChatRuntimeStore(
+ (s) => s.supportsReasoningOff,
+ );
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
- externalProvidersForChat.find(
- (p) => p.id === selection.providerId,
- ) ?? null
+ externalProvidersForChat.find((p) => p.id === selection.providerId) ??
+ null
);
}, [externalProvidersForChat, inferenceParams.checkpoint]);
- const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
+ const activeExternalProviderType =
+ activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
@@ -819,7 +819,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
- const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
+ const storedCodeToolsEnabled = loadOptionalBool(
+ CHAT_CODE_TOOLS_ENABLED_KEY,
+ );
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
@@ -983,8 +985,7 @@ export function ChatPage(): ReactElement {
selectedProvider?.providerType,
selectedExternal?.modelId,
{
- isReasoningProvider:
- selectedProvider?.isReasoningModel === true,
+ isReasoningProvider: selectedProvider?.isReasoningModel === true,
baseUrl: selectedProvider?.baseUrl ?? null,
},
);
@@ -1030,11 +1031,12 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
- const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
- selectedProvider?.providerType,
- selectedExternal?.modelId,
- selectedProvider?.baseUrl,
- );
+ const supportsBuiltinCodeExecution =
+ providerSupportsBuiltinCodeExecution(
+ selectedProvider?.providerType,
+ selectedExternal?.modelId,
+ selectedProvider?.baseUrl,
+ );
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType,
@@ -1231,8 +1233,7 @@ export function ChatPage(): ReactElement {
if (!usage) return;
const store = useChatRuntimeStore.getState();
const activeCheckpoint = store.params.checkpoint;
- const usageModelId =
- (usage as { modelId?: unknown }).modelId;
+ const usageModelId = (usage as { modelId?: unknown }).modelId;
// Scope by modelId when present; reject if no active checkpoint
// (model-scoped usage cannot be attributed to "nothing").
if (typeof usageModelId === "string" && usageModelId) {
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index c6b36c6f9e..e9cf589f32 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -21,7 +21,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
+import { applyQwenThinkingParams } from "@/features/chat";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
@@ -55,7 +55,10 @@ import { useNavigate } from "@tanstack/react-router";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
-import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
+import {
+ parseExternalModelId,
+ providerTypeSupportsVision,
+} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
@@ -78,6 +81,7 @@ import {
useCallback,
useContext,
useEffect,
+ useMemo,
useRef,
useState,
} from "react";
@@ -166,7 +170,10 @@ function fileToBase64DataURL(file: File): Promise
{
});
}
-function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
+function formatReasoningEffortLabel(
+ level: ReasoningEffort,
+ modelId?: string,
+): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
@@ -202,7 +209,12 @@ function useDictation(
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
- (window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
+ (window.SpeechRecognition ??
+ (
+ window as unknown as {
+ webkitSpeechRecognition?: typeof SpeechRecognition;
+ }
+ ).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
@@ -248,7 +260,11 @@ function useDictation(
const supported =
typeof window !== "undefined" &&
- !!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
+ !!(
+ window.SpeechRecognition ??
+ (window as unknown as { webkitSpeechRecognition?: unknown })
+ .webkitSpeechRecognition
+ );
return { isDictating, start, stop, supported };
}
@@ -287,9 +303,16 @@ export function RegisterCompareHandle({
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
- aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
+ aui
+ .thread()
+ .append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) =>
- aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
+ aui.thread().append({
+ role: "user",
+ content,
+ createdAt: new Date(),
+ startRun: false,
+ } as never),
startRun: () => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
@@ -327,13 +350,10 @@ function PendingImageThumb({
file: File;
onRemove: () => void;
}): ReactElement {
- const [src, setSrc] = useState(null);
+ const src = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => {
- const url = URL.createObjectURL(file);
- setSrc(url);
- return () => URL.revokeObjectURL(url);
- }, [file]);
- if (!src) return ;
+ return () => URL.revokeObjectURL(src);
+ }, [src]);
return (

@@ -383,7 +403,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState
([]);
- const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
+ const [pendingAudio, setPendingAudio] = useState<{
+ name: string;
+ base64: string;
+ } | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef(null);
@@ -412,10 +435,16 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
- const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
- const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
+ const supportsReasoningOff = useChatRuntimeStore(
+ (s) => s.supportsReasoningOff,
+ );
+ const reasoningEffortLevels = useChatRuntimeStore(
+ (s) => s.reasoningEffortLevels,
+ );
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
- const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
+ const supportsPreserveThinking = useChatRuntimeStore(
+ (s) => s.supportsPreserveThinking,
+ );
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@@ -510,7 +539,8 @@ export function SharedComposer({
effectiveSupportsReasoning || effectiveReasoningAlwaysOn;
const isEffort = effectiveReasoningStyle === "reasoning_effort";
const thinkingActiveLook = isEffort
- ? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled)
+ ? reasoningLockedOn ||
+ (effectiveReasoningVisualEnabled && !reasoningDisabled)
: reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled);
// Two-pill gating: Search pill lights up when the runtime has either
// a local tool runtime (supportsTools, gives us our Code/python + local
@@ -540,7 +570,7 @@ export function SharedComposer({
// allowance, so we only disable Code unconditionally in Gemini
// image mode.
const isExternalGemini = selectedExternalProvider?.providerType === "gemini";
- const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
+ const imageDisabled = modelLoaded && !supportsBuiltinImageGeneration;
const imageModeDisablesCode =
isExternalGemini && imageToolsEnabled && !imageDisabled;
// Image-tier Gemini models always reject codeExecution and reject
@@ -549,8 +579,7 @@ export function SharedComposer({
// runtime flag re-enable a pill the Gemini backend will silently
// drop. Detect "external provider is Gemini AND model is image-tier"
// and gate strictly on the provider builtin support.
- const isGeminiImageTier =
- isExternalGemini && supportsBuiltinImageGeneration;
+ const isGeminiImageTier = isExternalGemini && supportsBuiltinImageGeneration;
// Disable only when a loaded model lacks the capability; with no model the
// tool can still be pre-selected and reflected, matching the + menu.
const searchDisabled =
@@ -564,22 +593,24 @@ export function SharedComposer({
? true
: !(supportsTools || supportsBuiltinCodeExecution))) ||
imageModeDisablesCode;
- // Images pill is only ever lit on OpenAI cloud's Responses-API models
- // and Gemini Nano Banana family. No local tool runtime fallback.
- const showImagePill = supportsBuiltinImageGeneration;
+ // Images pill is only visible when selected. Its menu item appears when the
+ // provider supports it or when a persisted selected state needs to be shown/toggled.
+ const showImageMenuItem = supportsBuiltinImageGeneration || imageToolsEnabled;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
- const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
- const showWebFetchPill = supportsBuiltinWebFetch;
- // Backwards-compatible alias for any other call site that may still
- // reference `toolsDisabled` (rare; both pills used it before).
- const toolsDisabled = codeDisabled;
+ const webFetchDisabled = modelLoaded && !supportsBuiltinWebFetch;
+ const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
- const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
-
- const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
- setText,
+ const clearPendingAudioStore = useChatRuntimeStore(
+ (s) => s.clearPendingAudio,
);
+ const {
+ isDictating,
+ start: startDictation,
+ stop: stopDictation,
+ supported: dictationSupported,
+ } = useDictation(setText);
+
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
@@ -596,15 +627,17 @@ export function SharedComposer({
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
- const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
- const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
+ const paddingY =
+ parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
+ const borderY =
+ parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
- const addFiles = useCallback((files: FileList | null) => {
+ function addFiles(files: FileList | null) {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
@@ -632,7 +665,7 @@ export function SharedComposer({
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
- }, [setPendingAudioStore, attachUnavailableReason]);
+ }
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
@@ -690,12 +723,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
- description: "Use the model dropdown above each pane, then send your prompt.",
+ description:
+ "Use the model dropdown above each pane, then send your prompt.",
});
return;
}
- if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
+ if (
+ pendingImages.length > 0 &&
+ !isGeneralizedCompare &&
+ imageUnavailableReason
+ ) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
@@ -733,8 +771,9 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
- const effectiveChatTemplateOverride =
- chatTemplateOverride?.trim() ? chatTemplateOverride : null;
+ const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
+ ? chatTemplateOverride
+ : null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@@ -742,11 +781,14 @@ export function SharedComposer({
}
// Helper: load a model and update store checkpoint
- async function ensureModelLoaded(sel: CompareModelSelection): Promise {
+ async function ensureModelLoaded(
+ sel: CompareModelSelection,
+ ): Promise {
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
- (currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
+ (currentStore.activeGgufVariant ?? null) ===
+ (sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
@@ -836,9 +878,17 @@ export function SharedComposer({
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
- toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
+ toast("Loading Model 1…", {
+ id: toastId,
+ description: name1,
+ duration: Infinity,
+ });
const status1 = await ensureModelLoaded(model1);
- toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
+ toast("Generating with Model 1…", {
+ id: toastId,
+ description: `${name1} (${status1})`,
+ duration: Infinity,
+ });
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
@@ -846,13 +896,22 @@ export function SharedComposer({
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
- const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
- || (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
+ const needsLoad =
+ model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
+ (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
- toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
+ toast("Loading Model 2…", {
+ id: toastId,
+ description: name2,
+ duration: Infinity,
+ });
}
const status2 = await ensureModelLoaded(model2);
- toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
+ toast("Generating with Model 2…", {
+ id: toastId,
+ description: `${name2} (${status2})`,
+ duration: Infinity,
+ });
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
@@ -906,7 +965,9 @@ export function SharedComposer({
}
}
- const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
+ const hasComposerContent =
+ text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null;
+ const canSend = hasComposerContent && !busy && !isComposing;
return (
{pendingAudio.name}
setToolsEnabled(!toolsEnabled)}
>
@@ -1074,6 +1140,38 @@ export function SharedComposer({
Code
{codeToolsEnabled ? : null}
+ {showImageMenuItem ? (
+
setImageToolsEnabled(!imageToolsEnabled)}
+ >
+
+ Images
+ {imageToolsEnabled ? : null}
+
+ ) : null}
+ {showFetchMenuItem ? (
+
+ setWebFetchToolsEnabled(!webFetchToolsEnabled)
+ }
+ >
+
+ Fetch
+ {webFetchToolsEnabled ? (
+
+ ) : null}
+
+ ) : null}
setSettingsPanelOpen(true)}>
MCP
@@ -1123,38 +1221,50 @@ export function SharedComposer({
-
+ ) : null}
+ {codeToolsEnabled ? (
+ setCodeToolsEnabled(!codeToolsEnabled)}
+ className="composer-pill-btn"
+ data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
+ aria-label={
+ codeToolsEnabled
+ ? "Disable code execution"
+ : "Enable code execution"
+ }
+ >
+
+ Code
+
+
+ ) : null}
{/* Active in compare mode; click to exit back to single chat. */}
Compare
+
- {showImagePill && (
+ {imageToolsEnabled ? (
setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
- data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
+ data-active={
+ imageToolsEnabled && !imageDisabled ? "true" : "false"
+ }
aria-label={
- imageToolsEnabled ? "Disable image generation" : "Enable image generation"
+ imageToolsEnabled
+ ? "Disable image generation"
+ : "Enable image generation"
}
>
Images
+
- )}
- {showWebFetchPill && (
+ ) : null}
+ {webFetchToolsEnabled ? (
Fetch
+
- )}
+ ) : null}
{showReasoningControl ? (
@@ -1392,7 +1509,7 @@ export function SharedComposer({
onClick={startDictation}
aria-label="Dictate"
>
-
+
) : (
- ) : (
+ ) : hasComposerContent ? (
-
+
- )}
+ ) : null}