unsloth/studio/frontend/src/features/chat/shared-composer.tsx

1597 lines
60 KiB
TypeScript

// 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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import {
thinkEffortAriaLabel,
thinkToggleAriaLabel,
} from "@/components/assistant-ui/think-aria-label";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
BookOpenIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
LightbulbOffIcon,
MicIcon,
PlusIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import { Image03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cancelJob, subscribeToJobEvents } from "@/features/rag/api/rag-api";
import { useIndexProgressStore } from "@/features/rag/stores/index-progress-store";
import { useRagStore } from "@/features/rag/stores/rag-store";
import { acquireIndexSlot, releaseIndexSlot } from "./utils/rag-index-queue";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebFetch,
} from "./provider-capabilities";
import {
type CompositionEvent,
type KeyboardEvent,
type MutableRefObject,
type ReactElement,
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
export type CompareMessagePart =
| { type: "text"; text: string }
| { type: "image"; image: string }
| { type: "audio"; audio: string };
export interface CompareHandle {
append: (content: CompareMessagePart[]) => void;
/** Append a user message without triggering generation. */
appendMessage: (content: CompareMessagePart[]) => void;
/** Trigger generation on the current thread (after appendMessage). */
startRun: () => void;
cancel: () => void;
isRunning: () => boolean;
/** Returns a promise that resolves when the current or next run finishes. */
waitForRunEnd: () => Promise<void>;
}
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
const DOCUMENT_ACCEPT = ".pdf,.txt,.md,.markdown,.docx,.html,.htm";
const DOCUMENT_EXTENSIONS = new Set([
".pdf",
".txt",
".md",
".markdown",
".docx",
".html",
".htm",
]);
function isDocumentFile(file: File): boolean {
const lower = file.name.toLowerCase();
const dot = lower.lastIndexOf(".");
if (dot < 0) return false;
return DOCUMENT_EXTENSIONS.has(lower.slice(dot));
}
type PendingDoc = {
id: string;
file: File;
status: "uploading" | "ingesting" | "ready" | "error";
jobId?: string;
documentId?: string;
errorMessage?: string;
};
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
// IME commit, so the compose flag would otherwise stay true forever.
const IME_STUCK_TIMEOUT_MS = 2500;
function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("Failed to read image file"));
reader.readAsDataURL(file);
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (
normalized.startsWith("claude-opus-4-6") ||
normalized.startsWith("claude-sonnet-4-6")
) {
return "Max";
}
return "Extra High";
}
return level.charAt(0).toUpperCase() + level.slice(1);
}
function formatReasoningDisabledLabel(
supportsReasoningOff: boolean,
isExternalOpenAIReasoning: boolean,
modelId?: string,
): string {
const normalized = modelId?.trim().toLowerCase() ?? "";
// Magistral keeps the "none" wire value, but UX should present this floor
// as "Medium" rather than a disabled state label.
if (normalized.includes("magistral-medium-latest")) return "Medium";
return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off";
}
function useDictation(
setText: (value: string | ((prev: string) => string)) => void,
) {
const [isDictating, setIsDictating] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
const recognition = new SpeechRecognitionAPI() as SpeechRecognition;
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "en-US";
recognition.onresult = (event: SpeechRecognitionEvent) => {
const last = event.resultIndex;
const result = event.results[last];
if (!result?.isFinal) return;
const transcript = result[0]?.transcript?.trim();
if (transcript) {
setText((prev) => (prev ? `${prev} ${transcript}` : transcript));
}
};
recognition.onerror = () => {
setIsDictating(false);
};
recognition.onend = () => {
setIsDictating(false);
};
recognition.start();
recognitionRef.current = recognition;
setIsDictating(true);
}, [setText]);
const stop = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current = null;
}
setIsDictating(false);
}, []);
useEffect(() => {
return () => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
const supported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
return { isDictating, start, stop, supported };
}
export type CompareHandles = MutableRefObject<Record<string, CompareHandle>>;
const CompareHandlesContext = createContext<CompareHandles | null>(null);
export function CompareHandlesProvider({
handlesRef,
children,
}: {
handlesRef: CompareHandles;
children: ReactNode;
}): ReactElement {
return (
<CompareHandlesContext.Provider value={handlesRef}>
{children}
</CompareHandlesContext.Provider>
);
}
export function RegisterCompareHandle({
name,
}: {
name: string;
}): ReactElement | null {
const handlesRef = useContext(CompareHandlesContext);
const aui = useAui();
useEffect(() => {
if (!handlesRef) {
return;
}
const currentHandles = handlesRef.current;
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
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),
startRun: () => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
aui.thread().startRun({ parentId: lastId });
},
cancel: () => aui.thread().cancelRun(),
isRunning: () => aui.thread().getState().isRunning,
waitForRunEnd: () =>
new Promise<void>((resolve) => {
let wasRunning = false;
const unsub = useChatRuntimeStore.subscribe((state) => {
const anyRunning = Object.keys(state.runningByThreadId).length > 0;
if (anyRunning) wasRunning = true;
if (wasRunning && !anyRunning) {
unsub();
resolve();
}
});
}),
};
return () => {
delete currentHandles[name];
};
}, [handlesRef, name, aui]);
return null;
}
type PendingImage = { id: string; file: File };
function PendingImageThumb({
file,
onRemove,
}: {
file: File;
onRemove: () => void;
}): ReactElement {
const [src, setSrc] = useState<string | null>(null);
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 (
<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" />
<button
type="button"
onClick={onRemove}
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-white text-muted-foreground shadow-sm hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove attachment"
>
<XIcon className="size-3" />
</button>
</div>
);
}
type CompareModelSelection = {
id: string;
isLora: boolean;
ggufVariant?: string;
};
export function SharedComposer({
handlesRef,
model1,
model2,
}: {
handlesRef: CompareHandles;
model1?: CompareModelSelection;
model2?: CompareModelSelection;
}): ReactElement {
const [text, setText] = useState("");
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 [pendingDocs, setPendingDocs] = useState<PendingDoc[]>([]);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
const activeModel = useChatRuntimeStore((s) => {
const checkpoint = s.params.checkpoint;
return s.models.find((m) => m.id === checkpoint);
});
const aui = useAui();
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const connectionsEnabled = useExternalProvidersStore(
(s) => s.connectionsEnabled,
);
const externalProvidersAll = useExternalProvidersStore((s) => s.providers);
const externalProviders = connectionsEnabled ? externalProvidersAll : [];
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const loadedIsMultimodal = useChatRuntimeStore((s) => s.loadedIsMultimodal);
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
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 setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const supportsBuiltinWebSearch = useChatRuntimeStore(
(s) => s.supportsBuiltinWebSearch,
);
const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled);
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
const setRagToolEnabled = useChatRuntimeStore((s) => s.setRagToolEnabled);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
const setWebFetchToolsEnabled = useChatRuntimeStore(
(s) => s.setWebFetchToolsEnabled,
);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
const externalSelection = parseExternalModelId(checkpoint);
const isExternalModel = externalSelection !== null;
const selectedExternalProvider =
externalSelection != null
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const imageUnavailableReason = getImageInputUnavailableReason({
activeModel,
isExternalModel,
externalSupportsVision: providerTypeSupportsVision(
selectedExternalProvider?.providerType,
),
externalModelLabel: externalSelection?.modelId ?? null,
loadedIsMultimodal,
modelLoaded,
});
const isCompareMode = Boolean(model1?.id || model2?.id);
// Attach-time gate. Compare mode defers to send: the catalog can lag
// behind a model's real capabilities (e.g., a GGUF whose mmproj
// arrives after the catalog snapshot), and we only sync the models[]
// entry after ensureModelLoaded runs at send time. Single mode uses
// the loaded model's runtime capability.
const attachUnavailableReason = isCompareMode ? null : imageUnavailableReason;
const effectiveExternalModelId =
selectedExternalProvider?.providerType === "openrouter" &&
externalSelection?.modelId === "openrouter/free" &&
lastOpenRouterChosenModel
? lastOpenRouterChosenModel
: externalSelection?.modelId;
const externalReasoningCaps =
externalSelection != null
? getExternalReasoningCapabilities(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
{
isReasoningProvider:
selectedExternalProvider?.isReasoningModel === true,
baseUrl: selectedExternalProvider?.baseUrl ?? null,
},
)
: null;
const isExternalOpenAIReasoning =
externalReasoningCaps?.supportsReasoning === true &&
externalReasoningCaps.reasoningStyle === "reasoning_effort";
const effectiveReasoningStyle =
externalReasoningCaps?.reasoningStyle ?? reasoningStyle;
const effectiveReasoningAlwaysOn =
externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn;
const effectiveSupportsReasoningOff =
externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff;
const effectiveReasoningEffortLevels =
externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels;
const effectiveSupportsReasoning =
externalReasoningCaps?.supportsReasoning ?? supportsReasoning;
const reasoningLockedOn =
effectiveSupportsReasoning &&
(effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff);
// Kimi's $web_search builtin mandates thinking=disabled per the docs at
// https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay
// clickable for Kimi, but turning one on flips the other off — the
// click handlers below enforce this mutual exclusion so the visible
// state always matches what the backend actually sends.
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
const effectiveReasoningVisualEnabled =
effectiveReasoningEnabled && reasoningEffort !== "none";
const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning;
const showReasoningControl =
effectiveSupportsReasoning || effectiveReasoningAlwaysOn;
// Two-pill gating: Search pill lights up when the runtime has either
// a local tool runtime (supportsTools, gives us our Code/python + local
// web_search) OR a server-side web_search the provider runs for us
// (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter
// / Kimi). Code pill lights up on the local runtime OR when Anthropic
// is selected with a model that accepts the server-side
// code_execution_20250825 tool — see
// providerSupportsBuiltinCodeExecution. Anthropic is the only external
// provider that ships a code-execution tool today.
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
selectedExternalProvider?.baseUrl,
);
const supportsBuiltinImageGeneration = providerSupportsBuiltinImageGeneration(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
selectedExternalProvider?.baseUrl,
);
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
selectedExternalProvider?.providerType,
);
// Gemini rejects codeExecution alongside image modalities. Search is
// blocked on older Gemini image ids but allowed on Gemini 3 image
// models -- supportsBuiltinWebSearch already encodes the per-model
// allowance, so we only disable Code unconditionally in Gemini
// image mode.
const isExternalGemini = selectedExternalProvider?.providerType === "gemini";
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
const imageModeDisablesCode =
isExternalGemini && imageToolsEnabled && !imageDisabled;
// Image-tier Gemini models always reject codeExecution and reject
// web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded
// in supportsBuiltinWebSearch). Don't let the local `supportsTools`
// 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 searchDisabled =
!modelLoaded ||
(isGeminiImageTier
? !supportsBuiltinWebSearch
: !(supportsTools || supportsBuiltinWebSearch));
const codeDisabled =
!modelLoaded ||
(isGeminiImageTier
? 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;
// Local models run RAG through the search_knowledge_base tool loop, so
// they need tool-calling. External providers use the prefetch path
// (studio retrieves + injects, no tool loop), so RAG is allowed for them
// regardless of the local supportsTools flag.
const ragDisabled =
!modelLoaded || (!supportsTools && !isExternalModel);
// 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 setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
);
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
const any = Object.values(handles).some((h) => h.isRunning());
setRunning(any);
}, 200);
return () => clearInterval(id);
}, [handlesRef]);
// Auto-expand textarea up to 6 rows, then scroll (matches regular chat composer).
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
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 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 ensureThreadId = useCallback(async (): Promise<string | null> => {
const stored = useChatRuntimeStore.getState().activeThreadId;
if (stored) return stored;
try {
const runtime = aui.threads().__internal_getAssistantRuntime?.();
if (!runtime) return null;
const localId = runtime.threads.getState().mainThreadId;
if (!localId) return null;
const { remoteId } = await runtime.threads
.getItemById(localId)
.initialize();
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
return remoteId;
} catch {
return null;
}
}, [aui]);
// Composer "+" upload — routes to whichever scope the Retrieval
// dropdown currently points at (KB or thread). Keeps "what you see is
// what you upload to" so users don't get silent thread-vs-KB mismatches.
const addDoc = useCallback(
(file: File) => {
const localChipId = crypto.randomUUID();
// Lifecycle state shared between the upload flow and the cancel thunk;
// the thunk closes over these `let`s so it sees the latest ids whenever
// the user cancels.
const abort = new AbortController();
let jobId: string | undefined;
let documentId: string | undefined;
let scopeKey: string | null = null;
let unsubscribe: (() => void) | undefined;
let slotAcquired = false;
let slotReleased = false;
let cleaned = false;
const releaseSlot = () => {
if (slotAcquired && !slotReleased) {
slotReleased = true;
releaseIndexSlot();
}
};
const removeChip = () => {
setPendingDocs((prev) => prev.filter((d) => d.id !== localChipId));
};
const cleanupBackend = async () => {
if (cleaned) return;
cleaned = true;
if (jobId) await cancelJob(jobId);
if (documentId && scopeKey) {
try {
await useRagStore.getState().deleteDocument(documentId, scopeKey);
} catch {}
}
};
setPendingDocs((prev) => [
...prev,
{ id: localChipId, file, status: "uploading" },
]);
// Register in the aggregate-progress store now (whole batch) so the
// single toast counts queued files too.
const indexProgress = useIndexProgressStore.getState();
indexProgress.add(localChipId, file.name);
indexProgress.setCancel(localChipId, async () => {
abort.abort();
unsubscribe?.();
releaseSlot();
await cleanupBackend();
removeChip();
});
void (async () => {
// Hold an indexing slot for the document's whole lifecycle so bulk /
// folder uploads drain at the configured concurrency. Released on
// every terminal path below.
await acquireIndexSlot();
slotAcquired = true;
if (abort.signal.aborted) {
releaseSlot();
return;
}
indexProgress.setIndexing(localChipId);
const ragSource = useChatRuntimeStore.getState().ragSource;
let scope:
| { kind: "kb"; kbId: string }
| { kind: "thread"; threadId: string }
| null = null;
if (ragSource.kind === "kb") {
scope = { kind: "kb", kbId: ragSource.kbId };
scopeKey = `kb:${ragSource.kbId}`;
} else {
const threadId = await ensureThreadId();
if (abort.signal.aborted) {
releaseSlot();
return;
}
if (!threadId) {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? {
...d,
status: "error",
errorMessage: "Could not create thread for upload",
}
: d,
),
);
toast.error("Could not create thread for upload");
indexProgress.setError(localChipId);
releaseSlot();
return;
}
scope = { kind: "thread", threadId };
scopeKey = `thread:${threadId}`;
}
const uploadDocument = useRagStore.getState().uploadDocument;
try {
const {
documentId: did,
jobId: jid,
alreadyIndexed,
} = await uploadDocument(scope, file);
documentId = did;
jobId = jid;
if (abort.signal.aborted) {
// Cancelled while uploading: the document now exists on the
// backend, so tear it down here.
releaseSlot();
await cleanupBackend();
removeChip();
return;
}
if (alreadyIndexed) {
// Drop the just-added chip if this doc is already represented
// so the composer never shows the same document twice.
setPendingDocs((prev) => {
const dupExists = prev.some(
(d) => d.id !== localChipId && d.documentId === did,
);
if (dupExists) {
return prev.filter((d) => d.id !== localChipId);
}
return prev.map((d) =>
d.id === localChipId
? { ...d, status: "ready", documentId: did }
: d,
);
});
toast.info(`${file.name} is already indexed`);
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore.getState().setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId);
releaseSlot();
return;
}
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "ingesting", jobId: jid, documentId: did }
: d,
),
);
unsubscribe = subscribeToJobEvents(jid, {
onEvent: (event) => {
if (event.type === "progress") {
indexProgress.setProgress(localChipId, event.progress);
} else if (event.type === "complete") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId ? { ...d, status: "ready" } : d,
),
);
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore
.getState()
.setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId, event.num_chunks);
releaseSlot();
} else if (event.type === "cancelled") {
releaseSlot();
} else if (event.type === "error") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: event.error }
: d,
),
);
indexProgress.setError(localChipId);
releaseSlot();
}
},
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Upload failed";
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: message }
: d,
),
);
toast.error(`Document upload failed: ${message}`);
indexProgress.setError(localChipId);
releaseSlot();
}
})();
},
[ensureThreadId],
);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
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);
});
continue;
}
if (isDocumentFile(file)) {
addDoc(file);
continue;
}
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason, addDoc]);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
const removePendingDoc = useCallback((id: string) => {
setPendingDocs((prev) => {
const doc = prev.find((d) => d.id === id);
if (doc?.documentId) {
void useRagStore
.getState()
.deleteDocument(
doc.documentId,
`thread:${activeThreadId ?? ""}`,
)
.catch(() => {});
}
return prev.filter((d) => d.id !== id);
});
}, [activeThreadId]);
function clearStuckImeTimer() {
if (stuckImeTimerRef.current) {
clearTimeout(stuckImeTimerRef.current);
stuckImeTimerRef.current = null;
}
}
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
clearStuckImeTimer();
if (next) {
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
}
function refreshStuckImeTimer() {
if (!composingRef.current) {
return;
}
clearStuckImeTimer();
stuckImeTimerRef.current = setTimeout(() => {
stuckImeTimerRef.current = null;
composingRef.current = false;
setIsComposing(false);
}, IME_STUCK_TIMEOUT_MS);
}
useEffect(() => () => clearStuckImeTimer(), []);
async function send() {
if (composingRef.current) return;
const msg = text.trim();
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
const hasCompareHandles = Boolean(
handlesRef.current["model1"] || handlesRef.current["model2"],
);
const isGeneralizedCompare =
hasCompareHandles && Boolean(model1?.id && model2?.id);
// Generalized compare requires both panes to have a model. A
// half-selected send either races to an empty bubble with bogus
// tok/s (#5569) or leaves the empty pane with a dangling prompt.
// hasCompareHandles is true only in GeneralCompareContent, so
// 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.",
});
return;
}
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
// pre-stream gate runs per-side against that fresh state.
toast.error(imageUnavailableReason);
return;
}
const content: CompareMessagePart[] = [];
for (const { file } of pendingImages) {
try {
const image = await fileToBase64DataURL(file);
content.push({ type: "image", image });
} catch {
// skip failed image
}
}
if (pendingAudio) {
content.push({ type: "audio", audio: pendingAudio.base64 });
}
if (msg) {
content.push({ type: "text", text: msg });
}
if (content.length === 0) return;
setText("");
setPendingImages([]);
setPendingAudio(null);
clearPendingAudioStore();
// Docs stay in backend; drop chips only.
setPendingDocs([]);
textareaRef.current?.focus();
// Generalized compare: load each model before dispatching to its side
if (isGeneralizedCompare) {
const store = useChatRuntimeStore.getState();
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
return parts[parts.length - 1] || id;
}
// Helper: load a model and update store checkpoint
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
hf_token: currentStore.hfToken || null,
max_seq_length: maxSeqLength,
load_in_4bit: true,
is_lora: sel.isLora,
gguf_variant: sel.ggufVariant ?? null,
trust_remote_code: trustRemoteCode,
chat_template_override: effectiveChatTemplateOverride,
});
if (validation.requires_trust_remote_code && !trustRemoteCode) {
throw new Error(
`${modelDisplayName(sel.id)} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`,
);
}
}
const resp = await loadModel({
model_path: sel.id,
hf_token: useChatRuntimeStore.getState().hfToken || null,
max_seq_length: maxSeqLength,
load_in_4bit: true,
is_lora: sel.isLora,
gguf_variant: sel.ggufVariant ?? null,
trust_remote_code: trustRemoteCode,
chat_template_override: effectiveChatTemplateOverride,
});
const store = useChatRuntimeStore.getState();
store.setCheckpoint(
resp.model,
resp.is_gguf ? (sel.ggufVariant ?? undefined) : null,
);
store.setModelRequiresTrustRemoteCode(
resp.requires_trust_remote_code ?? false,
);
useChatRuntimeStore.setState({
supportsReasoning: resp.supports_reasoning ?? false,
reasoningAlwaysOn: resp.reasoning_always_on ?? false,
reasoningStyle: resp.reasoning_style ?? "enable_thinking",
supportsPreserveThinking: resp.supports_preserve_thinking ?? false,
supportsTools: resp.supports_tools ?? false,
loadedIsMultimodal: isMultimodalResponse(resp),
});
// Sync the models[] entry with the load response so the
// attach/send gates read fresh capabilities. /api/models/list
// can lag behind a model's actual state (e.g., a GGUF whose
// mmproj was downloaded after the catalog snapshot).
const currentModels = useChatRuntimeStore.getState().models;
const idx = currentModels.findIndex((m) => m.id === sel.id);
const synced = {
isVision: Boolean(resp.is_vision),
isGguf: Boolean(resp.is_gguf),
isAudio: Boolean(resp.is_audio),
audioType: resp.audio_type ?? null,
hasAudioInput: Boolean(resp.has_audio_input),
};
if (idx === -1) {
store.setModels([
...currentModels,
{
id: sel.id,
name: resp.display_name ?? sel.id,
isLora: sel.isLora,
...synced,
},
]);
} else {
const next = [...currentModels];
next[idx] = { ...next[idx], ...synced };
store.setModels(next);
}
return resp.status;
}
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 });
setComparing(true);
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
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 });
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
}
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
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 });
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
}
toast.success("Compare complete", { id: toastId, duration: 2000 });
} catch (err) {
toast.error("Compare failed", {
id: toastId,
description: err instanceof Error ? err.message : "Unknown error",
duration: 4000,
});
} finally {
setComparing(false);
}
} else {
// Original behavior: fire all handles simultaneously
for (const handle of Object.values(handlesRef.current)) {
handle.append(content);
}
}
}
function stop() {
if (isDictating) stopDictation();
for (const handle of Object.values(handlesRef.current)) {
handle.cancel();
}
}
const busy = running || comparing;
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
// watchdog (#5546) cleared it during a long candidate-window pause; this
// keeps a follow-up click-Send from submitting preedit text. Re-arm the
// watchdog on the same path — without it the WSL+Chrome no-compositionend
// case would leave composingRef pinned forever after an IME keypress and
// re-lock Send.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckImeTimer();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
send();
}
}
}
const docsIndexing = pendingDocs.some(
(d) => d.status === "uploading" || d.status === "ingesting",
);
const canSend =
(text.trim().length > 0 ||
pendingImages.length > 0 ||
pendingAudio !== null ||
pendingDocs.length > 0) &&
!busy &&
!isComposing &&
!docsIndexing;
return (
<div
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
onDragOver={(e) => {
if (isTauri) return;
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
// Phase 1 native model drops own Tauri local-path drops. Restore browser
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
if (isTauri) return;
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}}
>
{(pendingImages.length > 0 ||
pendingAudio ||
pendingDocs.length > 0) && (
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
{pendingImages.map(({ id, file }) => (
<PendingImageThumb
key={id}
file={file}
onRemove={() => removePendingImage(id)}
/>
))}
{pendingDocs.map((doc) => {
const statusLabel =
doc.status === "uploading"
? "Uploading…"
: doc.status === "ingesting"
? "Indexing…"
: doc.status === "error"
? doc.errorMessage ?? "Failed"
: "Ready";
const statusClass =
doc.status === "error"
? "text-destructive"
: doc.status === "ready"
? "text-muted-foreground"
: "text-muted-foreground italic";
return (
<div
key={doc.id}
className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs"
>
<FileTextIcon className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="max-w-48 truncate">{doc.file.name}</span>
<span className={cn("text-[10px] leading-tight", statusClass)}>
{statusLabel}
</span>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => removePendingDoc(doc.id)}
aria-label="Remove document"
>
<XIcon className="size-3.5" />
</button>
</div>
);
})}
{pendingAudio && (
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
<HeadphonesIcon className="size-3.5 text-muted-foreground" />
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
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"
>
<XIcon className="size-3" />
</button>
</div>
)}
</div>
)}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => {
// ALWAYS mirror the DOM value into React state, even during IME
// composition. The controlled `value` prop must match the DOM at
// all times, otherwise any unrelated parent re-render reconciles
// the textarea back to the stored value mid-composition — wiping
// the IME preedit AND prior committed text (e.g. Tab cycling
// candidates erases earlier words). Issue #5318.
setCompositionState(isNativeComposing(e.nativeEvent));
setText(e.target.value);
}}
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);
}}
onKeyDown={onKeyDown}
placeholder="Send to both models..."
className="composer-input"
rows={1}
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
// from the first strong character; no effect on LTR scripts.
dir="auto"
/>
<div className="composer-action-wrapper">
<div className="flex items-center gap-0.5">
<input
ref={fileInputRef}
type="file"
accept={`${IMAGE_ACCEPT},${DOCUMENT_ACCEPT}`}
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30"
onClick={() => {
// The picker accepts both image and audio. Don't gate the
// button on image-availability — addFiles still filters
// image files per-file when the loaded model can't take
// them, while audio attach always works.
fileInputRef.current?.click();
}}
aria-label="Add Attachment"
>
<PlusIcon className="size-5 stroke-[1.5px]" />
</TooltipIconButton>
{activeModel?.hasAudioInput && (
<>
<input
ref={audioInputRef}
type="file"
accept={AUDIO_ACCEPT}
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
<TooltipIconButton
tooltip="Upload audio"
side="bottom"
variant="ghost"
size="icon"
className="size-8.5 rounded-full p-1 text-muted-foreground hover:bg-muted-foreground/15"
onClick={() => audioInputRef.current?.click()}
aria-label="Upload audio"
>
<HeadphonesIcon className="size-4.5 stroke-[1.5px]" />
</TooltipIconButton>
</>
)}
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)
) : null}
{supportsPreserveThinking && (
<button
type="button"
disabled={!modelLoaded}
onClick={() => setPreserveThinking(!preserveThinking)}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
!modelLoaded
? "cursor-not-allowed opacity-40"
: preserveThinking
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
}
>
{preserveThinking && modelLoaded ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Preserve Think</span>
</button>
)}
<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 comes on, and
// back on when Search goes off — mutual exclusion that
// mirrors what the backend enforces.
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"}
>
<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>
{showImagePill && (
<button
type="button"
disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
}
>
<HugeiconsIcon
icon={Image03Icon}
className="size-3.5"
strokeWidth={2}
/>
<span>Images</span>
</button>
)}
{/* Master RAG toggle; sidebar Retrieval section configures the rest. */}
<button
type="button"
disabled={ragDisabled}
onClick={() => {
const next = !ragToolEnabled;
setRagToolEnabled(next);
if (next && ragSource.kind === "off") {
setRagSource({ kind: "thread" });
}
}}
className="composer-pill-btn"
data-active={ragToolEnabled && !ragDisabled ? "true" : "false"}
aria-label={ragToolEnabled ? "Disable RAG" : "Enable RAG"}
title={
ragDisabled
? "RAG needs a model that supports tool calling"
: ragToolEnabled
? "RAG on — the model can search your attached documents"
: "Enable RAG — let the model search your documents"
}
>
<BookOpenIcon className="size-3.5" />
<span>RAG</span>
</button>
{showWebFetchPill && (
<button
type="button"
disabled={webFetchDisabled}
onClick={() => setWebFetchToolsEnabled(!webFetchToolsEnabled)}
className="composer-pill-btn"
data-active={
webFetchToolsEnabled && !webFetchDisabled ? "true" : "false"
}
aria-label={
webFetchToolsEnabled ? "Disable URL fetch" : "Enable URL fetch"
}
>
<DownloadIcon className="size-3.5" />
<span>Fetch</span>
</button>
)}
</div>
<div className="flex items-center gap-1">
{dictationSupported && (
<>
{!isDictating ? (
<TooltipIconButton
tooltip="Dictate"
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground"
onClick={startDictation}
aria-label="Dictate"
>
<MicIcon className="size-4" />
</TooltipIconButton>
) : (
<TooltipIconButton
tooltip="Stop dictation"
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-destructive"
onClick={stopDictation}
aria-label="Stop dictation"
>
<SquareIcon className="size-3 animate-pulse fill-current" />
</TooltipIconButton>
)}
</>
)}
{busy ? (
<Button
type="button"
variant="default"
size="icon"
className="size-8 rounded-full"
onClick={stop}
>
<SquareIcon className="size-3 fill-current" />
</Button>
) : (
<TooltipIconButton
tooltip="Send message"
side="bottom"
variant="default"
size="icon"
className="size-8 rounded-full"
onClick={send}
disabled={!canSend}
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
</TooltipIconButton>
)}
</div>
</div>
</div>
);
}