From 37fd76a02fb72e7f7a236fc059b391c7e60a5712 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:07:30 -0700 Subject: [PATCH] studio: redesign chat composer (#5891) * studio: redesign chat composer Reworks the new-chat composer and the compare composer into a single rounded pill surface with a softer, lighter look. - New welcome screen with a time-of-day sloth mascot and a lighter heading. - One rounded composer surface with a soft drop shadow. The input grows inline as you type and collapses back to a single row when cleared. - Tools and attachments live in a single plus menu; the thinking control is a compact pill with a reasoning-effort submenu. - Inlined glyphs for the thinking, send, and dictate controls, kept in sync across the main and compare composers. - Toast notifications match the composer surface: no border line, the same drop shadow, and the same dark surface color, with a ring-less close button. - Dark mode: the side-menu shadow blends into the background, hovered menu rows read clearly, and their roundness matches light mode. - Composer styles use dedicated unsloth- prefixed classes so compare mode keeps its own stacked layout. * studio: sync compare-composer reasoning state and harden compare id - Compare composer: keep "Preserve thinking" consistent with reasoning, matching the main composer. Enabling it now turns reasoning on, and disabling reasoning (the None option or the Thinking toggle) turns it off, so the invalid "preserve on while thinking off" state can't occur. - Guard crypto.randomUUID in the Compare action. It is undefined in non-secure contexts (HTTP over a LAN IP) and would throw; fall back to a timestamped random id, matching createNavigationNonce. * studio: reflect pre-selected Search/Code tools when no model is loaded The Search and Code pills only lit up when the tool was usable right now (a model loaded and capable), so a tool turned on from the + menu showed as off in the pill while the menu showed it on. toolsEnabled is persisted and takes effect once a capable model loads, so the pill should reflect it. The pills now disable only when a loaded model lacks the capability, and otherwise reflect the selected state. Applied to the main and compare composers. * Studio: link MCP Servers heading to its PR and fix composer pill cursors Make the "MCP Servers" heading in the chat Configuration sheet link to the MCP PR, keeping the chevron as the toggle. The label and chevron are rendered as siblings so we don't nest an inside a ); diff --git a/studio/frontend/src/components/assistant-ui/image.tsx b/studio/frontend/src/components/assistant-ui/image.tsx index a2e3f30dc1..3cd7ecaaf5 100644 --- a/studio/frontend/src/components/assistant-ui/image.tsx +++ b/studio/frontend/src/components/assistant-ui/image.tsx @@ -15,13 +15,14 @@ import type { import { type VariantProps, cva } from "class-variance-authority"; import { CopyIcon, - DownloadIcon, ImageIcon, ImageOffIcon, Loader2Icon, RefreshCwIcon, ShieldAlertIcon, } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type ComponentProps, type PropsWithChildren, @@ -427,7 +428,7 @@ function ImageActions({ part, onRegenerate, className }: ImageActionsProps) { aria-label="Download image" className="inline-flex size-7 items-center justify-center rounded hover:bg-muted" > - + diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 0fcbefdabd..f8a3ce4e20 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -146,7 +146,7 @@ function ModelSelectorTrigger({ type="button" data-tour={dataTour} className={cn( - "flex min-w-0 items-center gap-2 transition-colors", + "unsloth-model-selector-trigger flex min-w-0 items-center gap-2 transition-colors", variant === "outline" && "rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", @@ -246,7 +246,7 @@ function ModelSelectorContent({ align="start" data-tour={dataTour} className={cn( - "menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2", + "unsloth-model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-3", className, )} > diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index ea40c260c5..2d715bf8de 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -38,11 +38,11 @@ import { import { cn, formatCompact } from "@/lib/utils"; import type { VramFitStatus } from "@/lib/vram"; import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; -import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; +import { Add01Icon, Cancel01Icon, Download01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { FolderBrowser } from "./folder-browser"; import { ModelDeleteAction } from "./model-delete-action"; -import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, StarIcon } from "lucide-react"; import { type ReactNode, useCallback, @@ -145,8 +145,8 @@ function ModelRow({ type="button" onClick={onClick} className={cn( - "flex w-full items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]", - selected && "bg-[#ececec] dark:bg-[#2e3035]", + "flex w-full items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]", + selected && "bg-[#ececec] dark:bg-[#3a3d44]", )} > @@ -925,7 +925,7 @@ export function HubModelPicker({ (!chatOnly && cachedModels.length > 0)) ? ( <> } + icon={} collapsed={downloadedCollapsed} onToggle={() => setDownloadedCollapsed((v) => !v)} >Downloaded diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index b323996072..83057074ba 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -2,11 +2,9 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { - ComposerAddAttachment, ComposerAttachments, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; -import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { GeneratedImageOverlayProvider, useGeneratedImageOverlay, @@ -40,15 +38,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; +import { useChatProjects } from "@/features/chat/hooks/use-chat-projects"; +import { NewProjectDialog } from "@/features/chat/components/new-project-dialog"; import { parseExternalModelId } from "@/features/chat/external-providers"; import { McpComposerButton } from "@/features/chat/mcp-composer-button"; 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 { useUserProfileStore } from "@/features/profile/stores/user-profile-store"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; import { isTauri } from "@/lib/api-base"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; @@ -69,44 +75,58 @@ import { useAuiState, } from "@assistant-ui/react"; import { flushResourcesSync } from "@assistant-ui/tap"; +import { + AttachmentIcon, + CodeIcon, + Copy01Icon, + Delete02Icon, + Download01Icon, + Edit03Icon, + Folder01Icon, + FolderAddIcon, + Image03Icon, + McpServerIcon, + PencilRulerIcon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowUpIcon, + CheckIcon, ChevronLeftIcon, ChevronRightIcon, - DownloadIcon, - FileTextIcon, + Columns2Icon, GlobeIcon, HeadphonesIcon, - LightbulbIcon, - LightbulbOffIcon, - MicIcon, MoreHorizontalIcon, + PlusIcon, RefreshCwIcon, SquareIcon, TerminalIcon, XIcon, } from "lucide-react"; -import { - Copy01Icon, - Delete02Icon, - Edit03Icon, - Image03Icon, - Tick02Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import { type ChangeEvent, type ComponentProps, type CompositionEvent, type FC, type KeyboardEvent, + type DragEvent as ReactDragEvent, + type ReactNode, + createContext, useCallback, + useContext, useEffect, useRef, useState, } from "react"; +// True while a file is dragged anywhere over the chat page (not just the +// composer), so the composer can show its "Drop files here" affordance. +const PageDragContext = createContext(false); + export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean; @@ -125,8 +145,55 @@ export const Thread: FC<{ const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const threadId = targetThreadId ?? activeThreadId ?? null; + // Page-wide drag-and-drop: dropping a file anywhere on the chat page (not + // just on the composer) attaches it and shows the composer drop affordance. + // The composer's own dropzone still handles drops on the box itself; its + // handler calls preventDefault, so the page handler skips them (no double-add). + const aui = useAui(); + const [pageDragging, setPageDragging] = useState(false); + const dragDepth = useRef(0); + const hasFiles = (e: ReactDragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + const onDragEnter = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + dragDepth.current += 1; + setPageDragging(true); + }; + const onDragOver = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + e.preventDefault(); + }; + const onDragLeave = (e: ReactDragEvent) => { + if (isTauri || !hasFiles(e)) return; + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setPageDragging(false); + }; + const onDrop = (e: ReactDragEvent) => { + if (isTauri) return; + dragDepth.current = 0; + setPageDragging(false); + // Compare panes hide this composer and use the shared composer's own + // dropzone, so don't capture drops into a hidden composer here. + if (hideComposer) return; + // Drops on the composer box are handled by its own dropzone, which calls + // preventDefault; skip those here so the file isn't added twice. + if (e.defaultPrevented) return; + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + e.preventDefault(); + for (const file of files) { + aui + .composer() + .addAttachment(file) + .catch(() => { + // Adapter shows its own toast (e.g. "Load a model before adding images"). + }); + } + }; + return ( + + ); }; @@ -250,7 +322,7 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ } aria-label="Download generated image" > - + - - {effectiveSupportsReasoningOff && ( - { - setReasoningEnabled(false); - applyQwenThinkingParams(false); - }} - > - None - {!effectiveReasoningVisualEnabled ? " \u2713" : ""} - - )} - {effectiveReasoningEffortLevels - .filter((level) => level !== "none") - .map((level) => ( + + {isEffort ? ( + <> + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + // Preserve thinking needs thinking on, so turn it off too. + setPreserveThinking(false); + }} + > + + None + + )} + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( + { + setReasoningEffort(level); + setReasoningEnabled(true); + applyQwenThinkingParams(true); + // Kimi's $web_search builtin forbids thinking, so + // enabling thinking flips the Search pill off. + if (isKimiExternal && toolsEnabled) { + setToolsEnabled(false, { persist: false }); + } + }} + > + + {formatEffortLabel(level)} + + ))} + + ) : ( + effectiveSupportsReasoningOff && + !reasoningLockedOn && ( { - setReasoningEffort(level); - setReasoningEnabled(true); - applyQwenThinkingParams(true); - // Kimi's $web_search builtin forbids thinking, so - // enabling thinking flips the Search pill off. - if (isKimiExternal && toolsEnabled) { - setToolsEnabled(false); + const next = !reasoningEnabled; + setReasoningEnabled(next); + applyQwenThinkingParams(next); + // Preserve thinking cannot run without thinking. + if (!next) setPreserveThinking(false); + if (isKimiExternal && next && toolsEnabled) { + setToolsEnabled(false, { persist: false }); } }} > - {formatEffortLabel(level)} - {effectiveReasoningVisualEnabled && reasoningEffort === level - ? " \u2713" - : ""} + + Thinking - ))} + ) + )} + {supportsPreserveThinking && ( + { + e.preventDefault(); + const next = !preserveThinking; + setPreserveThinking(next); + // Preserve thinking requires thinking on. + if (next) { + setReasoningEnabled(true); + applyQwenThinkingParams(true); + } + }} + > + + Preserve thinking + + )} ); @@ -940,18 +1329,13 @@ const ReasoningToggle: FC = () => { const next = !reasoningEnabled; setReasoningEnabled(next); applyQwenThinkingParams(next); - // Mutual exclusion with the Search pill on Kimi — see the - // dropdown branch above and shared-composer for the same rule. + // Mutually exclusive with Search on Kimi (see dropdown branch). if (isKimiExternal && next && toolsEnabled) { - setToolsEnabled(false); + setToolsEnabled(false, { persist: false }); } }} - className="composer-pill-btn" - data-active={ - reasoningLockedOn || (effectiveReasoningEnabled && !disabled) - ? "true" - : "false" - } + className="unsloth-thinking-pill" + data-active={activeLook ? "true" : "false"} aria-label={thinkToggleAriaLabel({ reasoningLockedOn, modelLoaded, @@ -959,53 +1343,21 @@ const ReasoningToggle: FC = () => { effectiveReasoningEnabled, })} > - {reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? ( - - ) : ( - - )} - Think + + + + {activeLook ? Thinking : null} ); }; -const PreserveThinkingToggle: FC = () => { - const modelLoaded = useChatRuntimeStore( - (s) => !!s.params.checkpoint && !s.modelLoading, - ); - const supportsPreserveThinking = useChatRuntimeStore( - (s) => s.supportsPreserveThinking, - ); - const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking); - const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); - if (!supportsPreserveThinking) return null; - const disabled = !modelLoaded; - return ( - - ); -}; +// Tool icon plus an X overlay the CSS reveals on hover when the pill is active. +const PillGlyph: FC<{ children: ReactNode }> = ({ children }) => ( + + {children} + + +); const WebSearchToggle: FC = () => { const modelLoaded = useChatRuntimeStore( @@ -1034,7 +1386,9 @@ const WebSearchToggle: FC = () => { ? externalProviders.find((p) => p.id === externalSelection.providerId) : undefined; const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; - const disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch); + // 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 || supportsBuiltinWebSearch); return ( ); @@ -1077,8 +1433,9 @@ const CodeToolsToggle: FC = () => { ); const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled); const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled); - const disabled = - !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution); + // 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); return ( ); @@ -1129,31 +1492,36 @@ const ImagesToggle: FC = () => { : "Enable image generation" } > - + + + Images ); }; const ArtifactsToggle: FC = () => { - const modelLoaded = useChatRuntimeStore( - (s) => !!s.params.checkpoint && !s.modelLoading, - ); const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); - const disabled = !modelLoaded; + // Canvas is opt-in; the pill only shows once it is toggled on from the menu. + if (!artifactsEnabled) return null; return ( ); }; @@ -1215,84 +1583,309 @@ const ToolStatusDisplay: FC = () => { ); }; +// Plus menu: attachment and workflow actions. Opens downward in the centered +// welcome composer; the docked composer passes side="top" to open upward. +const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ + side = "bottom", +}) => { + const navigate = useNavigate(); + 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 artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); + const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); + const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); + const setMcpEnabledForChat = useChatRuntimeStore( + (s) => s.setMcpEnabledForChat, + ); + // Capability gating, mirroring the visible pills so menu and pills agree on + // what a loaded model supports (a tool the backend drops must not look on). + const modelLoaded = useChatRuntimeStore( + (s) => !!s.params.checkpoint && !s.modelLoading, + ); + const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); + const supportsTools = useChatRuntimeStore((s) => s.supportsTools); + const supportsBuiltinWebSearch = useChatRuntimeStore( + (s) => s.supportsBuiltinWebSearch, + ); + const supportsBuiltinCodeExecution = useChatRuntimeStore( + (s) => s.supportsBuiltinCodeExecution, + ); + const supportsBuiltinImageGeneration = useChatRuntimeStore( + (s) => s.supportsBuiltinImageGeneration, + ); + const imageToolsEnabled = useChatRuntimeStore((s) => s.imageToolsEnabled); + const setImageToolsEnabled = useChatRuntimeStore( + (s) => s.setImageToolsEnabled, + ); + const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const externalProvidersAll = useExternalProvidersStore((s) => s.providers); + const externalProviders = connectionsEnabled ? externalProvidersAll : []; + const externalSelection = parseExternalModelId(checkpoint); + const selectedExternalProvider = + externalSelection != null + ? externalProviders.find((p) => p.id === externalSelection.providerId) + : undefined; + const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; + // Disable only when a loaded model lacks the capability; with no model the + // tool can still be pre-selected, matching the pill logic above. + const searchDisabled = + modelLoaded && !(supportsTools || supportsBuiltinWebSearch); + const codeDisabled = + modelLoaded && !(supportsTools || supportsBuiltinCodeExecution); + const imageDisabled = !modelLoaded; + // Like Search/Code: disabled only when a loaded model lacks tool support. + const mcpDisabled = modelLoaded && !supportsTools; + // Three most recently updated projects for the quick-access submenu. + const { projects } = useChatProjects(); + const recentProjects = [...projects] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 3); + const openProject = (projectId: string) => { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + }; -const ComposerAction: FC<{ + const startCompare = useCallback(() => { + const store = useChatRuntimeStore.getState(); + store.setActiveThreadId(null); + store.setContextUsage(null); + // crypto.randomUUID is undefined in non-secure contexts (HTTP over a LAN IP). + const compareId = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + navigate({ to: "/chat", search: { compare: compareId } }); + }, [navigate]); + + const [newProjectOpen, setNewProjectOpen] = useState(false); + + return ( + <> + + + + + event.preventDefault()} + > + + + + Add photos & files + + + + { + const next = !toolsEnabled; + setToolsEnabled(next); + // Mirror the Search pill: Kimi forbids search + thinking together. + if (isKimiExternal) { + setReasoningEnabled(!next, { persist: false }); + applyQwenThinkingParams(!next); + } + }} + > + + Web search + {toolsEnabled && !searchDisabled ? ( + + ) : null} + + setCodeToolsEnabled(!codeToolsEnabled)} + > + + Code + {codeToolsEnabled && !codeDisabled ? ( + + ) : null} + + {supportsBuiltinImageGeneration && ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled && !imageDisabled ? ( + + ) : null} + + )} + + setArtifactsEnabled(!artifactsEnabled)} + > + + Canvas + {artifactsEnabled ? : null} + + setMcpEnabledForChat(!mcpEnabledForChat)} + > + + MCP + {mcpEnabledForChat && !mcpDisabled ? ( + + ) : null} + + {/* RAG hidden temporarily */} + startCompare()}> + + Compare chat + + + + + + Projects + + + setNewProjectOpen(true)}> + + New project + + Recents + {recentProjects.length > 0 ? ( + recentProjects.map((project) => ( + openProject(project.id)} + > + + {project.name} + + )) + ) : ( + + No recent projects + + )} + + + + + + + ); +}; + +const ComposerRightControls: FC<{ disabled?: boolean; shouldBlockSend?: () => boolean; -}> = ({ disabled, shouldBlockSend }) => { + menuSide?: "top" | "bottom"; +}> = ({ disabled, shouldBlockSend, menuSide }) => { return ( -
-
- - - - - - - - - -
-
- - - - - - - - - - - - - - - !thread.isRunning}> - - { - if (shouldBlockSend?.()) { - event.preventDefault(); - } - }} - className="aui-composer-send size-8 rounded-full" - aria-label="Send message" - > - - - - - thread.isRunning}> - - - - -
+
+ + + + + + + + + + + + + + + + !thread.isRunning}> + + { + if (shouldBlockSend?.()) { + event.preventDefault(); + } + }} + className="aui-composer-send ml-1.5 size-8 rounded-full" + aria-label="Send message" + > + + + + + thread.isRunning}> + + + +
); }; @@ -1474,11 +2067,11 @@ const AssistantActionBar: FC = () => { side="bottom" align="start" onCloseAutoFocus={(e) => e.preventDefault()} - className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md" + className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md [--radius:1.1rem] bg-popover p-1 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(27,27,31,0.16)] dark:shadow-none" > - + Export as Markdown diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx index a9919708d0..c0ced3b844 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx @@ -6,7 +6,9 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; -import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react"; +import { ImageIcon, PencilIcon } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import type { CSSProperties, MouseEvent } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useGeneratedImageOverlay } from "./generated-image-overlay-context"; @@ -374,7 +376,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({ onClick={handleDownload} aria-label="Download generated image" > - +
diff --git a/studio/frontend/src/components/ui/combobox.tsx b/studio/frontend/src/components/ui/combobox.tsx index 4fe310b788..53d77415e5 100644 --- a/studio/frontend/src/components/ui/combobox.tsx +++ b/studio/frontend/src/components/ui/combobox.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + "use client"; /* eslint-disable react-refresh/only-export-components */ @@ -143,10 +143,10 @@ function ComboboxInput({ ); } -function ComboboxContent({ - className, - side = "bottom", - sideOffset = 6, +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, align = "start", alignOffset = 0, anchor, @@ -162,23 +162,23 @@ function ComboboxContent({ const dialogContainer = useDialogPortalContainer(); return ( - - + + ); diff --git a/studio/frontend/src/components/ui/dropdown-menu.tsx b/studio/frontend/src/components/ui/dropdown-menu.tsx index 9102e5c945..0f67194ee3 100644 --- a/studio/frontend/src/components/ui/dropdown-menu.tsx +++ b/studio/frontend/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// 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 { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import type * as React from "react"; @@ -46,7 +46,7 @@ function DropdownMenuContent({ sideOffset={sideOffset} align={align} className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border bg-popover text-popover-foreground min-w-48 rounded-lg p-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-48 rounded-lg p-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className, )} {...props} @@ -244,7 +244,7 @@ function DropdownMenuSubContent({ { { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", + // No border line; elevation comes from the composer's drop shadow. + "--normal-border": "transparent", "--border-radius": "var(--radius)", // Pin the close button inside the toast's top-right corner. // Sonner defaults to the left/outside edge, so keep the horizontal diff --git a/studio/frontend/src/components/ui/tabs.tsx b/studio/frontend/src/components/ui/tabs.tsx index 07167ddf36..1fa8274573 100644 --- a/studio/frontend/src/components/ui/tabs.tsx +++ b/studio/frontend/src/components/ui/tabs.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + "use client"; /* eslint-disable react-refresh/only-export-components */ @@ -54,7 +54,7 @@ export const tabsListVariants = cva( variants: { variant: { default: "bg-muted", - line: "gap-1 bg-transparent", + line: "gap-2 bg-transparent group-data-horizontal/tabs:h-auto", }, }, defaultVariants: { @@ -95,8 +95,10 @@ export function TabsTrigger({ className={cn( "gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", + // Line variant is a roomier pill (no underline); padding overrides px-2 py-1. + "group-data-[variant=line]/tabs-list:px-3.5 group-data-[variant=line]/tabs-list:py-2.5", "data-active:text-foreground dark:data-active:text-foreground", - "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5", className, )} {...props} @@ -104,7 +106,7 @@ export function TabsTrigger({ {isActive && ( { + if (typeof window === "undefined") return undefined; + return window.SpeechRecognition ?? window.webkitSpeechRecognition; +}; + +const stopStream = (stream: MediaStream | null) => { + stream?.getTracks().forEach((track) => track.stop()); +}; + +const describeMediaError = (error: unknown): string => { + if (!(error instanceof DOMException)) { + return "Dictation could not access the microphone."; + } + if (error.name === "NotAllowedError") { + return "Microphone access is blocked. Allow microphone access for this Studio page, then try again."; + } + if (error.name === "NotFoundError") { + return "No microphone was found for dictation."; + } + if (error.name === "NotReadableError") { + return "The microphone is already in use or unavailable."; + } + return error.message || "Dictation could not access the microphone."; +}; + +const describeSpeechError = (error: string, message?: string): string => { + if (error === "not-allowed") { + return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page."; + } + if (error === "service-not-allowed") { + return "Speech recognition is blocked by the browser speech service."; + } + if (error === "network") { + return "Speech recognition could not reach the browser speech service."; + } + if (error === "language-not-supported") { + return "Speech recognition does not support the current language."; + } + return message || `Speech recognition failed: ${error}`; +}; + +export class StudioWebSpeechDictationAdapter implements DictationAdapter { + private readonly language: string; + private readonly continuous: boolean; + private readonly interimResults: boolean; + + constructor( + options: { + language?: string; + continuous?: boolean; + interimResults?: boolean; + } = {}, + ) { + this.language = options.language ?? navigator.language ?? "en-US"; + this.continuous = options.continuous ?? true; + this.interimResults = options.interimResults ?? true; + } + + static isSupported(): boolean { + return ( + typeof window !== "undefined" && + window.isSecureContext && + getSpeechRecognitionAPI() !== undefined && + navigator.mediaDevices?.getUserMedia !== undefined + ); + } + + listen(): DictationAdapter.Session { + const SpeechRecognitionAPI = getSpeechRecognitionAPI(); + if (!SpeechRecognitionAPI || !navigator.mediaDevices?.getUserMedia) { + throw new Error("Speech recognition is not supported in this browser."); + } + + const recognition = new SpeechRecognitionAPI(); + recognition.lang = this.language; + recognition.continuous = this.continuous; + recognition.interimResults = this.interimResults; + + const speechStartCallbacks = new Set<() => void>(); + const speechEndCallbacks = new Set<(result: DictationAdapter.Result) => void>(); + const speechCallbacks = new Set<(result: DictationAdapter.Result) => void>(); + + let stream: MediaStream | null = null; + let finalTranscript = ""; + let ended = false; + let started = false; + let resolveEnded: (() => void) | null = null; + const endedPromise = new Promise((resolve) => { + resolveEnded = resolve; + }); + + const session: DictationAdapter.Session = { + status: { type: "starting" }, + + stop: async () => { + if (!ended && started) { + recognition.stop(); + } else if (!ended) { + finish("stopped"); + } + await endedPromise; + }, + + cancel: () => { + if (!ended && started) { + recognition.abort(); + } else if (!ended) { + finish("cancelled"); + } + }, + + onSpeechStart: (callback) => { + speechStartCallbacks.add(callback); + return () => { + speechStartCallbacks.delete(callback); + }; + }, + + onSpeechEnd: (callback) => { + speechEndCallbacks.add(callback); + return () => { + speechEndCallbacks.delete(callback); + }; + }, + + onSpeech: (callback) => { + speechCallbacks.add(callback); + return () => { + speechCallbacks.delete(callback); + }; + }, + }; + + const finish = (reason: "stopped" | "cancelled" | "error") => { + if (ended) return; + ended = true; + session.status = { type: "ended", reason }; + stopStream(stream); + stream = null; + if (finalTranscript) { + for (const callback of speechEndCallbacks) { + callback({ transcript: finalTranscript }); + } + finalTranscript = ""; + } + resolveEnded?.(); + }; + + recognition.addEventListener("start", () => { + session.status = { type: "running" }; + }); + + recognition.addEventListener("speechstart", () => { + for (const callback of speechStartCallbacks) callback(); + }); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as SpeechRecognitionEvent; + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result) continue; + const transcript = result[0]?.transcript ?? ""; + if (result.isFinal) { + finalTranscript += transcript; + for (const callback of speechCallbacks) { + callback({ transcript, isFinal: true }); + } + } else { + for (const callback of speechCallbacks) { + callback({ transcript, isFinal: false }); + } + } + } + }); + + recognition.addEventListener("end", () => { + finish("stopped"); + }); + + recognition.addEventListener("error", (event) => { + const errorEvent = event as SpeechRecognitionErrorEvent; + if (errorEvent.error === "aborted") { + finish("cancelled"); + return; + } + const description = describeSpeechError(errorEvent.error, errorEvent.message); + console.error("Dictation error:", errorEvent.error, errorEvent.message); + toast.error(description); + finish("error"); + }); + + void (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true }, + }); + if (ended) { + stopStream(stream); + stream = null; + return; + } + const audioTrack = stream.getAudioTracks()[0]; + if (!audioTrack || audioTrack.readyState !== "live") { + throw new DOMException("No live microphone track is available.", "NotFoundError"); + } + try { + recognition.start(audioTrack); + } catch (error) { + // Older engines expose only start(); retry without the experimental track overload. + console.debug("Dictation start(audioTrack) failed; retrying start().", error); + recognition.start(); + } + started = true; + } catch (error) { + const description = describeMediaError(error); + console.error("Dictation microphone error:", error); + toast.error(description); + finish("error"); + } + })(); + + return session; + } +} diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 85b3be7dea..7a71850d66 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -306,7 +306,9 @@ export async function listChatThreads( const qs = params.toString(); const response = await authFetch(`/api/chat/threads${qs ? `?${qs}` : ""}`); const data = await parseJsonOrThrow<{ threads: ThreadRecord[] }>(response); - return data.threads; + // Always hand back an array: an older or misbehaving backend may omit the + // field or send a non-array, which would crash list consumers. + return Array.isArray(data.threads) ? data.threads : []; } export async function getChatThread( @@ -370,7 +372,9 @@ export async function listChatProjects( const qs = params.toString(); const response = await authFetch(`/api/chat/projects${qs ? `?${qs}` : ""}`); const data = await parseJsonOrThrow<{ projects: ProjectRecord[] }>(response); - return data.projects; + // Always hand back an array: an older or misbehaving backend may omit the + // field or send a non-array, which would crash list consumers. + return Array.isArray(data.projects) ? data.projects : []; } export async function getChatProject( diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index 8de3f7861d..b4ddf38aaa 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -15,11 +15,12 @@ import { cn } from "@/lib/utils"; import { CheckIcon, CopyIcon, - DownloadIcon, EyeIcon, Maximize2Icon, XIcon, } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type KeyboardEvent, useEffect, @@ -266,7 +267,7 @@ export function ArtifactSurface({ onClick={() => downloadTextFile(filename, artifact.code)} aria-label="Download artifact HTML" > - + - + {loading ? "Loading…" diff --git a/studio/frontend/src/features/chat/components/new-project-dialog.tsx b/studio/frontend/src/features/chat/components/new-project-dialog.tsx new file mode 100644 index 0000000000..38b0983e10 --- /dev/null +++ b/studio/frontend/src/features/chat/components/new-project-dialog.tsx @@ -0,0 +1,97 @@ +// 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 { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/lib/toast"; + +import { createChatProject } from "../hooks/use-chat-projects"; +import { useChatRuntimeStore } from "../stores/chat-runtime-store"; + +// Create-project dialog usable from the composer + menu. Creating opens the new +// project straight away rather than dropping the user on the projects list. +export function NewProjectDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const [name, setName] = useState(""); + + async function commitCreate() { + const trimmed = name.trim(); + if (!trimmed) return; + try { + const project = await createChatProject(trimmed); + onOpenChange(false); + setName(""); + const runtime = useChatRuntimeStore.getState(); + runtime.setActiveThreadId(null); + runtime.setActiveProjectId(project.id); + navigate({ to: "/chat", search: { project: project.id } }); + } catch (err) { + toast.error("Failed to create project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + return ( + { + if (!next) setName(""); + onOpenChange(next); + }} + > + + + New project + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitCreate(); + } + }} + autoFocus={true} + maxLength={120} + placeholder="Project name" + aria-label="Project name" + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + + ); +} diff --git a/studio/frontend/src/features/chat/hooks/use-chat-projects.ts b/studio/frontend/src/features/chat/hooks/use-chat-projects.ts index fe0dfa9e65..3f0d46982d 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-projects.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-projects.ts @@ -21,9 +21,11 @@ export function useChatProjects(): { isLoading: boolean; hasLoaded: boolean; } { - const [projects, setProjects] = useState(cachedProjects); - const [isLoading, setIsLoading] = useState(cachedProjects.length === 0); - const [hasLoaded, setHasLoaded] = useState(cachedProjects.length > 0); + // Stay null-safe even if the cache was poisoned by a bad response. + const cached = Array.isArray(cachedProjects) ? cachedProjects : []; + const [projects, setProjects] = useState(cached); + const [isLoading, setIsLoading] = useState(cached.length === 0); + const [hasLoaded, setHasLoaded] = useState(cached.length > 0); useEffect(() => { let cancelled = false; @@ -32,8 +34,8 @@ export function useChatProjects(): { if (!cancelled) setIsLoading(true); try { const next = await listStoredChatProjects({ includeArchived: false }); - cachedProjects = next; - if (!cancelled) setProjects(next); + cachedProjects = Array.isArray(next) ? next : []; + if (!cancelled) setProjects(cachedProjects); } catch (error) { if (isExpectedBackgroundChatStorageError(error)) { return; diff --git a/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts b/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts new file mode 100644 index 0000000000..e159101a7f --- /dev/null +++ b/studio/frontend/src/features/chat/hooks/use-pill-activation-order.ts @@ -0,0 +1,30 @@ +// 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 { useEffect, useState } from "react"; + +// Tracks which of the given keys are active and the order in which each became +// active, so opt-in composer pills (Canvas, MCP) render in the order they were +// toggled on rather than a fixed order. +export function usePillActivationOrder(states: Record): string[] { + const [order, setOrder] = useState(() => + Object.keys(states).filter((key) => states[key]), + ); + // Re-run only when the active/inactive set changes, not on every render. + const signature = Object.keys(states) + .map((key) => `${key}:${states[key] ? 1 : 0}`) + .join(","); + useEffect(() => { + setOrder((prev) => { + const next = prev.filter((key) => states[key]); + for (const key of Object.keys(states)) { + if (states[key] && !next.includes(key)) next.push(key); + } + const unchanged = + next.length === prev.length && next.every((key, i) => key === prev[i]); + return unchanged ? prev : next; + }); + // states is read fresh inside; signature captures its boolean values. + }, [signature]); + return order; +} diff --git a/studio/frontend/src/features/chat/mcp-composer-button.tsx b/studio/frontend/src/features/chat/mcp-composer-button.tsx index b98be8bf69..92d2c319a6 100644 --- a/studio/frontend/src/features/chat/mcp-composer-button.tsx +++ b/studio/frontend/src/features/chat/mcp-composer-button.tsx @@ -1,13 +1,10 @@ // 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 { - Cancel01Icon, - McpServerIcon, - Tick02Icon, -} from "@hugeicons/core-free-icons"; +import { McpServerIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useCallback, useEffect, useState } from "react"; +import { CheckIcon } from "lucide-react"; +import { type FC, useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { @@ -23,7 +20,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; import { type McpServerConfig, @@ -34,6 +30,23 @@ import { import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; +// Matches the Thinking pill chevron so the affordance reads the same. +const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + type McpPreset = { id: string; displayName: string; // stored row name @@ -76,7 +89,11 @@ function normalizeMcpUrl(url: string): string { // Static, so it is not rebuilt on every render. const PRESET_URLS = new Set(MCP_PRESETS.map((p) => normalizeMcpUrl(p.url))); -export function McpComposerButton() { +export function McpComposerButton({ + side = "bottom", +}: { + side?: "top" | "bottom"; +} = {}) { const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); @@ -93,32 +110,20 @@ export function McpComposerButton() { const [pendingUrl, setPendingUrl] = useState(null); const [hintKey, setHintKey] = useState(null); - // mcp_enabled only applies on the local tool-capable send path; grey out otherwise. - const usable = modelLoaded && supportsTools; - - // Keep the per-chat flag in step with whether any server is enabled. Reads the - // store directly so the callback stays stable (no refetch loop on mount). - const reconcileFlag = useCallback( - (rows: McpServerConfig[]) => { - const anyEnabled = rows.some((s) => s.is_enabled); - const current = useChatRuntimeStore.getState().mcpEnabledForChat; - if (anyEnabled && !current) setMcpEnabledForChat(true); - else if (!anyEnabled && current) setMcpEnabledForChat(false); - }, - [setMcpEnabledForChat], - ); + // Grey out only when a loaded model lacks tool support; with no model yet MCP + // can still be pre-selected, matching the other composer tools. + const usable = !modelLoaded || supportsTools; const refresh = useCallback(async () => { try { const rows = await listMcpServers(); setServers(rows); - reconcileFlag(rows); } catch { // Keep prior state if the list call fails. } - }, [reconcileFlag]); + }, []); - // Initial load reconciles the pill with already-enabled servers (also on open). + // Load the server list on mount and whenever the menu opens. useEffect(() => { void refresh(); }, [refresh]); @@ -206,27 +211,12 @@ export function McpComposerButton() { ? () => setHintKey((k) => (k === opts.key ? null : k)) : undefined } - className={cn( - "group/mcp relative flex items-center justify-between gap-2", - opts.enabled && - "bg-emerald-500/10 data-[highlighted]:bg-emerald-500/20", - )} + className={ + opts.enabled ? "relative text-primary font-medium" : "relative" + } > {opts.label} - {opts.enabled ? ( - - - - - ) : null} + {opts.enabled ? : null} {opts.hint ? ( @@ -260,28 +250,21 @@ export function McpComposerButton() { > MCP + - -
- MCP Servers - -
+ + MCP Servers {MCP_PRESETS.map((preset) => { const norm = normalizeMcpUrl(preset.url); return renderRow({ @@ -330,7 +313,7 @@ export function McpComposerButton() { > MCP diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 32b591d880..979d28a952 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -14,7 +14,6 @@ import { type PendingAttachment, type ThreadHistoryAdapter, type ThreadMessage, - WebSpeechDictationAdapter, type unstable_RemoteThreadListAdapter, useAui, useAuiEvent, @@ -34,6 +33,7 @@ import { } from "react"; import { extractText, getDocumentProxy } from "unpdf"; import { toast } from "sonner"; +import { StudioWebSpeechDictationAdapter } from "./adapters/studio-web-speech-dictation-adapter"; import { createOpenAIStreamAdapter } from "./api/chat-adapter"; import { loadConnectionsEnabled, @@ -190,7 +190,21 @@ class PDFAttachmentAdapter implements AttachmentAdapter { } class TextAttachmentAdapter implements AttachmentAdapter { - accept = "text/plain,text/markdown,text/csv,text/xml,text/json,text/css"; + // MIME is unreliable for source files, so also match by extension + // (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers + // svg, code, config and other plain-text formats; html keeps its own + // adapter below. + accept = [ + "text/plain,text/markdown,text/csv,text/xml,text/json,text/css", + "application/json,application/xml,image/svg+xml", + ".txt,.text,.log,.md,.markdown,.mdx,.rst,.csv,.tsv", + ".json,.jsonl,.ndjson,.xml,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.properties", + ".css,.scss,.sass,.less,.svg", + ".js,.jsx,.mjs,.cjs,.ts,.tsx,.py,.pyi,.ipynb,.rb,.php,.go,.rs,.java,.kt,.kts,.scala,.swift", + ".c,.h,.cc,.cpp,.hpp,.cxx,.cs,.m,.mm", + ".sh,.bash,.zsh,.fish,.ps1,.bat,.lua,.pl,.pm,.r,.jl,.dart,.vue,.svelte,.astro", + ".sql,.graphql,.gql,.proto,.tf,.tfvars,.gradle,.dockerfile,.makefile,.cmake,.diff,.patch", + ].join(","); async add({ file }: { file: File }): Promise { return { @@ -962,8 +976,8 @@ function useStudioRuntimeAdapters( const dictation = useMemo( () => - WebSpeechDictationAdapter.isSupported() - ? new WebSpeechDictationAdapter() + StudioWebSpeechDictationAdapter.isSupported() + ? new StudioWebSpeechDictationAdapter() : undefined, [], ); diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 0165a3930e..1717152ba0 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -2,7 +2,6 @@ // 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, @@ -13,6 +12,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; @@ -23,27 +27,36 @@ import { getImageInputUnavailableReason } from "./utils/image-input-support"; import { useAui } from "@assistant-ui/react"; import { ArrowUpIcon, - DownloadIcon, - FileTextIcon, + CheckIcon, + Columns2Icon, GlobeIcon, HeadphonesIcon, - LightbulbIcon, - LightbulbOffIcon, - MicIcon, PlusIcon, SquareIcon, XIcon, } from "lucide-react"; -import { Image03Icon } from "@hugeicons/core-free-icons"; +import { + AttachmentIcon, + CodeIcon, + Download01Icon, + Folder01Icon, + FolderAddIcon, + Image03Icon, + McpServerIcon, + PencilRulerIcon, +} from "@hugeicons/core-free-icons"; +import { useNavigate } from "@tanstack/react-router"; import { HugeiconsIcon } from "@hugeicons/react"; import { toast } from "@/lib/toast"; +import { McpComposerButton } from "./mcp-composer-button"; +import { NewProjectDialog } from "./components/new-project-dialog"; +import { useChatProjects } from "./hooks/use-chat-projects"; import { loadModel, validateModel } from "./api/chat-api"; import { parseExternalModelId, providerTypeSupportsVision, } from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; -import { McpComposerButton } from "./mcp-composer-button"; import { type ReasoningEffort, useChatRuntimeStore, @@ -52,11 +65,11 @@ import { getExternalReasoningCapabilities, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, - providerSupportsBuiltinWebSearch, providerSupportsBuiltinWebFetch, } from "./provider-capabilities"; import { type CompositionEvent, + type FC, type KeyboardEvent, type MutableRefObject, type ReactElement, @@ -77,9 +90,9 @@ export type CompareMessagePart = export interface CompareHandle { append: (content: CompareMessagePart[]) => void; /** Append a user message without triggering generation. */ - appendMessage: (content: CompareMessagePart[]) => Promise; + appendMessage: (content: CompareMessagePart[]) => void; /** Trigger generation on the current thread (after appendMessage). */ - startRun: (parentId?: string | null) => void; + startRun: () => void; cancel: () => void; isRunning: () => boolean; /** Returns a promise that resolves when the current or next run finishes. */ @@ -88,7 +101,49 @@ export interface CompareHandle { const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; -const COMPARE_APPEND_MESSAGE_TIMEOUT_MS = 10_000; + +// Inlined to avoid a new icon dependency. Kept in sync with the main composer. +const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +const MicIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); + +const BulbIcon: FC<{ className?: string }> = ({ className }) => ( + + + +); function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; @@ -232,7 +287,6 @@ export function RegisterCompareHandle({ }): ReactElement | null { const handlesRef = useContext(CompareHandlesContext); const aui = useAui(); - const pendingAppendWaitersRef = useRef void>>(new Set()); useEffect(() => { if (!handlesRef) { @@ -245,76 +299,19 @@ export function RegisterCompareHandle({ aui .thread() .append({ role: "user", content, createdAt: new Date() } as never), - appendMessage: (content) => { - const thread = aui.thread(); - const beforeIds = new Set( - thread.getState().messages.map((message) => message.id), - ); - thread.append({ - role: "user", - content, - createdAt: new Date(), - startRun: false, - } as never); - - const findAppendedUserMessageId = () => { - const messages = thread.getState().messages; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (beforeIds.has(message.id) || message.role !== "user") { - continue; - } - return message.id; - } - return null; - }; - - const appendedId = findAppendedUserMessageId(); - if (appendedId) { - return Promise.resolve(appendedId); - } - - return new Promise((resolve) => { - const startedAt = Date.now(); - let settled = false; - let timer: number | null = null; - let cancel: (() => void) | null = null; - const cleanup = () => { - if (timer !== null) { - window.clearTimeout(timer); - timer = null; - } - if (cancel) { - pendingAppendWaitersRef.current.delete(cancel); - } - }; - const finish = (messageId: string | null) => { - if (settled) return; - settled = true; - cleanup(); - resolve(messageId); - }; - cancel = () => finish(null); - const poll = () => { - timer = null; - const messageId = findAppendedUserMessageId(); - if ( - messageId || - Date.now() - startedAt >= COMPARE_APPEND_MESSAGE_TIMEOUT_MS - ) { - finish(messageId); - return; - } - timer = window.setTimeout(poll, 16); - }; - pendingAppendWaitersRef.current.add(cancel); - timer = window.setTimeout(poll, 0); - }); - }, - startRun: (parentId) => { + appendMessage: (content) => + aui + .thread() + .append({ + role: "user", + content, + createdAt: new Date(), + startRun: false, + } as never), + startRun: () => { const msgs = aui.thread().getState().messages; - const fallbackId = msgs.length > 0 ? msgs[msgs.length - 1].id : null; - aui.thread().startRun({ parentId: parentId ?? fallbackId }); + 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, @@ -332,10 +329,6 @@ export function RegisterCompareHandle({ }), }; return () => { - for (const cancel of pendingAppendWaitersRef.current) { - cancel(); - } - pendingAppendWaitersRef.current.clear(); delete currentHandles[name]; }; }, [handlesRef, name, aui]); @@ -381,15 +374,37 @@ type CompareModelSelection = { ggufVariant?: string; }; +// Tool icon plus an X overlay the CSS reveals on hover when the pill is active. +function PillGlyph({ children }: { children: ReactNode }) { + return ( + + {children} + + + ); +} + export function SharedComposer({ handlesRef, model1, model2, + onExitCompare, }: { handlesRef: CompareHandles; model1?: CompareModelSelection; model2?: CompareModelSelection; + onExitCompare?: () => void; }): ReactElement { + const navigate = useNavigate(); + // Exit compare. Uses the parent's restore handler, or a fresh chat when + // compare was opened by direct URL. + const handleExitCompare = useCallback(() => { + if (onExitCompare) { + onExitCompare(); + return; + } + navigate({ to: "/chat" }); + }, [navigate, onExitCompare]); const [text, setText] = useState(""); const [running, setRunning] = useState(false); const [comparing, setComparing] = useState(false); @@ -400,6 +415,7 @@ export function SharedComposer({ } | null>(null); const [dragging, setDragging] = useState(false); const [isComposing, setIsComposing] = useState(false); + const [newProjectOpen, setNewProjectOpen] = useState(false); const textareaRef = useRef(null); const composingRef = useRef(false); const stuckImeTimerRef = useRef | null>(null); @@ -452,6 +468,19 @@ export function SharedComposer({ ); const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); + const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); + const setMcpEnabledForChat = useChatRuntimeStore( + (s) => s.setMcpEnabledForChat, + ); + // Three most recently updated projects for the quick-access submenu. + const { projects } = useChatProjects(); + const recentProjects = [...projects] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 3); + const openProject = (projectId: string) => { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + }; const webFetchToolsEnabled = useChatRuntimeStore( (s) => s.webFetchToolsEnabled, ); @@ -530,6 +559,10 @@ export function SharedComposer({ const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning; const showReasoningControl = effectiveSupportsReasoning || effectiveReasoningAlwaysOn; + const isEffort = effectiveReasoningStyle === "reasoning_effort"; + const thinkingActiveLook = isEffort + ? 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 // web_search) OR a server-side web_search the provider runs for us @@ -569,24 +602,34 @@ export function SharedComposer({ // and gate strictly on the provider builtin support. 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 = - !modelLoaded || + modelLoaded && (isGeminiImageTier ? !supportsBuiltinWebSearch : !(supportsTools || supportsBuiltinWebSearch)); const codeDisabled = - !modelLoaded || - (isGeminiImageTier - ? true - : !(supportsTools || supportsBuiltinCodeExecution)) || + (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; - const artifactDisabled = !modelLoaded; // Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209). const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch; const showWebFetchPill = supportsBuiltinWebFetch; + // With more than 4 pills showing, collapse them to icons only to cut clutter. + // Compare, Search and Code always show; the rest are conditional. + const pillsCompact = + 3 + + (showImagePill ? 1 : 0) + + (showWebFetchPill ? 1 : 0) + + (artifactsEnabled ? 1 : 0) + + (mcpEnabledForChat ? 1 : 0) > + 4; // Backwards-compatible alias for any other call site that may still // reference `toolsDisabled` (rare; both pills used it before). const toolsDisabled = codeDisabled; @@ -770,8 +813,6 @@ export function SharedComposer({ : null; function modelDisplayName(id: string): string { - const external = parseExternalModelId(id); - if (external) return external.modelId; const parts = id.split("/"); return parts[parts.length - 1] || id; } @@ -780,76 +821,6 @@ export function SharedComposer({ async function ensureModelLoaded( sel: CompareModelSelection, ): Promise { - const external = parseExternalModelId(sel.id); - if (external) { - const externalStore = useExternalProvidersStore.getState(); - if (!externalStore.connectionsEnabled) { - throw new Error( - "Connections are disabled. Turn on Enable connections in Settings -> Connections to use hosted models.", - ); - } - const provider = externalStore.providers.find( - (p) => p.id === external.providerId, - ); - if (!provider) { - throw new Error( - "Connection not found. Open Settings -> Connections and add it again.", - ); - } - - const reasoningCaps = getExternalReasoningCapabilities( - provider.providerType, - external.modelId, - { - isReasoningProvider: provider.isReasoningModel === true, - baseUrl: provider.baseUrl ?? null, - }, - ); - const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch( - provider.providerType, - external.modelId, - provider.baseUrl, - ); - const supportsBuiltinCodeExecution = - providerSupportsBuiltinCodeExecution( - provider.providerType, - external.modelId, - provider.baseUrl, - ); - const supportsBuiltinImageGeneration = - providerSupportsBuiltinImageGeneration( - provider.providerType, - external.modelId, - provider.baseUrl, - ); - const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch( - provider.providerType, - ); - const currentStore = useChatRuntimeStore.getState(); - currentStore.setCheckpoint(sel.id, null); - useChatRuntimeStore.setState({ - activeGgufVariant: null, - ggufContextLength: null, - ggufMaxContextLength: null, - ggufNativeContextLength: null, - activeNativePathToken: null, - supportsReasoning: reasoningCaps.supportsReasoning, - reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn, - reasoningStyle: reasoningCaps.reasoningStyle, - supportsReasoningOff: reasoningCaps.supportsReasoningOff, - reasoningEffortLevels: reasoningCaps.reasoningEffortLevels, - supportsPreserveThinking: false, - supportsTools: false, - supportsBuiltinWebSearch, - supportsBuiltinCodeExecution, - supportsBuiltinImageGeneration, - supportsBuiltinWebFetch, - loadedIsMultimodal: - providerTypeSupportsVision(provider.providerType) === true, - }); - return "external"; - } - const currentStore = useChatRuntimeStore.getState(); const isAlreadyActive = currentStore.params.checkpoint === sel.id && @@ -932,19 +903,9 @@ export function SharedComposer({ const handle1 = handlesRef.current["model1"]; const handle2 = handlesRef.current["model2"]; - // Show user messages immediately on both sides and keep the ids - // so delayed model loads can start the run from the intended turn. - const [parentId1, parentId2] = await Promise.all([ - handle1 ? handle1.appendMessage(content) : Promise.resolve(null), - handle2 ? handle2.appendMessage(content) : Promise.resolve(null), - ]); - if ((handle1 && !parentId1) || (handle2 && !parentId2)) { - toast.error("Compare failed", { - description: - "The prompt could not be added to both compare panes. Try sending it again.", - }); - return; - } + // 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) : ""; @@ -966,7 +927,7 @@ export function SharedComposer({ duration: Infinity, }); const done = handle1.waitForRunEnd(); - handle1.startRun(parentId1); + handle1.startRun(); await done; } @@ -989,7 +950,7 @@ export function SharedComposer({ duration: Infinity, }); const done = handle2.waitForRunEnd(); - handle2.startRun(parentId2); + handle2.startRun(); await done; } @@ -1050,7 +1011,7 @@ export function SharedComposer({ return (
{ if (isTauri) return; e.preventDefault(); @@ -1066,6 +1027,17 @@ export function SharedComposer({ addFiles(e.dataTransfer.files); }} > + {/* Gemini-style drop affordance, mirrored from the single composer. */} +
+ + Drop files here +
{(pendingImages.length > 0 || pendingAudio) && (
{pendingImages.map(({ id, file }) => ( @@ -1126,7 +1098,10 @@ export function SharedComposer({ dir="auto" />
-
+
- { - // 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(); + { + addFiles(e.target.files); + e.target.value = ""; }} - aria-label="Add Attachment" - > - - - {activeModel?.hasAudioInput && ( - <> - { - addFiles(e.target.files); - e.target.value = ""; - }} - /> - audioInputRef.current?.click()} - aria-label="Upload audio" + /> + + {/* Same + side menu as the single-chat composer (ComposerToolsMenu), + wired to the compare composer's own file/audio inputs and tools. */} + + + + + event.preventDefault()} + > + fileInputRef.current?.click()}> + + Add photos & files + + {activeModel?.hasAudioInput && ( + audioInputRef.current?.click()} + > + + Upload audio + + )} + { + const next = !toolsEnabled; + setToolsEnabled(next); + // Mirror the Search pill: Kimi forbids search + thinking together. + if (isKimiExternal) { + setReasoningEnabled(!next, { persist: false }); + applyQwenThinkingParams(!next); + } + }} + > + + Web search + {toolsEnabled && !searchDisabled ? ( + + ) : null} + + setCodeToolsEnabled(!codeToolsEnabled)} + > + + Code + {codeToolsEnabled && !codeDisabled ? ( + + ) : null} + + {showImagePill && ( + setImageToolsEnabled(!imageToolsEnabled)} + > + + Images + {imageToolsEnabled && !imageDisabled ? ( + + ) : null} + + )} + + setArtifactsEnabled(!artifactsEnabled)} + > + + Canvas + {artifactsEnabled ? : null} + + setMcpEnabledForChat(!mcpEnabledForChat)} + > + + MCP + {mcpEnabledForChat ? : null} + + {/* RAG hidden temporarily */} + {/* Always active: this menu only renders in compare mode. + Ticked like Web search/Code; click toggles it off. */} + + + Compare chat + + + + + + + Projects + + + setNewProjectOpen(true)}> + + New project + + Recents + {recentProjects.length > 0 ? ( + recentProjects.map((project) => ( + openProject(project.id)} + > + + {project.name} + + )) + ) : ( + + No recent projects + + )} + + + + + {/* Active in compare mode; sits first. Click to exit back to single chat. */} + + + + {showImagePill && ( + )} + {showWebFetchPill && ( + + )} + {artifactsEnabled ? ( + + ) : null} + {mcpEnabledForChat ? : null} +
+ {/* mr-0.5 matches the send button inset from the edge in normal chat; + gap-1.5 matches its control spacing. */} +
{showReasoningControl ? ( - effectiveReasoningStyle === "reasoning_effort" ? ( + isEffort || supportsPreserveThinking ? ( + + + {isEffort ? ( + <> + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + // Preserve thinking needs thinking on, so turn it off too. + setPreserveThinking(false); + }} + > + + {formatReasoningDisabledLabel( effectiveSupportsReasoningOff, isExternalOpenAIReasoning, checkpoint, )} - - - - - {effectiveSupportsReasoningOff && ( - { - setReasoningEnabled(false); - applyQwenThinkingParams(false); - }} - > - {formatReasoningDisabledLabel( - effectiveSupportsReasoningOff, - isExternalOpenAIReasoning, - checkpoint, + )} - {!effectiveReasoningVisualEnabled ? " \u2713" : ""} - - )} - {effectiveReasoningEffortLevels - .filter((level) => level !== "none") - .map((level) => ( + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( + { + 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, + )} + + ))} + + ) : ( + effectiveSupportsReasoningOff && + !reasoningLockedOn && ( { - setReasoningEffort(level); - setReasoningEnabled(true); - applyQwenThinkingParams(true); - // Mutual exclusion: turning thinking on for a - // Kimi model forces the web_search builtin off. - if (isKimiExternal && toolsEnabled) { + const next = !reasoningEnabled; + setReasoningEnabled(next); + applyQwenThinkingParams(next); + // Preserve thinking cannot run without thinking. + if (!next) setPreserveThinking(false); + if (isKimiExternal && next && toolsEnabled) { setToolsEnabled(false, { persist: false }); } }} > - {formatReasoningEffortLabel( - level, - externalSelection?.modelId, - )} - {effectiveReasoningVisualEnabled && - reasoningEffort === level - ? " \u2713" - : ""} + + Thinking - ))} + ) + )} + {supportsPreserveThinking && ( + { + e.preventDefault(); + const next = !preserveThinking; + setPreserveThinking(next); + // Preserve thinking requires thinking on. + if (next) { + setReasoningEnabled(true); + applyQwenThinkingParams(true); + } + }} + > + + Preserve thinking + + )} ) : ( @@ -1287,16 +1571,8 @@ export function SharedComposer({ 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 - ? "cursor-pointer text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]" - : "cursor-pointer hover:bg-primary/10 dark:hover:bg-white/[0.08]", - )} + className="unsloth-thinking-pill" + data-active={thinkingActiveLook ? "true" : "false"} aria-label={thinkToggleAriaLabel({ reasoningLockedOn, modelLoaded, @@ -1304,141 +1580,13 @@ export function SharedComposer({ effectiveReasoningEnabled, })} > - {reasoningLockedOn || - (effectiveReasoningEnabled && !reasoningDisabled) ? ( - - ) : ( - - )} - Think + + + + {thinkingActiveLook ? Thinking : null} ) ) : null} - {supportsPreserveThinking && ( - - )} - - - {showImagePill && ( - - )} - - - {showWebFetchPill && ( - - )} -
-
{dictationSupported && ( <> {!isDictating ? ( @@ -1473,7 +1621,7 @@ export function SharedComposer({ type="button" variant="default" size="icon" - className="size-8 rounded-full" + className="ml-1.5 size-8 rounded-full" onClick={stop} > @@ -1484,12 +1632,12 @@ export function SharedComposer({ side="bottom" variant="default" size="icon" - className="size-8 rounded-full" + className="ml-1.5 size-8 rounded-full" onClick={send} disabled={!canSend} aria-label="Send message" > - + )}
diff --git a/studio/frontend/src/features/chat/tour/steps.tsx b/studio/frontend/src/features/chat/tour/steps.tsx index e222fdc8a0..e4f37685cc 100644 --- a/studio/frontend/src/features/chat/tour/steps.tsx +++ b/studio/frontend/src/features/chat/tour/steps.tsx @@ -9,7 +9,6 @@ export function buildChatTourSteps({ closeModelSelector, openSettings, closeSettings, - openSidebar, enterCompare, exitCompare, }: { @@ -18,7 +17,6 @@ export function buildChatTourSteps({ closeModelSelector: () => void; openSettings: () => void; closeSettings: () => void; - openSidebar: () => void; enterCompare: () => void; exitCompare: () => void; }): TourStep[] { @@ -64,33 +62,22 @@ export function buildChatTourSteps({ ]; if (canCompare) { - steps.push( - { - id: "compare-btn", - target: "chat-compare", - title: "Compare mode", - body: ( - <> - Compare any two models side-by-side. - Pick a different model for each side and see how they respond to the same prompt. - - ), - onEnter: openSidebar, - }, - { - id: "compare-view", - target: "chat-compare-view", - title: "Side-by-side threads", - body: ( - <> - Same prompt, 2 threads. If LoRA is worse than base, it’s usually - data formatting, too many epochs, or a bad checkpoint choice. - - ), - onEnter: enterCompare, - onExit: exitCompare, - }, - ); + // Compare now lives in the + menu, so there is no sidebar button to anchor + // to; the view step enters compare on its own and explains it. + steps.push({ + id: "compare-view", + target: "chat-compare-view", + title: "Side-by-side threads", + body: ( + <> + Compare any two models side-by-side, available from the + menu. Same + prompt, 2 threads. If LoRA is worse than base, it’s usually data + formatting, too many epochs, or a bad checkpoint choice. + + ), + onEnter: enterCompare, + onExit: exitCompare, + }); } return steps; diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index ed590f226e..3b9b60e5dd 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -7,7 +7,8 @@ import { Label } from "@/components/ui/label"; import { getAuthToken } from "@/features/auth"; import { useT } from "@/i18n"; import { toastError, toastSuccess } from "@/shared/toast"; -import { Camera } from "lucide-react"; +import { Camera01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { useMemo, useRef, useState } from "react"; import { decodeJwtSubject } from "../utils/jwt-subject"; import { resizeImageFileToDataUrl } from "../utils/resize-image-file"; @@ -117,10 +118,10 @@ export function ProfilePersonalizationPanel() {
diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index b6f2463b68..a1048b202e 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -128,8 +128,10 @@ export function SettingsDialog() { // Cap at 820px but shrink to the viewport so we don't clip // on iPad-portrait widths (640-820px) where the fixed // `w-[820px]` overflows by 26px on each side. - "!max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden", - "shadow-border rounded-xl border-border", + "settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden", + // Soft shadow only, no outline ring. Pin --radius to the light value + // so the corner rounding is the same in dark mode. + "shadow-border rounded-xl ring-0 [--radius:1.1rem]", "max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none", )} > @@ -138,7 +140,10 @@ export function SettingsDialog() { {t("settings.dialog.description")}
-