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 <a> inside a <button>. Also add cursor-pointer to the composer pills and the thinking pill so hovering a clickable pill shows the hand cursor instead of the default arrow. * Studio: refine chat composer and add compare-mode parity - Composer expands to two rows only once the input wraps to a second line, not on the first keystroke. Re-measure the autosize textarea on the width swap so expanding no longer leaves a stray blank row. - Light-mode composer shadow now matches Gemini's soft elevation. - Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG) and add Code above MCP. Active Web search/Code items use medium weight. - Compare mode: the plus side menu, Search/Code toggles, and a Compare exit pill now match single chat, with the thinking control on the right. - Projects menu entries link to their tracking PR (#5725). - Add cursor-pointer to the composer plus button. * studio: refine composer controls and chat search shadow - Active tool pills show an x on hover to signal click-to-disable - Plus button rotates into an x when the tools menu opens - Composer surface uses a 32px radius and a taller single-line height - Even, ChatGPT-style spacing between the plus and tool pills in both the single and compare composers - Send and mic circles resized and spaced, with the arrow centered in the circle - Chat search box gets a borderless, soft Gemini-style shadow * studio: size the pill hover x to match the icon it replaces Cross-engine checks (Chromium, Firefox, WebKit) flagged the active-pill hover x as a fixed 14px, so it popped smaller than the 19px Code icon. Fill the glyph slot instead so the x tracks whatever icon it covers. * studio: do not persist Kimi search/thinking mutual-exclusion in single composer The single-chat composer flipped the other control off when toggling search or thinking on Kimi, but without { persist: false }, so it overwrote the user's saved preference. Match shared-composer and keep the side effect session-only. * studio: pointer cursor on model selector trigger and menu items Add scoped marker classes so the model picker trigger and every clickable element in its menu (tabs, model rows, delete, eject) show a pointer cursor; disabled items stay not-allowed. * studio: pass baseUrl when resolving reasoning caps in single composer The docked composer omitted baseUrl, so a custom Gemini OpenAI-compat gateway still advertised the native thinking ladder the backend cannot honor. Pass selectedExternalProvider.baseUrl like the compare composer so the resolver hides it. * studio: grey side-menu hover, green pill hover, thinking hover x - Plus side-menu items hover grey in light mode, not the green accent - Thinking pill hovers green like the Search and Code pills - The plain Thinking toggle shows an x on hover when active, matching Search and Code; the effort dropdown trigger keeps its bulb * studio: make the pill hover x a uniform size The x filled the icon slot, so the wider Code chevron gave a bigger x than Search and Compare. Pin it to a fixed 15px, centered, so every pill's x matches. * studio: broaden chat attachments, fix active hover color, gemini shadow - Accept svg, source code and many text/config files as drag-and-drop or picked attachments, matched by extension since their MIME is unreliable; html keeps its own adapter - Active (green) side-menu items keep their text and icon color on hover instead of switching to the accent color - Composer surface uses Gemini's soft centered shadow 0 0 20px rgba(0,0,0,0.04) * studio: keep the thinking pill full height when icon-only The inactive thinking pill has no label, so its flex row collapsed to the icon height and the hover box looked short. Reserve one text line (min-height: 1lh + padding) so it matches the Search and Code pills. * studio: refine composer menu, drop overlay and greetings - Open the MCP servers dialog directly from the composer plus menu - Redesign the drag-and-drop affordance Gemini style, drop the badge and border, make the whole chat page a drop target - Swap in Hugeicons for the RAG, attachment chip and new project icons - Add time-based randomized welcome greetings, each matched to a fitting sloth * studio: rename artifacts toggle to Canvas and make it opt-in - Label the toggle Canvas everywhere, matching the plus menu - Stop greying out the Canvas menu item; it toggles like the other items - Only show the Canvas pill in the composer row once it is turned on, since it is less central than Search and Code * studio: wire Canvas and MCP composer toggles, even out the pill row - Open the MCP servers dialog from the menu, or toggle MCP on/off once a server is enabled - Force MCP off when no server is enabled, so the toggle stays honest - Show Canvas and MCP as opt-in pills that appear in the order they were toggled on - Expand the composer and light up the pill when Canvas or MCP is on, like Search and Code - Keep Compare directly after Code in the compare composer - Use the same Code icon on both composers and give every pill an even icon slot * studio: tidy composer toggle row and fix MCP enable/disable lifecycle - Enable MCP automatically after a server is configured via the toggle flow - Force MCP off everywhere once the last enabled server is removed - Collapse the pill labels to icons only when more than 4 pills show, keeping Compare labelled - Order Compare first in compare mode, before Search and Code - Use the same Code icon and an even 19px icon slot across both composers - Match the compare composer surface padding and send button inset to normal chat * studio: revert compare composer padding change that cramped the input Matching the surface padding to normal chat clipped the textarea text and left a white strip on top. Restore the compare composer's own padding, which gives proper top spacing. The send button inset fix stays. * studio: center welcome greeting and soften composer scrollbar Center the sloth and title together over the composer instead of shifting the row left, which left the greeting sitting off to the side. Keep the composer textarea scroll thumb faint by default and only darken it when the thumb is hovered or dragged, so a tall draft no longer shows a heavy dark rail. * studio: match composer plus-menu tool gating to the pills The new plus-menu tool entries did not carry the gating the visible pills already enforce, so the menu and pills could disagree about a loaded model's capabilities. - Web search and Code menu items now disable when a loaded model lacks the capability, while still allowing preselection with no model loaded. - Enabling Web search from the menu on a Kimi model now flips thinking off as a session-only change, since Kimi forbids search and thinking together. This matches the Search pill. - Added an Images menu item, shown only for image-generation models and disabled until a model loads, so a short prompt has an entry point. Applied to both the single-chat and compare composers. * studio: round the active-pill hover x and even out pill padding The hover x sat bare and the trailing label was tighter to the pill edge than the leading icon, so the pill looked lopsided. - Give the hover x a soft circular background that fills the icon slot, matching the ChatGPT-style toggle and the icon it replaces. - Add a little more trailing padding so the label and the leading icon have even breathing room, and keep icon-only compact pills symmetric. * studio: nudge the thinking bulb icon up by 0.5px Bump the thinking lightbulb from 15px to 15.5px in the single-chat and compare composers so it sits a touch larger next to the other controls. * studio: drop the hover x circle on icon-only pills When pills collapse to icon-only, the circle around the hover x is too cramped in the small chip, so show a bare x there and keep the circle only on the full-width labelled pills. * studio: space the compare send button like normal chat In compare mode the Thinking control sat right against the send button. Match the normal composer's control spacing (gap-1.5 plus a send margin) so Thinking has the same breathing room before send. The send button keeps its 14px inset, so its position is unchanged. * studio: make collapsed pill hover a circle, not a wide pill Icon-only pills were wider than tall, so their rounded-full hover highlight read as a fat rounded rectangle. Make the compact button a square and center the glyph so the hover (and the x it reveals) sits in a clean circle. * studio: fix compare pane drops and audio picker lifetime - Skip the page-level drop handler when the composer is hidden, so files dropped on a compare pane are not swallowed by a hidden composer; the shared compare composer keeps handling drops through its own dropzone. - Build the audio file input on document.body instead of inside the plus menu, so the menu closing on select no longer unmounts the input before the OS picker returns and drops the file. * studio/chat: stop projects list from white-screening on older backends The projects list API returned data.projects directly, so a backend that omits the field handed back undefined. useChatProjects cached that value, then the next mount read undefined.length and crashed the whole chat page. Default the projects and threads list APIs to an empty array and keep the hook null-safe so a bad response can never poison the cache. * studio/chat: align MCP dropdown with the + menu and add a chevron Reuse the + menu surface (unsloth-plus-menu) for the MCP dropdown: rounded corners, narrower width, neutral grey hover, and enabled rows shown as green text with a right-aligned check instead of the emerald underlay. Add a chevron to the MCP pill so it reads as openable, matching the Thinking pill. * studio/chat: make MCP an opt-in pill and fix its dropdown placement - MCP is back in the + menu as a toggle. The pill now only shows in the composer when MCP is on, matching Canvas, instead of always sitting there. - The dropdown follows the composer side like the + menu (opens down in the welcome composer, up when docked) rather than always opening upward. - Drop the dropdown caret when pills collapse so the icon is not squished. - Stop force-syncing mcpEnabledForChat to the server count; the + menu owns it. * studio/chat: MCP expands the composer, drop sidebar Compare, tidy scrollbars - Toggling MCP now expands the composer and shows the tool pills, the same as Canvas, instead of leaving the row collapsed. - Remove the Compare item from the sidebar now that it lives in the + menu, and point the compare tour step at the side-by-side view instead of the old button. - Both sidebars only show their scrollbar on hover, and run settings reserves the scrollbar gutter so the close button no longer shifts when it appears. * studio/chat: tighten toggle gap, fix run-settings close button, collapsed Train - Reduce the composer toggle gap by 2px (gap-1 to gap-0.5) in both composers. - Move the run settings header out of the scroll area so the close button keeps its position whether or not the scrollbar shows, and sits flush with the topbar open button again instead of shifting left. - Surface Train as an icon in the collapsed sidebar (it already has a labelled section when expanded). * studio/chat: tighten Thinking pill X padding, create projects inline - The Thinking pill used px-2.5, so the hover X sat further in than the left pills. Match their pl-2 so the X lines up. - The + menu New project now opens a create dialog and jumps straight to the new project, instead of routing to the projects list. Shared by both composers via a small NewProjectDialog. * studio/chat: soften account menu, hover scrollbars, show collapsed chevrons - Account menu drops its border ring for the composer's soft shadow and opens centered over its trigger. - Settings and search reuse the hover-only scrollbar via a shared hover-scrollbar class, matching the sidebars. - Train and Recents keep their chevron visible while collapsed so it is clear they can be expanded. * studio/chat: roomier, more rounded account menu Widen the account menu, add more left and right padding on the rows, bump the row height and text a touch, and round the corners more, closer to the GPT account menu. * studio/chat: trim account menu width and nudge it up 2px Pull the account menu in slightly on the left and right (narrower box, a touch less row padding) and lift it 2px higher above the trigger. * studio/settings: drop outline ring, circular close hover, pointer cursors - Remove the settings dialog outline ring, keeping just the soft shadow. - The close button hover is now a circle instead of a rounded rectangle. - Every clickable control in the settings dialog uses a pointer cursor. * studio/chat: bump MCP pill icon to 14.5px Nudge the MCP icon up 0.5px so it sits even with the other pill glyphs. * studio/chat: bump MCP pill icon to 15px Nudge the MCP icon up another 0.5px. * studio/settings: add a Settings title above the tabs Put a Settings heading at the top of the sidebar so the tabs sit below it, matching the Claude settings layout. Hidden on mobile where the nav is a row. * studio/settings: rounder tab hover, bigger title, less-round search dialog * studio/sidebar: round nav row hover boxes 2px more (10px to 12px) * studio: drop settings dark shadow + divider, add tab left padding, tune hover roundness * studio/model-selector: roomier padding, borderless box, rounder hover rows; settings divider light-only * studio/search: match chat box shadow (soft light, none dark) * studio/sidebar: borderless chat context menus, rename submenu to Projects with folder-export icon * studio/model-selector: match light corner radius in dark, drop dark shadow, more visible dark hover * studio/sidebar: chat context menu matches + side menu styling; relabel submenu Move to project * studio: borderless message export menu (no dark shadow), match dark corner radius to light on export menu and settings * studio/sidebar: open chat options menu GPT-style (down-right) and widen so Move to project fits one line * studio/chat: message export menu uses the chatbox shadow in light mode * studio/sidebar: narrow chat options menu slightly (w-60 to w-56) * studio: unify all download icons to Hugeicons download-01; round profile button hover 1px more * studio/run-settings: bump header to 16px * studio/sidebar: trim chat options menu width slightly (w-56 to 216px) * studio/sidebar: trim chat options menu width to w-52 * studio/profile: camera-01 Hugeicons glyph and chatbox shadow on avatar button * studio: match dark-mode corner radius to light globally (single --radius token) * studio/recipes: borderless New Recipe menu with chatbox shadow in light, none in dark * studio: borderless dropdowns globally, chatbox shadow in light, none in dark * studio: extend borderless + chatbox/none shadow to select, combobox and popover overlays * studio/mcp: nudge MCP dropdown radius to 20px so its wider box reads as round as the + menu * studio: restore dark dropdown shadow to avoid same-color merge; greet name ~1/3 of lines; bigger sloth + more gap * studio/train: active tab is a borderless pill (no underline), roomier padding, more tab gap and bottom spacing * studio/chat: nudge welcome up ~5px (still vh-based) and trim sloth image to 44px * studio/train: active tab pill is white with chatbox shadow in light, taller padding * studio/chat: welcome offset to calc(30vh - 10px) * studio/chat: welcome offset to 28vh (drop the -10px) * studio/chat: tighten sloth-to-text gap by 1px (16px to 15px) * studio/train: revert light active pill to grey fill, drop white bg + shadow * studio: app-wide hand cursor on every clickable control (disabled excluded) * studio/chat: welcome offset to 26vh * studio/chat: welcome offset to 28vh * studio/chat: harden project and thread list guards against non-array payloads * studio/sidebar: give the profile row more height and breathing room * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: reduce Train and Recents section label size slightly * studio/sidebar: trim the profile row top and bottom padding a touch more * studio/sidebar: enlarge the profile hover area top and bottom * studio/sidebar: increase profile hover roundness by 1px * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: trim the profile row top and bottom padding slightly * studio/chat: cache composer line metrics so wrap detection runs once, not per keystroke * studio/chat: restore the prior view when exiting compare opened from the + menu * studio/tests: drive Compare from the composer + menu after it moved out of the sidebar * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio/tests: open Compare from the composer + menu in the extra UI suite too * studio: fix chat dictation microphone access * studio: snappier plus-to-x spin and steady composer expand gap Speed up the composer plus icon morph from 480ms to 300ms. Add row-gap on the expanded composer line so the space between the text and the controls row stays the same whether the box expanded from wrapped text or from a toggle being on. The gap sits on the line, not the input, so the placeholder max-height clamp never crops it. * studio: only show composer tool pills once a model is loaded Persisted Search/Code/Canvas/MCP toggles were surfacing the composer pill row on a fresh page load before any model was selected, so an empty composer looked different from the clean just-ejected state. Gate the composerExpanded tool checks on modelLoaded so a model-less composer stays collapsed, while saved preferences still apply the moment a model loads. * studio: hide RAG composer menu item temporarily Hide the placeholder RAG entry from the composer plus menu in both single chat and compare until the feature is ready, and drop the now-unused DatabaseIcon import. * studio: let composer tools pre-select before a model loads Selecting Web search, Code, Canvas or MCP from the + menu with no model loaded did nothing visible: the toggle turned on but the composer never expanded, so the pill stayed hidden. Drop the model-loaded gate from the expand check so an active tool always surfaces its pill. Align MCP with the Search/Code pattern too: grey it out only when a loaded model lacks tool support, so MCP stays toggleable and the pill stays clickable before a model is loaded instead of looking disabled. --------- Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
parent
5777ccd03e
commit
37fd76a02f
38 changed files with 2628 additions and 962 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem className={className}>
|
||||
<div className="relative">
|
||||
<SidebarMenuButton
|
||||
tooltip={label}
|
||||
|
|
@ -210,7 +212,7 @@ function NavItem({
|
|||
onClick={onClick}
|
||||
isActive={active}
|
||||
data-tour={dataTour}
|
||||
className="sidebar-nav-btn h-[33px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
|
||||
className="sidebar-nav-btn h-[33px] rounded-[11px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
|
||||
>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
|
||||
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
|
||||
|
|
@ -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() {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="unsloth-plus-menu menu-flat-destructive w-52"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
|
|
@ -609,13 +611,13 @@ export function AppSidebar() {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<HugeiconsIcon icon={FolderExportIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Move to project</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
sideOffset={8}
|
||||
alignOffset={-4}
|
||||
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-56 py-2 font-heading rounded-[14px] border-0"
|
||||
className="unsloth-plus-menu w-52"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
|
|
@ -754,25 +756,6 @@ export function AppSidebar() {
|
|||
disabled={chatDisabled}
|
||||
onClick={() => openNewChat(null)}
|
||||
/>
|
||||
<NavItem
|
||||
icon={ColumnInsertIcon}
|
||||
label={t("shell.navigation.compare")}
|
||||
active={
|
||||
!!search.compare &&
|
||||
!chatItems.some((i) => i.id === search.compare)
|
||||
}
|
||||
disabled={chatDisabled}
|
||||
dataTour="chat-compare"
|
||||
onClick={() => {
|
||||
if (chatDisabled) return;
|
||||
setActiveThreadId(null);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { compare: createNavigationNonce() },
|
||||
});
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Search01Icon}
|
||||
label={t("shell.navigation.search")}
|
||||
|
|
@ -813,6 +796,22 @@ export function AppSidebar() {
|
|||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
{/* Train has its own labelled section when expanded; surface it as
|
||||
a plain icon here only while the sidebar is collapsed. */}
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label={t("shell.navigation.train")}
|
||||
active={
|
||||
pathname === "/studio" || pathname.startsWith("/studio/")
|
||||
}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
className="hidden group-data-[collapsible=icon]:block"
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
|
@ -822,7 +821,7 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
||||
{t("shell.navigation.train")}
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -871,7 +870,7 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -893,7 +892,7 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -917,7 +916,7 @@ export function AppSidebar() {
|
|||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActiveRun}
|
||||
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[10px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
|
||||
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[11px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
|
||||
onClick={() => {
|
||||
setSelectedHistoryRunId(run.id);
|
||||
closeMobileIfOpen();
|
||||
|
|
@ -1007,14 +1006,14 @@ export function AppSidebar() {
|
|||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={t("shell.accountMenu", { name: displayTitle })}
|
||||
className="sidebar-nav-btn !h-[40px] gap-[8px] px-2 py-[5px] rounded-[10px]"
|
||||
className="sidebar-nav-btn !h-[44px] gap-[9px] px-2 py-[3px] rounded-[14px]"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<UserAvatar
|
||||
name={displayTitle}
|
||||
imageUrl={avatarDataUrl}
|
||||
size="sm"
|
||||
className="!size-[30px]"
|
||||
className="!size-[32px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
|
||||
|
|
@ -1026,8 +1025,9 @@ export function AppSidebar() {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
|
||||
align="center"
|
||||
sideOffset={6}
|
||||
className="app-user-menu menu-soft-surface-up ring-0 w-[16rem] px-1.5 py-2.5 font-heading rounded-[20px] border-0"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import {
|
|||
useAui,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, PlusIcon, XIcon } from "lucide-react";
|
||||
import { File02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
|
|
@ -134,7 +136,11 @@ const AttachmentThumb: FC = () => {
|
|||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FileText className="size-6 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={File02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AudioPlayerProps> = ({ src }) => {
|
|||
onClick={handleDownload}
|
||||
title="Download audio"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
|||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Copy01Icon, Download01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
|
|
@ -279,7 +278,7 @@ function CodeBlockActions({
|
|||
downloadTextFile(getCodeFilename(language), source);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-icon" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
|
@ -385,7 +385,7 @@ function GgufVariantExpander({
|
|||
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[6px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
|
||||
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[8px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#3a3d44]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
|
|
@ -925,7 +925,7 @@ export function HubModelPicker({
|
|||
(!chatOnly && cachedModels.length > 0)) ? (
|
||||
<>
|
||||
<ListLabel
|
||||
icon={<DownloadIcon className="size-3" />}
|
||||
icon={<HugeiconsIcon icon={Download01Icon} className="size-3" />}
|
||||
collapsed={downloadedCollapsed}
|
||||
onToggle={() => setDownloadedCollapsed((v) => !v)}
|
||||
>Downloaded</ListLabel>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ComboboxPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-[120] pointer-events-auto"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-[120] pointer-events-auto"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) 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-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
|
@ -33,7 +33,7 @@ function PopoverContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 flex flex-col gap-4 rounded-lg p-4 text-sm duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
|
||||
"bg-popover text-popover-foreground 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 flex flex-col gap-4 rounded-lg p-4 text-sm duration-100 z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ function SelectContent({
|
|||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
"bg-popover text-popover-foreground 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 min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
{
|
||||
"--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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<motion.span
|
||||
layoutId={`tab-bg-${ctx.id}`}
|
||||
className="absolute inset-0 rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input"
|
||||
className="absolute inset-0 rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
// 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 type { DictationAdapter } from "@assistant-ui/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const getSpeechRecognitionAPI = (): SpeechRecognitionConstructor | undefined => {
|
||||
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<void>((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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -388,6 +388,7 @@ const CompareContent = memo(function CompareContent({
|
|||
onFoldersChange,
|
||||
onModelsChange,
|
||||
deleteDisabled,
|
||||
onExitCompare,
|
||||
}: {
|
||||
pairId: string;
|
||||
projectId?: string | null;
|
||||
|
|
@ -397,11 +398,16 @@ const CompareContent = memo(function CompareContent({
|
|||
onFoldersChange?: () => void;
|
||||
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
|
||||
deleteDisabled?: boolean;
|
||||
onExitCompare?: () => void;
|
||||
}): ReactElement {
|
||||
const isLoraCompare = useIsLoraCompare();
|
||||
|
||||
return isLoraCompare ? (
|
||||
<LoraCompareContent pairId={pairId} projectId={projectId} />
|
||||
<LoraCompareContent
|
||||
pairId={pairId}
|
||||
onExitCompare={onExitCompare}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<GeneralCompareContent
|
||||
pairId={pairId}
|
||||
|
|
@ -412,6 +418,7 @@ const CompareContent = memo(function CompareContent({
|
|||
onFoldersChange={onFoldersChange}
|
||||
onModelsChange={onModelsChange}
|
||||
deleteDisabled={deleteDisabled}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -512,8 +519,13 @@ function CompareShell({
|
|||
/** Fast path: same model, adapter on/off, simultaneous generation. */
|
||||
const LoraCompareContent = memo(function LoraCompareContent({
|
||||
pairId,
|
||||
onExitCompare,
|
||||
projectId,
|
||||
}: { pairId: string; projectId?: string | null }): ReactElement {
|
||||
}: {
|
||||
pairId: string;
|
||||
onExitCompare?: () => void;
|
||||
projectId?: string | null;
|
||||
}): ReactElement {
|
||||
const handlesRef = useRef<Record<string, CompareHandle>>({});
|
||||
const [baseThreadId, setBaseThreadId] = useState<string>();
|
||||
const [loraThreadId, setLoraThreadId] = useState<string>();
|
||||
|
|
@ -539,7 +551,12 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
return (
|
||||
<CompareShell
|
||||
handlesRef={handlesRef}
|
||||
composer={<SharedComposer handlesRef={handlesRef} />}
|
||||
composer={
|
||||
<SharedComposer
|
||||
handlesRef={handlesRef}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<ComparePane
|
||||
|
|
@ -639,6 +656,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
onFoldersChange,
|
||||
onModelsChange,
|
||||
deleteDisabled,
|
||||
onExitCompare,
|
||||
}: {
|
||||
pairId: string;
|
||||
projectId?: string | null;
|
||||
|
|
@ -648,6 +666,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
onFoldersChange?: () => void;
|
||||
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
|
||||
deleteDisabled?: boolean;
|
||||
onExitCompare?: () => void;
|
||||
}): ReactElement {
|
||||
const handlesRef = useRef<Record<string, CompareHandle>>({});
|
||||
const [model1ThreadId, setModel1ThreadId] = useState<string>();
|
||||
|
|
@ -712,6 +731,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
handlesRef={handlesRef}
|
||||
model1={model1}
|
||||
model2={model2}
|
||||
onExitCompare={onExitCompare}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -1067,6 +1087,15 @@ export function ChatPage(): ReactElement {
|
|||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
|
||||
// Tracks the latest non-compare view so exiting compare can restore it even
|
||||
// when compare was opened from a path that does not set viewBeforeCompareRef
|
||||
// (e.g. the composer + menu).
|
||||
const lastNonCompareViewRef = useRef<ChatSearch | null>(null);
|
||||
useEffect(() => {
|
||||
if (!search.compare) {
|
||||
lastNonCompareViewRef.current = { ...search };
|
||||
}
|
||||
}, [search]);
|
||||
const inferenceParams = useChatRuntimeStore((state) => state.params);
|
||||
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
|
||||
const activeGgufVariant = useChatRuntimeStore(
|
||||
|
|
@ -1768,8 +1797,7 @@ export function ChatPage(): ReactElement {
|
|||
() => setSettingsOpen(false),
|
||||
[setSettingsOpen],
|
||||
);
|
||||
const { setPinned, isMobile } = useSidebar();
|
||||
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const enterCompare = useCallback(() => {
|
||||
viewBeforeCompareRef.current = { ...search };
|
||||
|
|
@ -1785,8 +1813,14 @@ export function ChatPage(): ReactElement {
|
|||
}, [currentProjectId, navigate, search]);
|
||||
|
||||
const exitCompare = useCallback(() => {
|
||||
const saved = viewBeforeCompareRef.current;
|
||||
if (!saved) return;
|
||||
// Prefer the explicit save; fall back to the last non-compare view so the
|
||||
// composer + menu path also returns to where the user started.
|
||||
const saved = viewBeforeCompareRef.current ?? lastNonCompareViewRef.current;
|
||||
// No saved view (compare opened by direct URL); fall back to a fresh chat.
|
||||
if (!saved) {
|
||||
navigate({ to: "/chat" });
|
||||
return;
|
||||
}
|
||||
viewBeforeCompareRef.current = null;
|
||||
navigate({ to: "/chat", search: saved });
|
||||
// Restore usage from the last assistant message, but only if it
|
||||
|
|
@ -2038,7 +2072,6 @@ export function ChatPage(): ReactElement {
|
|||
closeModelSelector,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
openSidebar,
|
||||
enterCompare,
|
||||
exitCompare,
|
||||
}),
|
||||
|
|
@ -2050,7 +2083,6 @@ export function ChatPage(): ReactElement {
|
|||
exitCompare,
|
||||
openModelSelector,
|
||||
openSettings,
|
||||
openSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -2256,6 +2288,7 @@ export function ChatPage(): ReactElement {
|
|||
onFoldersChange={refreshLocalModels}
|
||||
onModelsChange={refreshModelLists}
|
||||
deleteDisabled={modelOperationInProgress}
|
||||
onExitCompare={exitCompare}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -723,18 +723,17 @@ export function ChatSettingsPanel({
|
|||
|
||||
const settingsContent = (
|
||||
<>
|
||||
<div
|
||||
ref={settingsScrollRef}
|
||||
className="relative h-full overflow-y-auto"
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header sits outside the scroll area so the scrollbar never shifts the
|
||||
close button. */}
|
||||
<div className="flex h-[48px] shrink-0 items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
|
||||
{isMobile ? (
|
||||
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Run settings
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
|
||||
Run settings
|
||||
</span>
|
||||
<Tooltip>
|
||||
|
|
@ -764,6 +763,10 @@ export function ChatSettingsPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={settingsScrollRef}
|
||||
className="run-settings-scroll relative min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="px-[18px] pt-3">
|
||||
{hasModelContent && (
|
||||
<CollapsibleSection label="Model" defaultOpen={true} first>
|
||||
|
|
@ -1373,6 +1376,7 @@ export function ChatSettingsPanel({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={systemPromptEditorOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function ChatSearchDialog() {
|
|||
<CommandDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setOpen}
|
||||
className="corner-squircle top-[25%] w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px] border-0 ring-0 shadow-[0_10px_34px_rgba(0,0,0,0.12)] dark:border dark:border-border dark:ring-0 dark:shadow-none"
|
||||
className="chat-search-surface corner-squircle top-[25%] w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<Command className="rounded-4xl p-0" filter={chatSearchFilter}>
|
||||
|
|
@ -87,7 +87,7 @@ export function ChatSearchDialog() {
|
|||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CommandList className="cmd-native-scrollbar max-h-[420px] p-1">
|
||||
<CommandList className="cmd-native-scrollbar hover-scrollbar max-h-[420px] p-1">
|
||||
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Loading…"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setName("");
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitCreate()}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,9 +21,11 @@ export function useChatProjects(): {
|
|||
isLoading: boolean;
|
||||
hasLoaded: boolean;
|
||||
} {
|
||||
const [projects, setProjects] = useState<ProjectRecord[]>(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<ProjectRecord[]>(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;
|
||||
|
|
|
|||
|
|
@ -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, boolean>): string[] {
|
||||
const [order, setOrder] = useState<string[]>(() =>
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<string | null>(null);
|
||||
const [hintKey, setHintKey] = useState<string | null>(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"
|
||||
}
|
||||
>
|
||||
<span className="truncate">{opts.label}</span>
|
||||
{opts.enabled ? (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-emerald-600 dark:text-emerald-400">
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
className="size-4 group-data-[highlighted]/mcp:hidden"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="hidden size-4 text-foreground group-data-[highlighted]/mcp:block"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{opts.enabled ? <CheckIcon className="ml-auto" /> : null}
|
||||
{opts.hint ? (
|
||||
<Tooltip open={hintKey === opts.key}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
|
|
@ -260,28 +250,21 @@ export function McpComposerButton() {
|
|||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-3.5"
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>MCP</span>
|
||||
<ArrowDownStandardIcon className="composer-pill-caret size-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="flex items-center justify-between pr-1">
|
||||
<DropdownMenuLabel>MCP Servers</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="size-3.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuContent
|
||||
side={side}
|
||||
align="start"
|
||||
sideOffset={2}
|
||||
avoidCollisions={true}
|
||||
className="unsloth-plus-menu mcp-menu w-[232px]"
|
||||
>
|
||||
<DropdownMenuLabel>MCP Servers</DropdownMenuLabel>
|
||||
{MCP_PRESETS.map((preset) => {
|
||||
const norm = normalizeMcpUrl(preset.url);
|
||||
return renderRow({
|
||||
|
|
@ -330,7 +313,7 @@ export function McpComposerButton() {
|
|||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-3.5"
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>MCP</span>
|
||||
|
|
|
|||
|
|
@ -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<PendingAttachment> {
|
||||
return {
|
||||
|
|
@ -962,8 +976,8 @@ function useStudioRuntimeAdapters(
|
|||
|
||||
const dictation = useMemo(
|
||||
() =>
|
||||
WebSpeechDictationAdapter.isSupported()
|
||||
? new WebSpeechDictationAdapter()
|
||||
StudioWebSpeechDictationAdapter.isSupported()
|
||||
? new StudioWebSpeechDictationAdapter()
|
||||
: undefined,
|
||||
[],
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(27,27,31,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label={t("settings.profile.changePicture")}
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-border">
|
||||
<h2 className="pl-3 pr-2.5 pt-3.5 pb-3.5 text-[19px] font-semibold text-foreground max-sm:hidden">
|
||||
{t("settings.dialog.title")}
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
|
||||
{TABS.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
|
|
@ -151,7 +156,7 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"relative flex h-[32px] items-center gap-2.5 rounded-[11px] pl-3 pr-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
|
||||
"max-sm:shrink-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
|
|
@ -162,7 +167,7 @@ export function SettingsDialog() {
|
|||
{active && (
|
||||
<motion.span
|
||||
layoutId="settings-active-pill"
|
||||
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
className="absolute inset-0 rounded-[11px] bg-[#ececec] dark:bg-[#2d2f33]"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
|
|
@ -198,12 +203,12 @@ export function SettingsDialog() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("settings.dialog.closeAriaLabel")}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
|
||||
<div className="hover-scrollbar flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6 [scrollbar-gutter:stable]">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Delete02Icon, Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
|
|
@ -165,7 +165,7 @@ export function ChatTab() {
|
|||
onClick={handleExport}
|
||||
disabled={exporting || count === 0}
|
||||
>
|
||||
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-3.5 mr-1.5" />
|
||||
{exporting
|
||||
? t("settings.chat.exportingAction")
|
||||
: t("settings.chat.exportAction")}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function StudioPage(): ReactElement {
|
|||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 pb-3">
|
||||
{selectedHistoryRunId && activeTab === "history" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@
|
|||
--sidebar-border: #2d2d2f;
|
||||
--sidebar-ring: #17b88b;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
/* Match light's radius so every rounded-* element is the same in both themes. */
|
||||
--radius: 1.1rem;
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
|
|
@ -584,7 +585,7 @@
|
|||
}
|
||||
|
||||
.sidebar-sticky-label {
|
||||
@apply rounded-none bg-sidebar pt-0 pb-[8px] pl-[18px] pr-4 text-[14.5px]! leading-[17px] font-medium normal-case focus-visible:ring-0! focus-visible:outline-none transition-shadow duration-150;
|
||||
@apply rounded-none bg-sidebar pt-0 pb-[8px] pl-[18px] pr-4 text-[14px]! leading-[17px] font-medium normal-case focus-visible:ring-0! focus-visible:outline-none transition-shadow duration-150;
|
||||
/* Muted section-header gray, matching Gemini's "Notebooks"/"Recents".
|
||||
Lightened from #5f6368 so the label reads as a header, clearly
|
||||
lighter than the near-black nav items. */
|
||||
|
|
@ -764,13 +765,13 @@
|
|||
|
||||
.app-user-menu [data-slot="dropdown-menu-item"],
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] {
|
||||
height: 32px;
|
||||
padding: 0 0.625rem !important;
|
||||
gap: 8.5px !important;
|
||||
border-radius: 10px;
|
||||
height: 36px;
|
||||
padding: 0 0.75rem !important;
|
||||
gap: 9.5px !important;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
line-height: 19px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
color: var(--nav-fg);
|
||||
}
|
||||
|
|
@ -835,6 +836,59 @@
|
|||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Settings dialog: no shadow in dark mode. */
|
||||
.dark .settings-surface.shadow-border {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* App-wide: every clickable control shows the hand cursor. Disabled ones
|
||||
(native :disabled, aria-disabled, Radix data-disabled) keep the default. */
|
||||
button:not(:disabled):not([aria-disabled="true"]),
|
||||
summary,
|
||||
label[for],
|
||||
select:not(:disabled),
|
||||
a[href],
|
||||
:is(
|
||||
[role="button"],
|
||||
[role="tab"],
|
||||
[role="switch"],
|
||||
[role="radio"],
|
||||
[role="checkbox"],
|
||||
[role="menuitem"],
|
||||
[role="menuitemcheckbox"],
|
||||
[role="menuitemradio"],
|
||||
[role="option"],
|
||||
[role="combobox"],
|
||||
[role="link"]
|
||||
):not([aria-disabled="true"]):not([data-disabled]):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Model selector: pointer cursor on every clickable element. */
|
||||
.unsloth-model-selector-trigger,
|
||||
.unsloth-model-selector-menu button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unsloth-model-selector-trigger:disabled,
|
||||
.unsloth-model-selector-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chat search box: borderless, soft Gemini-style elevation. */
|
||||
.chat-search-surface {
|
||||
border: none;
|
||||
/* Pin to the dark --radius so the corners are the same (less round) in
|
||||
both themes; rounded-4xl here and on the inner Command follow this. */
|
||||
--radius: 0.625rem;
|
||||
/* Match the chat box: soft elevation in light, none in dark. */
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
||||
.dark .chat-search-surface {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-soft-surface,
|
||||
.menu-soft-surface-up {
|
||||
--menu-soft-edge: rgba(0, 0, 0, 0.14);
|
||||
|
|
@ -858,31 +912,125 @@
|
|||
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
/* Model selector: drop the inset edge ring, keep the soft drop shadow.
|
||||
Pin --radius to the light value so the corners match in both themes. */
|
||||
.unsloth-model-selector-menu.menu-soft-surface {
|
||||
--radius: 1.1rem;
|
||||
box-shadow: 0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
|
||||
.dark .unsloth-model-selector-menu.menu-soft-surface {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
|
||||
none in dark. !important so it also overrides the bespoke menu shadows. */
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"],
|
||||
[data-slot="combobox-content"],
|
||||
[data-slot="popover-content"] {
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16) !important;
|
||||
}
|
||||
/* Dark: keep the chat-box dropdown shadow so menus don't merge into a
|
||||
same-color surface (e.g. a menu overlapping the composer). */
|
||||
.dark [data-slot="dropdown-menu-content"],
|
||||
.dark [data-slot="dropdown-menu-sub-content"],
|
||||
.dark [data-slot="select-content"],
|
||||
.dark [data-slot="combobox-content"],
|
||||
.dark [data-slot="popover-content"] {
|
||||
box-shadow: 0 4px 14px var(--background) !important;
|
||||
}
|
||||
|
||||
/* Context menus (chat, run, project): drop the inset edge ring, keep the soft shadow. */
|
||||
.app-user-menu.menu-soft-surface {
|
||||
box-shadow: 0 var(--menu-soft-offset-y) var(--menu-soft-blur)
|
||||
var(--menu-soft-spread) var(--menu-soft-shadow);
|
||||
}
|
||||
|
||||
/* Account menu: drop the inset edge ring and use the composer's soft shadow. */
|
||||
.app-user-menu.menu-soft-surface-up {
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
.dark .app-user-menu.menu-soft-surface-up {
|
||||
box-shadow: 0 8px 28px -6px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.chat-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
@apply relative flex w-full flex-col rounded-[32px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 14px oklch(0 0 0 / 0.05),
|
||||
0 18px 40px oklch(0 0 0 / 0.05);
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
||||
.dark .chat-composer-surface {
|
||||
border-color: #34363a;
|
||||
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15);
|
||||
background-color: #2a2a2c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer-pill-btn {
|
||||
@apply flex cursor-pointer items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
@apply flex cursor-pointer items-center gap-1.5 rounded-full py-1.5 pl-2 pr-2.5 text-[14px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
/* Icon-only (compact) pills drop their label, so make the button a square
|
||||
and center the glyph; the rounded-full hover highlight then reads as a
|
||||
circle around the icon rather than a wide rounded rectangle. */
|
||||
[data-pill-compact="true"] .composer-pill-btn:not([data-keep-label]) {
|
||||
@apply size-8 justify-center px-0;
|
||||
}
|
||||
|
||||
.composer-pill-btn[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* With more than 4 tools on, drop pill labels to icons only to cut clutter.
|
||||
Compare keeps its label via data-keep-label. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
> span:not(.composer-pill-glyph) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Drop the MCP dropdown caret when collapsed so the icon is not squished. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
.composer-pill-caret {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fixed-width icon slot so every pill's icon occupies the same space and
|
||||
the labels line up on an even rhythm, regardless of icon size. */
|
||||
.composer-pill-glyph {
|
||||
@apply relative inline-flex w-[19px] shrink-0 items-center justify-center transition-opacity;
|
||||
}
|
||||
|
||||
/* On hover the icon swaps for an X inside a soft circle (ChatGPT-style),
|
||||
filling the icon slot so every pill's X is identical and centered. */
|
||||
.composer-pill-x {
|
||||
@apply pointer-events-none absolute inset-0 m-auto size-[19px] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
|
||||
}
|
||||
|
||||
/* Icon-only (compact) pills are too small for the circle, so show a bare x. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
.composer-pill-x {
|
||||
@apply size-[15px] bg-transparent p-0 dark:bg-transparent;
|
||||
}
|
||||
|
||||
/* Hovering an active pill swaps the icon for an X (click to turn off). */
|
||||
.composer-pill-btn[data-active="true"]:hover .composer-pill-glyph > :not(.composer-pill-x),
|
||||
.unsloth-thinking-pill[data-active="true"]:hover .composer-pill-glyph > :not(.composer-pill-x) {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.composer-pill-btn[data-active="true"]:hover .composer-pill-x,
|
||||
.unsloth-thinking-pill[data-active="true"]:hover .composer-pill-x {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-2 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.composer-action-wrapper {
|
||||
|
|
@ -894,6 +1042,254 @@
|
|||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Pill composer; own classes so compare-mode keeps its stacked layout. */
|
||||
.unsloth-composer-surface {
|
||||
@apply relative flex w-full flex-col rounded-[32px] bg-background dark:bg-card px-3 py-3 outline-none transition-shadow;
|
||||
font-family: var(--font-sans);
|
||||
background-clip: padding-box;
|
||||
/* Gemini search pill: soft, centered shadow, no downward offset. */
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.unsloth-composer-surface:focus-within {
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.dark .unsloth-composer-surface {
|
||||
background-color: #2a2a2c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Keep the expand/collapse width swap instant. A transition on width (e.g.
|
||||
the reduced-motion blanket rule) makes getComputedStyle().width lag a
|
||||
frame, so autosize measures the stale width and leaves a stray blank row. */
|
||||
.unsloth-composer-line,
|
||||
.unsloth-composer-line .unsloth-composer-input,
|
||||
.unsloth-composer-left {
|
||||
transition-property: none !important;
|
||||
}
|
||||
|
||||
/* Composer row: one centered line when empty; two rows (input over controls)
|
||||
when filled, so the textarea never remounts. */
|
||||
.unsloth-composer-line {
|
||||
@apply flex w-full flex-wrap items-center gap-0.5 px-1;
|
||||
}
|
||||
|
||||
.unsloth-composer-left {
|
||||
@apply flex shrink-0 items-center gap-0.5;
|
||||
order: 1;
|
||||
/* Pull the plus button closer to the composer edge. */
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line .unsloth-composer-input {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.unsloth-composer-line .aui-composer-action-wrapper {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
/* Inset the send circle from the edge, Gemini-style. */
|
||||
margin-right: -0.125rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-input {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
/* Sits close to the left edge, near the plus. */
|
||||
padding-left: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Two-row gap between text and controls. On the line, not the input, so
|
||||
the placeholder max-height clamp never crops it like padding would. */
|
||||
.unsloth-composer-line[data-expanded="true"] {
|
||||
row-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.unsloth-composer-line[data-expanded="true"] .unsloth-composer-left {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* Empty (placeholder shown): clamp back to one row. max-height beats the
|
||||
autosize textarea's inline !important height, so a cleared message leaves
|
||||
no stale tall box or stray scrollbar. */
|
||||
.unsloth-composer-input:placeholder-shown {
|
||||
max-height: 40px !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.unsloth-composer-input {
|
||||
@apply min-h-[40px] min-w-0 flex-1 resize-none overflow-y-auto bg-transparent pl-0.5 pr-2 py-2 text-[15px] font-[450] leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus {
|
||||
@apply flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-foreground transition-colors hover:bg-muted-foreground/15 disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus[data-state="open"] {
|
||||
@apply bg-muted-foreground/15;
|
||||
/* Radix's modal menu sets body pointer-events:none; re-enable on the open
|
||||
trigger so the cursor and click-to-close work. */
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Open menu rotates the plus 45deg into an x. Ease-in-out for a smooth
|
||||
spin; keep it at 45deg so it never passes back through a "+". */
|
||||
.unsloth-composer-plus svg {
|
||||
transition: transform 300ms cubic-bezier(0.65, 0, 0.35, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.unsloth-composer-plus[data-state="open"] svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Keyboard focus shows a subtle brand ring, not the browser's blue outline;
|
||||
mouse clicks (no :focus-visible) stay ring-free. */
|
||||
.composer-pill-btn:focus-visible,
|
||||
.unsloth-thinking-pill:focus-visible,
|
||||
.unsloth-composer-plus:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Set Hellix explicitly; .aui-thread-root resets --font-heading to sans. */
|
||||
.unsloth-welcome-title {
|
||||
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Right-side Thinking pill (toggle or dropdown). pl-2 matches the left
|
||||
pills so the hover X is not pushed in too far. */
|
||||
.unsloth-thinking-pill {
|
||||
@apply inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-full py-1.5 pl-2 pr-2.5 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
/* Reserve a text line so the icon-only (inactive) pill matches the
|
||||
text pills' height instead of collapsing to the icon. */
|
||||
min-height: calc(1lh + 0.75rem);
|
||||
}
|
||||
|
||||
.unsloth-thinking-pill[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.unsloth-thinking-pill[data-state="open"] {
|
||||
@apply bg-muted-foreground/10;
|
||||
/* See .unsloth-composer-plus: re-enable pointer-events on the open trigger. */
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Smaller tick for selected Thinking options. */
|
||||
.unsloth-tick {
|
||||
width: 0.8rem !important;
|
||||
height: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
|
||||
.unsloth-plus-menu[data-slot] {
|
||||
/* Pin radius so dark matches light (rounded-lg resolves smaller in dark). */
|
||||
border-radius: 18px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 18px oklch(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* MCP menu is wider (232px), so nudge the radius up so it reads as round as
|
||||
the narrower + menu (18 * 232/212 ~= 20px). */
|
||||
.unsloth-plus-menu.mcp-menu[data-slot] {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.dark .unsloth-plus-menu[data-slot] {
|
||||
background-color: #2a2a2c;
|
||||
/* Shadow tinted to the page bg so it blends, not a dark halo. */
|
||||
box-shadow: 0 4px 14px var(--background);
|
||||
}
|
||||
|
||||
/* Compact items; also applied to portaled sub-content. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
) {
|
||||
@apply gap-3 pl-4 pr-3 py-2 text-[14px];
|
||||
cursor: pointer;
|
||||
/* Pin hover-box radius so dark matches light (same as the container). */
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-label"] {
|
||||
@apply pl-4 pr-3 py-1.5 text-[12px];
|
||||
}
|
||||
|
||||
/* Active (green) items keep their primary text and icon color on hover. */
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"].text-primary:is(:hover, :focus, :focus-visible, [data-highlighted]),
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"].text-primary:is(:hover, :focus, :focus-visible, [data-highlighted]) svg {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Light hover: neutral grey, not the green accent. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) {
|
||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
/* Dark hover: the accent nearly matches the surface, so use a clear overlay. */
|
||||
.dark .unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Hover tints only the background; the item and every descendant (text, icons,
|
||||
labels) keep their resting color, overriding Radix's focus:**:text-accent-
|
||||
foreground. The active green rule above still wins via higher specificity. */
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]),
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
):is(:hover, :focus, :focus-visible, [data-highlighted], [data-state="open"]) * {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.unsloth-plus-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
)
|
||||
svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
/* Destructive items keep red text and a red-tinted hover, not the grey one. */
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"] * {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
.unsloth-plus-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:is(:hover, :focus, :focus-visible, [data-highlighted]) {
|
||||
background-color: color-mix(in oklab, var(--destructive) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Thinking menu: tighter gap; nowrap keeps "Preserve thinking" on one line
|
||||
so the menu sizes to its content. */
|
||||
.unsloth-thinking-menu :is(
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
) {
|
||||
@apply gap-1.5 pl-2.5 pr-2.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artifact-panel-shell {
|
||||
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
|
||||
}
|
||||
|
|
@ -1223,7 +1619,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Lighter shadow + tighter vertical padding than Sonner's defaults; !important because Sonner injects its base rules at runtime. */
|
||||
/* No border line; same drop shadow as the composer (.unsloth-composer-surface).
|
||||
!important because Sonner injects its base rules at runtime. */
|
||||
[data-sonner-toast][data-styled='true'] {
|
||||
padding: 12px 18px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important;
|
||||
|
|
@ -1263,7 +1660,8 @@
|
|||
|
||||
/* Boost shadow on dark surfaces; mirrors .shadow-border / .menu-soft-surface pattern. */
|
||||
.dark [data-sonner-toast][data-styled='true'] {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;
|
||||
background-color: #2a2a2c !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Selectable toast text; non-selectable toast buttons. */
|
||||
|
|
@ -1349,6 +1747,86 @@
|
|||
background: oklch(0.67 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Composer textarea: keep the scroll thumb faint until the user hovers or
|
||||
drags the thumb itself, so a tall draft never shows a heavy dark rail. */
|
||||
.composer-input,
|
||||
.unsloth-composer-input {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.16) transparent;
|
||||
}
|
||||
|
||||
.composer-input::-webkit-scrollbar-thumb,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.16);
|
||||
}
|
||||
|
||||
.composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.composer-input::-webkit-scrollbar-thumb:active,
|
||||
.unsloth-composer-input::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.5 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.dark .composer-input,
|
||||
.dark .unsloth-composer-input {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.18) transparent;
|
||||
}
|
||||
|
||||
.dark .composer-input::-webkit-scrollbar-thumb,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.dark .composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb:hover,
|
||||
.dark .composer-input::-webkit-scrollbar-thumb:active,
|
||||
.dark .unsloth-composer-input::-webkit-scrollbar-thumb:active {
|
||||
background: oklch(0.72 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Sidebars, settings and search: hide the scroll thumb at rest and reveal it
|
||||
only while the area is hovered, so the rail never sits visible over content. */
|
||||
.sidebar-scroll-fade,
|
||||
.run-settings-scroll,
|
||||
.hover-scrollbar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade::-webkit-scrollbar-thumb,
|
||||
.run-settings-scroll::-webkit-scrollbar-thumb,
|
||||
.hover-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade:hover,
|
||||
.run-settings-scroll:hover,
|
||||
.hover-scrollbar:hover {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.4) transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-fade:hover::-webkit-scrollbar-thumb,
|
||||
.run-settings-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.dark .sidebar-scroll-fade:hover,
|
||||
.dark .run-settings-scroll:hover,
|
||||
.dark .hover-scrollbar:hover {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.4) transparent;
|
||||
}
|
||||
|
||||
.dark .sidebar-scroll-fade:hover::-webkit-scrollbar-thumb,
|
||||
.dark .run-settings-scroll:hover::-webkit-scrollbar-thumb,
|
||||
.dark .hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.72 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Run settings: always reserve the scrollbar gutter so the header close
|
||||
button keeps its position whether or not the scrollbar is showing. */
|
||||
.run-settings-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Chat viewport: solid track matching sidebar so the scrollbar reads as a
|
||||
full-height rail flush to the right edge, without a separate decorative strip. */
|
||||
.aui-thread-viewport {
|
||||
|
|
@ -1429,7 +1907,12 @@
|
|||
transform: none !important;
|
||||
background: var(--popover) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Keep the (borderless) close button blended with the dark toast surface. */
|
||||
.dark [data-sonner-toast][data-styled="true"] [data-close-button] {
|
||||
background: #2a2a2c !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled="true"] [data-close-button] svg {
|
||||
|
|
@ -1438,7 +1921,7 @@
|
|||
[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover {
|
||||
background: var(--muted) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.generated-image-loading-card {
|
||||
|
|
@ -1522,4 +2005,10 @@
|
|||
animation-duration: 1850ms !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
|
||||
/* Keep the plus/x morph animating under reduced motion (small rotation,
|
||||
like the spinners above). */
|
||||
.unsloth-composer-plus svg {
|
||||
transition-duration: 300ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
studio/frontend/src/speech-recognition.d.ts
vendored
9
studio/frontend/src/speech-recognition.d.ts
vendored
|
|
@ -28,6 +28,11 @@ interface SpeechRecognitionEvent extends Event {
|
|||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
|
|
@ -35,7 +40,7 @@ interface SpeechRecognition extends EventTarget {
|
|||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start(): void;
|
||||
start(audioTrack?: MediaStreamTrack): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
|
@ -49,4 +54,4 @@ interface Window {
|
|||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}
|
||||
|
||||
declare var SpeechRecognition: SpeechRecognitionConstructor | undefined;
|
||||
declare const SpeechRecognition: SpeechRecognitionConstructor | undefined;
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,25 @@ with sync_playwright() as p:
|
|||
step("sidebar nav: New Chat -> Compare -> Search -> Recipes")
|
||||
click_nav("New Chat", r"/chat")
|
||||
shoot("11-new-chat")
|
||||
click_nav("Compare", r"/chat\?") # /chat?compare=...
|
||||
# Compare moved into the composer + menu (Tools and attachments).
|
||||
plus_btn = page.get_by_role(
|
||||
"button", name = re.compile(r"Tools and attachments", re.I)
|
||||
).first
|
||||
if plus_btn.count() > 0:
|
||||
plus_btn.click(force = True)
|
||||
page.wait_for_timeout(400)
|
||||
compare_item = page.get_by_role(
|
||||
"menuitem", name = re.compile(r"Compare chat", re.I)
|
||||
).first
|
||||
if compare_item.count() > 0:
|
||||
compare_item.click(force = True)
|
||||
page.wait_for_timeout(800)
|
||||
if not re.search(r"/chat\?", page.url):
|
||||
soft_fail(f"'Compare chat' didn't open compare; current: {page.url}")
|
||||
else:
|
||||
soft_fail("composer + menu: 'Compare chat' item not found")
|
||||
else:
|
||||
soft_fail("composer + menu: plus button not found")
|
||||
shoot("12-compare")
|
||||
# Search opens a dialog (not a route change).
|
||||
search_btn = page.get_by_role("button", name = re.compile(r"^search$", re.I)).first
|
||||
|
|
|
|||
|
|
@ -327,17 +327,23 @@ with sync_playwright() as p:
|
|||
# 1. Compare tab.
|
||||
# ─────────────────────────────────────────────────────
|
||||
step("Compare tab: send to two panes")
|
||||
# The Compare nav lives in the sidebar; click it.
|
||||
compare_nav = page.locator('[data-tour="chat-compare"]').first
|
||||
if compare_nav.count() == 0:
|
||||
compare_nav = page.get_by_role(
|
||||
"button",
|
||||
name = re.compile(r"^\s*Compare\s*$", re.I),
|
||||
# Compare moved into the composer + menu (Tools and attachments).
|
||||
compare_opened = False
|
||||
plus_btn = page.get_by_role(
|
||||
"button", name = re.compile(r"Tools and attachments", re.I)
|
||||
).first
|
||||
if plus_btn.count() > 0:
|
||||
plus_btn.click(force = True)
|
||||
page.wait_for_timeout(400)
|
||||
compare_item = page.get_by_role(
|
||||
"menuitem", name = re.compile(r"Compare chat", re.I)
|
||||
).first
|
||||
if compare_nav.count() == 0:
|
||||
if compare_item.count() > 0:
|
||||
compare_item.click(force = True)
|
||||
compare_opened = True
|
||||
if not compare_opened:
|
||||
soft_fail("Compare nav not found")
|
||||
else:
|
||||
compare_nav.click()
|
||||
page.wait_for_timeout(1500)
|
||||
shoot("02-compare-opened")
|
||||
# Compare view's container.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue