From f7b92e5f111a8dcbc15e2986be602fd3e825bf6f Mon Sep 17 00:00:00 2001 From: wasimysaid Date: Sat, 30 May 2026 20:21:21 +0200 Subject: [PATCH] feat(chat): update single composer tools --- .../src/components/assistant-ui/thread.tsx | 274 ++++++++++++------ studio/frontend/src/features/chat/index.ts | 6 + 2 files changed, 197 insertions(+), 83 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index ddde7990a2..52ba313d74 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -44,13 +44,15 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { sentAudioNames } from "@/features/chat/api/chat-adapter"; -import { parseExternalModelId } from "@/features/chat/external-providers"; -import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; -import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; -import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; -import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; -import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; +import { + applyQwenThinkingParams, + deleteThreadMessage, + getExternalReasoningCapabilities, + parseExternalModelId, + sentAudioNames, + useChatRuntimeStore, + useExternalProvidersStore, +} from "@/features/chat"; import { isTauri } from "@/lib/api-base"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; @@ -137,7 +139,10 @@ export const Thread: FC<{ const threadId = targetThreadId ?? activeThreadId ?? null; return ( - + thread.isEmpty && !thread.isLoading} > - + )} @@ -363,20 +371,19 @@ const ThreadScrollToBottom: FC = () => { ); }; +function getWelcomeEmoji(): string { + const hour = new Date().getHours(); + if (hour >= 6 && hour < 12) return "large sloth drink.png"; + if (hour >= 12 && hour < 17) return "sloth magnify final.png"; + if (hour >= 17 && hour < 21) return "sloth shy large.png"; + return "unsloth-gem.png"; +} + const ThreadWelcome: FC<{ hideComposer?: boolean; threadId?: string | null; }> = ({ hideComposer, threadId }) => { - const [currentEmoji, setCurrentEmoji] = useState("large sloth drink.png"); - - useEffect(() => { - const hour = new Date().getHours(); - if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png"); - else if (hour >= 12 && hour < 17) - setCurrentEmoji("sloth magnify final.png"); - else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png"); - else setCurrentEmoji("unsloth-gem.png"); - }, []); + const [currentEmoji] = useState(getWelcomeEmoji); const currentEmojiSrc = currentEmoji === "unsloth-gem.png" @@ -406,7 +413,7 @@ const ComposerAnimated: FC<{ menuSide?: "top" | "bottom"; }> = ({ disabled, threadId, menuSide }) => { return ( -
+
@@ -451,6 +458,9 @@ const Composer: FC<{ const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled); + const webFetchToolsEnabled = useChatRuntimeStore( + (s) => s.webFetchToolsEnabled, + ); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setPendingImageEditReference = useChatRuntimeStore( (s) => s.setPendingImageEditReference, @@ -463,20 +473,23 @@ const Composer: FC<{ const inputRef = useRef(null); const [isMultiline, setIsMultiline] = useState(false); useEffect(() => { - if (composerText.length === 0) { - setIsMultiline(false); - return; - } - const el = inputRef.current; - if (!el) { - return; - } - const cs = getComputedStyle(el); - const lineHeight = Number.parseFloat(cs.lineHeight) || 24; - const padTop = Number.parseFloat(cs.paddingTop) || 0; - const padBottom = Number.parseFloat(cs.paddingBottom) || 0; - const contentHeight = el.scrollHeight - padTop - padBottom; - setIsMultiline((prev) => prev || contentHeight > lineHeight * 1.5); + const frame = window.requestAnimationFrame(() => { + if (composerText.length === 0) { + setIsMultiline(false); + return; + } + const el = inputRef.current; + if (!el) { + return; + } + const cs = getComputedStyle(el); + const lineHeight = Number.parseFloat(cs.lineHeight) || 24; + const padTop = Number.parseFloat(cs.paddingTop) || 0; + const padBottom = Number.parseFloat(cs.paddingBottom) || 0; + const contentHeight = el.scrollHeight - padTop - padBottom; + setIsMultiline((prev) => prev || contentHeight > lineHeight * 1.5); + }); + return () => window.cancelAnimationFrame(frame); }, [composerText]); const hasAttachments = useAuiState( ({ composer }) => composer.attachments.length > 0, @@ -499,7 +512,8 @@ const Composer: FC<{ hasPendingAudio || toolsEnabled || codeToolsEnabled || - imageToolsEnabled; + imageToolsEnabled || + webFetchToolsEnabled; // react-textarea-autosize re-measures only on value change or window resize, // not on the width swap from expanding, so it keeps the taller height and // leaves a stray blank row. Nudge a resize whenever the input width changes. @@ -622,9 +636,10 @@ const Composer: FC<{ {composerExpanded ? ( <> - - - + {toolsEnabled ? : null} + {codeToolsEnabled ? : null} + {imageToolsEnabled ? : null} + {webFetchToolsEnabled ? : null} ) : null}
@@ -645,12 +660,8 @@ const Composer: FC<{ {...inputProps} /> @@ -1221,6 +1232,7 @@ const WebSearchToggle: FC = () => { > Search + ); }; @@ -1242,7 +1254,8 @@ const CodeToolsToggle: FC = () => { const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); // 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 disabled = modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); + const disabled = + modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); return ( ); }; @@ -1269,10 +1283,6 @@ const ImagesToggle: FC = () => { const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); - // OpenAI cloud Responses-API models advertise image_generation as a - // server-side tool; no local runtime fallback exists. Mirror of - // shared-composer's imageDisabled / showImagePill so the in-thread - // composer surfaces the same control as the empty-state composer. const supportsBuiltinImageGeneration = useChatRuntimeStore( (s) => s.supportsBuiltinImageGeneration, ); @@ -1280,10 +1290,14 @@ const ImagesToggle: FC = () => { const setImageToolsEnabled = useChatRuntimeStore( (s) => s.setImageToolsEnabled, ); - if (!supportsBuiltinImageGeneration) { + + if (!imageToolsEnabled) { return null; } - const disabled = !modelLoaded; + + // Disable only when a loaded model lacks the capability; before a model is + // loaded, preserve the existing pre-selected-pill behavior used by Search/Code. + const disabled = modelLoaded && !supportsBuiltinImageGeneration; return ( + ); +}; + +const WebFetchToggle: FC = () => { + const modelLoaded = useChatRuntimeStore( + (s) => !!s.params.checkpoint && !s.modelLoading, + ); + const supportsBuiltinWebFetch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebFetch, + ); + const webFetchToolsEnabled = useChatRuntimeStore( + (s) => s.webFetchToolsEnabled, + ); + const setWebFetchToolsEnabled = useChatRuntimeStore( + (s) => s.setWebFetchToolsEnabled, + ); + + if (!webFetchToolsEnabled) { + return null; + } + + // Disable only when a loaded model lacks the capability; before a model is + // loaded, preserve the existing pre-selected-pill behavior used by Search/Code. + const disabled = modelLoaded && !supportsBuiltinWebFetch; + return ( + ); }; @@ -1315,15 +1369,16 @@ const ToolStatusDisplay: FC = () => { }, [visible]); useEffect(() => { - if (!toolStatus) { + const resetFrame = window.requestAnimationFrame(() => { setElapsed(0); - if (!isThreadRunning) { + if (!toolStatus && !isThreadRunning) { setVisible(false); } - return; - } + }); - setElapsed(0); + if (!toolStatus) { + return () => window.cancelAnimationFrame(resetFrame); + } // Debounce badge visibility by 300ms when the badge is not // already on screen. Once visible from a prior tool, consecutive @@ -1338,6 +1393,7 @@ const ToolStatusDisplay: FC = () => { setElapsed((prev) => prev + 1); }, 1000); return () => { + window.cancelAnimationFrame(resetFrame); clearInterval(interval); if (showTimer) { clearTimeout(showTimer); @@ -1376,6 +1432,29 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); + const modelLoaded = useChatRuntimeStore( + (s) => !!s.params.checkpoint && !s.modelLoading, + ); + const supportsBuiltinImageGeneration = useChatRuntimeStore( + (s) => s.supportsBuiltinImageGeneration, + ); + const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled); + const setImageToolsEnabled = useChatRuntimeStore( + (s) => s.setImageToolsEnabled, + ); + const supportsBuiltinWebFetch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebFetch, + ); + const webFetchToolsEnabled = useChatRuntimeStore( + (s) => s.webFetchToolsEnabled, + ); + const setWebFetchToolsEnabled = useChatRuntimeStore( + (s) => s.setWebFetchToolsEnabled, + ); + const showImageMenuItem = supportsBuiltinImageGeneration || imageToolsEnabled; + const showFetchMenuItem = supportsBuiltinWebFetch || webFetchToolsEnabled; + const imageMenuDisabled = modelLoaded && !supportsBuiltinImageGeneration; + const fetchMenuDisabled = modelLoaded && !supportsBuiltinWebFetch; const startCompare = useCallback(() => { const store = useChatRuntimeStore.getState(); @@ -1397,7 +1476,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ aria-label="Tools and attachments" className="unsloth-composer-plus" > - + = ({ Code {codeToolsEnabled ? : null} + {showImageMenuItem ? ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled ? : null} + + ) : null} + {showFetchMenuItem ? ( + setWebFetchToolsEnabled(!webFetchToolsEnabled)} + > + + Fetch + {webFetchToolsEnabled ? : null} + + ) : null} setSettingsPanelOpen(true)}> MCP @@ -1498,9 +1603,10 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ const ComposerRightControls: FC<{ disabled?: boolean; + showSend?: boolean; shouldBlockSend?: () => boolean; menuSide?: "top" | "bottom"; -}> = ({ disabled, shouldBlockSend, menuSide }) => { +}> = ({ disabled, showSend, shouldBlockSend, menuSide }) => { return (
@@ -1510,9 +1616,9 @@ const ComposerRightControls: FC<{ tooltip="Dictate" aria-label="Dictate" variant="ghost" - className="size-9 rounded-full text-foreground" + className="size-8 rounded-full text-foreground" > - + @@ -1522,40 +1628,42 @@ const ComposerRightControls: FC<{ tooltip="Stop dictation" aria-label="Stop dictation" variant="ghost" - className="size-9 rounded-full text-destructive" + className="size-8 rounded-full text-destructive" > - !thread.isRunning}> - - { - if (shouldBlockSend?.()) { - event.preventDefault(); - } - }} - className="aui-composer-send size-9 rounded-full" - aria-label="Send message" - > - - - - + {showSend ? ( + !thread.isRunning}> + + { + if (shouldBlockSend?.()) { + event.preventDefault(); + } + }} + className="aui-composer-send size-8 rounded-full disabled:bg-transparent disabled:text-foreground/40 disabled:opacity-100 disabled:pointer-events-none" + aria-label="Send message" + > + + + + + ) : null} thread.isRunning}>