diff --git a/studio/backend/main.py b/studio/backend/main.py index d5016980ba..10fbbfddf0 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -515,7 +515,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault( "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", + "camera=(), microphone=(self), geolocation=()", ) response.headers["server"] = "unsloth-studio" return response diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index 85b82f1011..5e5b5a3c3b 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -260,7 +260,10 @@ class TestSecurityHeadersMiddleware: assert r.headers["x-frame-options"] == "DENY" assert r.headers["x-content-type-options"] == "nosniff" assert r.headers["referrer-policy"] == "no-referrer" - assert "camera=()" in r.headers["permissions-policy"] + permissions_policy = r.headers["permissions-policy"] + assert "camera=()" in permissions_policy + assert "microphone=(self)" in permissions_policy + assert "geolocation=()" in permissions_policy assert r.headers["server"] == "unsloth-studio" def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module): diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 16292bd6e4..941c2e8a74 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -46,12 +46,12 @@ import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { ChefHatIcon, - ColumnInsertIcon, CursorInfo02Icon, Delete02Icon, DownloadSquare01Icon, Edit03Icon, FolderAddIcon, + FolderExportIcon, Folder01Icon, Globe02Icon, HelpCircleIcon, @@ -192,6 +192,7 @@ function NavItem({ onClick, children, dataTour, + className, }: { icon: typeof ZapIcon; label: string; @@ -200,9 +201,10 @@ function NavItem({ onClick: () => void; children?: ReactNode; dataTour?: string; + className?: string; }) { return ( - +
{label} @@ -550,7 +552,7 @@ export function AppSidebar() { ? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto" : "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"; const buttonClass = cn( - "sidebar-nav-btn h-[33px] cursor-pointer rounded-[10px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", + "sidebar-nav-btn h-[33px] cursor-pointer rounded-[11px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", variant === "project" ? "pl-[37px]" : "pl-2.5", variant === "project" ? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" @@ -599,9 +601,9 @@ export function AppSidebar() { openRenameChat(item)}> @@ -609,13 +611,13 @@ export function AppSidebar() { - + Move to project { @@ -754,25 +756,6 @@ export function AppSidebar() { disabled={chatDisabled} onClick={() => openNewChat(null)} /> - i.id === search.compare) - } - disabled={chatDisabled} - dataTour="chat-compare" - onClick={() => { - if (chatDisabled) return; - setActiveThreadId(null); - navigate({ - to: "/chat", - search: { compare: createNavigationNonce() }, - }); - closeMobileIfOpen(); - }} - /> + {/* Train has its own labelled section when expanded; surface it as + a plain icon here only while the sidebar is collapsed. */} + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + className="hidden group-data-[collapsible=icon]:block" + /> @@ -822,7 +821,7 @@ export function AppSidebar() { {t("shell.navigation.train")} - + @@ -871,7 +870,7 @@ export function AppSidebar() { {t("shell.navigation.recents")} - + @@ -893,7 +892,7 @@ export function AppSidebar() { {t("shell.navigation.recents")} - + @@ -917,7 +916,7 @@ export function AppSidebar() { > { setSelectedHistoryRunId(run.id); closeMobileIfOpen(); @@ -1007,14 +1006,14 @@ export function AppSidebar() {
@@ -1026,8 +1025,9 @@ export function AppSidebar() { { return (
- +
); }; diff --git a/studio/frontend/src/components/assistant-ui/audio-player.tsx b/studio/frontend/src/components/assistant-ui/audio-player.tsx index 6b99de8374..8836eddff5 100644 --- a/studio/frontend/src/components/assistant-ui/audio-player.tsx +++ b/studio/frontend/src/components/assistant-ui/audio-player.tsx @@ -4,7 +4,9 @@ "use client"; import { Button } from "@/components/ui/button"; -import { DownloadIcon, PauseIcon, PlayIcon } from "lucide-react"; +import { PauseIcon, PlayIcon } from "lucide-react"; +import { Download01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type FC, useRef, useState } from "react"; interface AudioPlayerProps { @@ -110,7 +112,7 @@ export const AudioPlayer: FC = ({ src }) => { onClick={handleDownload} title="Download audio" > - +
); 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")}
-