feat(chat): align compare composer

This commit is contained in:
wasimysaid 2026-05-30 20:21:21 +02:00
commit b76d90d1b3
2 changed files with 264 additions and 146 deletions

View file

@ -314,7 +314,7 @@ function CompareShell({
{children}
</div>
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
<div className="mx-auto w-full max-w-[660px]">{composer}</div>
<p className="composer-footer-note">
LLMs can make mistakes. Double-check responses.
</p>
@ -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({
<CompareShell
handlesRef={handlesRef}
composer={
<SharedComposer
handlesRef={handlesRef}
onExitCompare={onExitCompare}
/>
<SharedComposer handlesRef={handlesRef} onExitCompare={onExitCompare} />
}
>
<>
@ -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) {

View file

@ -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<string> {
});
}
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<string | null>(null);
const src = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
return () => URL.revokeObjectURL(src);
}, [src]);
return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
<img src={src} alt={file.name} className="h-full w-full object-cover" />
@ -383,7 +403,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
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<HTMLTextAreaElement>(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<string> {
async function ensureModelLoaded(
sel: CompareModelSelection,
): Promise<string> {
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 (
<div
@ -941,7 +1002,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
onClick={() => {
setPendingAudio(null);
clearPendingAudioStore();
}}
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio"
>
@ -1014,7 +1078,7 @@ export function SharedComposer({
aria-label="Tools and attachments"
className="unsloth-composer-plus"
>
<PlusIcon className="size-[22px] stroke-[1.75px]" />
<PlusIcon className="size-6 stroke-[1.75px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -1057,7 +1121,9 @@ export function SharedComposer({
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className={toolsEnabled ? "text-primary font-medium" : undefined}
className={
toolsEnabled ? "text-primary font-medium" : undefined
}
onSelect={() => setToolsEnabled(!toolsEnabled)}
>
<GlobeIcon />
@ -1074,6 +1140,38 @@ export function SharedComposer({
Code
{codeToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
{showImageMenuItem ? (
<DropdownMenuItem
disabled={imageDisabled}
className={
imageToolsEnabled ? "text-primary font-medium" : undefined
}
onSelect={() => setImageToolsEnabled(!imageToolsEnabled)}
>
<HugeiconsIcon icon={Image03Icon} strokeWidth={2} />
Images
{imageToolsEnabled ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
) : null}
{showFetchMenuItem ? (
<DropdownMenuItem
disabled={webFetchDisabled}
className={
webFetchToolsEnabled
? "text-primary font-medium"
: undefined
}
onSelect={() =>
setWebFetchToolsEnabled(!webFetchToolsEnabled)
}
>
<DownloadIcon />
Fetch
{webFetchToolsEnabled ? (
<CheckIcon className="ml-auto" />
) : null}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => setSettingsPanelOpen(true)}>
<PlugIcon />
MCP
@ -1123,38 +1221,50 @@ export function SharedComposer({
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<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);
{toolsEnabled ? (
<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);
}
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
</button>
<button
type="button"
disabled={codeDisabled}
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
</button>
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
<XIcon className="composer-pill-close" aria-hidden={true} />
</button>
) : null}
{codeToolsEnabled ? (
<button
type="button"
disabled={codeDisabled}
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={
codeToolsEnabled
? "Disable code execution"
: "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
<XIcon className="composer-pill-close" aria-hidden={true} />
</button>
) : null}
{/* Active in compare mode; click to exit back to single chat. */}
<button
type="button"
@ -1165,16 +1275,21 @@ export function SharedComposer({
>
<Columns2Icon className="size-3.5" />
<span>Compare</span>
<XIcon className="composer-pill-close" aria-hidden={true} />
</button>
{showImagePill && (
{imageToolsEnabled ? (
<button
type="button"
disabled={imageDisabled}
onClick={() => 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"
}
>
<HugeiconsIcon
@ -1183,9 +1298,10 @@ export function SharedComposer({
strokeWidth={2}
/>
<span>Images</span>
<XIcon className="composer-pill-close" aria-hidden={true} />
</button>
)}
{showWebFetchPill && (
) : null}
{webFetchToolsEnabled ? (
<button
type="button"
disabled={webFetchDisabled}
@ -1200,8 +1316,9 @@ export function SharedComposer({
>
<DownloadIcon className="size-3.5" />
<span>Fetch</span>
<XIcon className="composer-pill-close" aria-hidden={true} />
</button>
)}
) : null}
</div>
<div className="ml-auto flex items-center gap-1">
{showReasoningControl ? (
@ -1392,7 +1509,7 @@ export function SharedComposer({
onClick={startDictation}
aria-label="Dictate"
>
<MicIcon className="size-4" />
<MicIcon className="size-5" />
</TooltipIconButton>
) : (
<TooltipIconButton
@ -1419,20 +1536,20 @@ export function SharedComposer({
>
<SquareIcon className="size-3 fill-current" />
</Button>
) : (
) : hasComposerContent ? (
<TooltipIconButton
tooltip="Send message"
side="bottom"
variant="default"
size="icon"
className="size-8 rounded-full"
className="size-8 rounded-full disabled:bg-transparent disabled:text-foreground/40 disabled:opacity-100 disabled:pointer-events-none"
onClick={send}
disabled={!canSend}
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
<ArrowUpIcon className="size-6 stroke-[1.75px]" />
</TooltipIconButton>
)}
) : null}
</div>
</div>
</div>