From 275c046c090579860578473b2484715ae18300d7 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:48:29 -0700 Subject: [PATCH 1/6] studio: use Hugeicons AI Security glyph for Run automatically (#7409) Swap the lucide CircleOff icon on the Run automatically permission mode for the Hugeicons AI Security 03 glyph, matching the app's existing Hugeicons usage. A small lucide-compatible wrapper lets it drop into the option list. Icon-only change, no behavior change. Co-authored-by: Unsloth --- .../features/chat/permission-mode-select.tsx | 14 ++--- studio/frontend/src/lib/sparkles-icon.tsx | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 studio/frontend/src/lib/sparkles-icon.tsx diff --git a/studio/frontend/src/features/chat/permission-mode-select.tsx b/studio/frontend/src/features/chat/permission-mode-select.tsx index f3e1594795..2fafeab7d6 100644 --- a/studio/frontend/src/features/chat/permission-mode-select.tsx +++ b/studio/frontend/src/features/chat/permission-mode-select.tsx @@ -1,13 +1,8 @@ // 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 { - ChevronDown, - CircleAlert, - CircleOff, - Hand, - ShieldCheck, -} from "lucide-react"; +import { ChevronDown, CircleAlert, Hand, ShieldCheck } from "lucide-react"; +import type { ComponentType } from "react"; import { useState } from "react"; import { @@ -29,6 +24,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; +import { SparklesGlyph } from "@/lib/sparkles-icon"; import { Tick02Icon } from "@/lib/tick-icon"; import { cn } from "@/lib/utils"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -45,7 +41,7 @@ export const PERMISSION_MODE_OPTIONS: readonly { value: PermissionMode; label: string; description: string; - icon: typeof Hand; + icon: ComponentType<{ className?: string; strokeWidth?: number }>; }[] = [ { value: "ask", @@ -63,7 +59,7 @@ export const PERMISSION_MODE_OPTIONS: readonly { value: "off", label: "Run automatically", description: "Run tool calls without approval prompts inside the sandbox", - icon: CircleOff, + icon: SparklesGlyph, }, { value: "full", diff --git a/studio/frontend/src/lib/sparkles-icon.tsx b/studio/frontend/src/lib/sparkles-icon.tsx new file mode 100644 index 0000000000..de4c911158 --- /dev/null +++ b/studio/frontend/src/lib/sparkles-icon.tsx @@ -0,0 +1,51 @@ +// 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 { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +// Hugeicons "AI Security 03" (stroke-rounded). A four-point sparkle inside a +// shield. https://hugeicons.com/icon/ai-security-03 +export const SparklesIcon: IconSvgElement = [ + [ + "path", + { + d: "M11.6769 8.67348C11.8274 8.43697 12.1726 8.43697 12.3231 8.67348L12.7586 9.35767C13.2401 10.1143 13.8818 10.756 14.6384 11.2375L15.3226 11.6729C15.5591 11.8235 15.5591 12.1687 15.3226 12.3192L14.6384 12.7547C13.8818 13.2362 13.2401 13.8779 12.7586 14.6345L12.3231 15.3187C12.1726 15.5552 11.8274 15.5552 11.6769 15.3187L11.2414 14.6345C10.7599 13.8779 10.1182 13.2362 9.36157 12.7547L8.67738 12.3192C8.44087 12.1687 8.44087 11.8235 8.67738 11.6729L9.36157 11.2375C10.1182 10.756 10.7599 10.1143 11.2414 9.35767L11.6769 8.67348Z", + stroke: "currentColor", + strokeLinejoin: "round", + strokeWidth: "1.5", + key: "0", + }, + ], + [ + "path", + { + d: "M3.9068 5.28387C6.87149 5.4984 8.78311 2.49713 12.0262 2.49713C15.2208 2.43341 16.784 5.32395 20.059 5.32395C21.8147 14.2606 18.1622 19.8743 12.053 21.4961C6.38992 20.15 2.13481 14.4788 3.9068 5.28387Z", + stroke: "currentColor", + strokeLinejoin: "round", + strokeWidth: "1.5", + key: "1", + }, + ], +]; + +/** + * AI Security shield glyph wrapped as a lucide-compatible component so it can drop + * into the permission-mode option list alongside lucide icons (same className / + * strokeWidth props). strokeWidth is a number here (lucide style); Hugeicons + * accepts it directly. + */ +export function SparklesGlyph({ + className, + strokeWidth, +}: { + className?: string; + strokeWidth?: number; +}) { + return ( + + ); +} From 1bd080208c610e7868bf3011a348ef3d3e8beee0 Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Fri, 24 Jul 2026 21:20:57 +0800 Subject: [PATCH 2/6] Fix Studio model picker toolbar overflow (#7403) * fix(studio): contain model picker toolbar * fix(studio): preserve model picker tab icons --- .../model-picker/components/model-selector.tsx | 8 +++++--- .../components/model-selector/pickers.tsx | 15 ++++++++++----- .../components/model-selector/pill-tabs.tsx | 7 ++++--- tests/studio/test_model_picker_contracts.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/features/model-picker/components/model-selector.tsx b/studio/frontend/src/features/model-picker/components/model-selector.tsx index cda6e49e24..9ae7e59a1f 100644 --- a/studio/frontend/src/features/model-picker/components/model-selector.tsx +++ b/studio/frontend/src/features/model-picker/components/model-selector.tsx @@ -301,12 +301,12 @@ const HUB_SECTION_TABS: { value: string; label: string; icon?: ReactNode }[] = [ { value: "recommended", label: "Recommended", - icon: , + icon: , }, { value: "downloaded", label: "On Device", - icon: , + icon: , }, ]; @@ -384,7 +384,9 @@ function ModelSelectorContent({ { value: "connected", label: "Connected", - icon: , + icon: ( + + ), }, ] : HUB_SECTION_TABS, diff --git a/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx b/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx index 20810b3f11..71b20058f4 100644 --- a/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx +++ b/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx @@ -3064,13 +3064,18 @@ export function HubModelPicker({ ) : null} - {/* Section tabs then the format and sort dropdowns, packed left with one - uniform gap between every control. The box is sized so the last - dropdown still lands on Search Hub's edge. Dropdowns hide on Connected. */} -
+ {/* Keep the left-packed controls on one line while they fit, then wrap + whole groups before their intrinsic widths cross the picker edge. + Dropdowns hide on Connected. */} +
{sectionToggle} {showConnected ? null : ( -
+
onValueChange(tab.value)} className={cn( "relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full transition-colors", - fit ? "shrink-0" : "min-w-0 flex-1", + fit ? "min-w-0 shrink" : "min-w-0 flex-1", compact ? "h-7 px-2.5 text-ui-11" : "h-9 px-3 text-ui-12p5", value === tab.value ? "text-foreground" @@ -97,7 +98,7 @@ export function PillTabs({ )} > {tab.icon} - {tab.label} + {tab.label} ))}
diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 62b1a3cf76..3934b72d83 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -217,6 +217,24 @@ def test_local_picker_rows_require_chat_capability(): assert "row.capabilities.canChat" in memo.group(0) +def test_model_picker_toolbar_reflows_before_crossing_picker_edge(): + """The content-sized section tabs and fixed-width dropdowns must reflow, + while an oversized tab group must shrink labels but preserve its icons.""" + picker = _read("features/model-picker/components/model-selector/pickers.tsx") + assert '"flex flex-wrap items-center gap-2"' in picker + assert 'hasConnected ? "-mr-4" : "-mr-2"' in picker + assert '"flex max-w-full min-w-0 flex-wrap items-center gap-2"' in picker + + tabs = _read("features/model-picker/components/model-selector/pill-tabs.tsx") + assert 'fit ? "min-w-0 shrink" : "min-w-0 flex-1"' in tabs + assert '{tab.label}' in tabs + + selector = _read("features/model-picker/components/model-selector.tsx") + assert 'icon={StarIcon} className="size-3.5 shrink-0"' in selector + assert 'icon={Download01Icon} className="size-3.5 shrink-0"' in selector + assert 'icon={CloudIcon} className="size-3.5 shrink-0"' in selector + + def test_native_picked_gguf_template_read_through_lease(): """A native (picked / drag-drop) GGUF's path lives only in its signed lease, and the picker chat-template GET has no lease plumbing, so the default From 0e3e4f318078a00ad430cced34c82dc4cbfe7e85 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:50:59 -0700 Subject: [PATCH 3/6] Studio: scale menu, toast, chat and composer icons with the UI font size (#7400) * Studio: scale menu, toast, chat and composer icons with the UI font size Glyphs that sit beside scaled labels now follow the preference: the shared --icon-size token (nav, settings tabs, chat action bars, code block actions), classed svgs inside dropdown, select, context, menubar, popover and command surfaces, toasts, the chat thread and both composers, and the composer pill glyph slot. Sonner toast text is unpinned from its injected 13px. Hit targets, paddings and surface geometry stay fixed and every value is identity at the default size. * Studio: icons scale at half the UI font size rate; cover review gaps Icons now follow the preference at half the rate of the text, matching the logo lockup: base + (setting - 16) / 2. The menu specific rules that outranked the scoped block (app-user-menu, unsloth-plus-menu, unsloth-tick) carry the scale too, which also restores the plus menu's intended 1.15rem glyph base at the default size. From review: closed select triggers join the scoped surfaces so their chevron tracks the label, sonner action button labels scale at full text rate alongside the title and description, and the unused built-in sonner loader gets a defensive size override. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icons match the text scale below the default, half rate above Piecewise icon scaling: below the 16px default icons follow the UI font size at the full text rate, above it they move at half the rate so glyphs stay slightly smaller than the text. Written as min(full, half) since the smaller branch is correct on each side. Applies to the shared --icon-size token, the scoped menu, toast, chat and composer overrides, and the menu rules that outrank them. * Studio: cap icons at their default size above the 16px setting Below the default icons still match the text scale; above it they now keep their default size instead of growing at half rate, so enlarged text dominates and glyphs read slightly smaller than the text. The curve is min(full rate, base). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icons above the default scale at half rate, not capped A 16px glyph at setting 20 renders 18px, as if the setting were 18: above the default icons move at half the rate of the text, below it they match the text scale. The curve is min(full rate, half rate). * Studio: standard icons render at the UI font size itself One shared --ui-icon-size token replaces the per-base curves for every glyph with a 16px or larger base: icons match the UI font size below the default and grow at half the change above it, so setting 12 gives 12px icons, 16 gives 16px and 20 gives 18px, slightly smaller than the enlarged text. Sub 16px glyphs keep their proportions through the same curve as a factor. This also slims the previous 18px to 21px icon bases down to the font size at the default setting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icon scale review fixes for ticks, comboboxes and art glyphs From review: thinking ticks keep their own size inside plus menus (the important menu rule now excludes them), combobox popups and triggers join the scoped surfaces, 24px size-6 art glyphs such as attachment tile icons go back to proportional scaling instead of the uniform token, branch picker 36px chevrons scale proportionally beside their counter, and buttons that default un-classed icons to size-4 get the shared token (xs buttons keep their pinned small icons). Sonner cancel labels already scale: sonner renders cancel with data-button set, so the existing override reaches it. * Studio: keep the toast close glyph compact The button icon fallback matched Sonner's close button, whose unclassed 12px X then rendered at the shared icon size inside its fixed control. Exclude data-close-button from the fallback. * Studio: use text-ui-11 for the new chat settings sheet caption The raw px guard caught a text-[11px] added on main; raw px text ignores the UI font size preference. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../src/features/chat/chat-settings-sheet.tsx | 2 +- studio/frontend/src/index.css | 120 ++++++++++++++++-- tests/studio/playwright_ui_font_scale.py | 8 ++ tests/studio/test_ui_font_scale_contract.py | 33 +++++ 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index c3a59e9860..7b310c50d4 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -966,7 +966,7 @@ export function ChatSettingsPanel({ Delete
-

+

Saving a preset also stores current load settings (context length, KV cache dtype, speculative decoding, GPU layers). {currentLoadSummary ? ( diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index ba112d5f53..4797401ead 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -282,8 +282,13 @@ /* Standard interactive-icon size for nav, menus, action bars, and in-message code-block actions. Sized one step above body text so icons read as minimally larger than adjacent labels (~14px text). + Follows the UI font size preference; 18px at the default. Theme-independent — declared once in :root. */ - --icon-size: 18px; + /* Standard icon size follows the UI font size itself: matches it below + the 16px default, grows at half the change above it (setting 20 -> + 18px), so icons read slightly smaller than enlarged text. */ + --ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), calc(0.5rem + 0.5rem * var(--ui-font-scale, 1))); + --icon-size: var(--ui-icon-size); /* Inset of a centered .size-icon glyph within a 2rem (size-8) action button — i.e. (32px − icon-size) / 2. Use as a negative margin on a chat-message action bar so the leftmost icon's visual edge aligns @@ -1265,8 +1270,8 @@ html[data-chat-font] .aui-root { } .app-user-menu [data-slot="dropdown-menu-item"] svg, .app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg { - width: 19px !important; - height: 19px !important; + width: var(--ui-icon-size) !important; + height: var(--ui-icon-size) !important; flex-shrink: 0; } .app-user-menu [data-slot="dropdown-menu-item"]:focus, @@ -1561,20 +1566,20 @@ html[data-chat-font] .aui-root { /* 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; + @apply relative inline-flex w-[var(--ui-icon-size)] 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]; + @apply pointer-events-none absolute inset-0 m-auto size-[var(--ui-icon-size)] 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; + @apply size-[min(calc(15px*var(--ui-font-scale,1)),calc(7.5px+7.5px*var(--ui-font-scale,1)))] bg-transparent p-0 dark:bg-transparent; } /* Compact pills hide their labels, so surface the name as a hover @@ -1853,8 +1858,8 @@ html[data-chat-font] .aui-root { /* Smaller tick for selected Thinking options. */ .unsloth-tick { - width: 0.8rem !important; - height: 0.8rem !important; + width: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important; + height: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important; } /* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */ @@ -1943,9 +1948,9 @@ html[data-chat-font] .aui-root { [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"] ) - svg { - width: 1.15rem; - height: 1.15rem; + svg:not(.unsloth-tick) { + width: var(--ui-icon-size) !important; + height: var(--ui-icon-size) !important; } /* Destructive items keep red text and a red-tinted hover, not the grey one. */ @@ -2770,3 +2775,96 @@ html[data-chat-font] .aui-root { display: block !important; width: 8px; } + +/* Icons that sit beside scaled labels follow the UI font size itself: + glyphs at or above a 16px base render at --ui-icon-size (12 -> 12px, + 16 -> 16px, 20 -> 18px), so icons track the text below the default and + read slightly smaller than it above. Sub-16px glyphs keep their + proportions through the same curve as a factor. Menu, select and closed select trigger surfaces, popovers, toasts, + the chat thread and both composers. Only glyphs scale; hit targets, + paddings and surface geometry stay fixed. Identity at the default. */ +:is( + [data-slot='dropdown-menu-content'], + [data-slot='dropdown-menu-sub-content'], + [data-slot='select-content'], + [data-slot='select-trigger'], + [data-slot='combobox-content'], + [data-slot='combobox-trigger'], + [data-slot='context-menu-content'], + [data-slot='context-menu-sub-content'], + [data-slot='menubar-content'], + [data-slot='popover-content'], + [data-slot='command'], + [data-sonner-toast], + .composer-action-wrapper, + .aui-composer-action-wrapper, + .aui-action-bar-more-content, + .aui-root +) { + & svg.size-2\.5 { width: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); height: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); } + & svg.size-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); } + & svg.size-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); } + & svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-4\.5 { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-5 { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-6 { width: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); height: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); } + & svg.size-\[5px\] { width: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); height: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); } + & svg.size-\[6px\] { width: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); height: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); } + & svg.size-\[10px\] { width: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); height: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); } + & svg.size-\[11px\] { width: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); height: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); } + & svg.size-\[12px\] { width: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); height: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); } + & svg.size-\[13px\] { width: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); height: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); } + & svg.size-\[14px\] { width: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); height: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); } + & svg.size-\[15px\] { width: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); height: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); } + & svg.size-\[15\.5px\] { width: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); height: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); } + & svg.size-\[16px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[17px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[18px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[18\.5px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[20px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[21px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[22px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); } + & svg.size-\[36px\] { width: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); height: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); } + & svg.w-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); } + & svg.h-3 { height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); } + & svg.w-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); } + & svg.h-3\.5 { height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); } + & svg.w-4 { width: var(--ui-icon-size); } + & svg.h-4 { height: var(--ui-icon-size); } + & svg.w-5 { width: var(--ui-icon-size); } + & svg.h-5 { height: var(--ui-icon-size); } + /* Buttons default un-classed icons to size-4 the same way. Sonner's + close button keeps its compact 12px glyph inside a fixed control. */ + & button:not([class*=':size-3'], [data-close-button]) svg:not([class*='size-'], [class*='w-'], [class*='h-'], .unsloth-tick) { + width: var(--ui-icon-size); + height: var(--ui-icon-size); + } + /* Menu items default un-classed icons to size-4. */ + & [data-slot*='item'] svg:not([class*='size-'], [class*='w-'], [class*='h-']) { + width: var(--ui-icon-size); + height: var(--ui-icon-size); + } +} + +/* Sonner injects fixed 13px toast text and 12px action labels at runtime; + text follows the preference at full rate. Line heights are unitless so + they track automatically. */ +[data-sonner-toast][data-styled='true'] { + font-size: calc(13px * var(--ui-font-scale, 1)) !important; +} +[data-sonner-toast][data-styled='true'] [data-description] { + font-size: calc(13px * var(--ui-font-scale, 1)) !important; +} +[data-sonner-toast][data-styled='true'] [data-button] { + font-size: calc(12px * var(--ui-font-scale, 1)) !important; +} +/* Sonner's icon well is a fixed 16px box; track the glyph. */ +[data-sonner-toast][data-styled='true'] [data-icon] { + width: var(--ui-icon-size) !important; + height: var(--ui-icon-size) !important; +} +/* Defensive: the built-in loader is unused (a custom loading icon is always + passed) but keep its fixed --size on the scale in case that changes. */ +[data-sonner-toast] .sonner-loading-wrapper { + --size: var(--ui-icon-size) !important; +} diff --git a/tests/studio/playwright_ui_font_scale.py b/tests/studio/playwright_ui_font_scale.py index 0f14c42422..903d7745c1 100644 --- a/tests/studio/playwright_ui_font_scale.py +++ b/tests/studio/playwright_ui_font_scale.py @@ -208,6 +208,14 @@ def main(): # text-ui-12p5 at scale 0.75; 16px means twMerge dropped the token. if not near(tab_font, 12.5 * 12 / 16): fail(f"hub tab font did not scale (twMerge drop?): {tab_font}") + icon_w = page.evaluate( + "() => { const el = document.querySelector('.size-icon');" + " return el ? parseFloat(getComputedStyle(el).width) : null; }" + ) + # Standard icons render at the UI font size itself below the + # default, so setting 12 gives 12px glyphs. + if not near(icon_w, 12): + fail(f"size-icon did not match the UI font size below 16: {icon_w}") page.goto(BASE, wait_until = "domcontentloaded") page.wait_for_timeout(1500) open_appearance(page) diff --git a/tests/studio/test_ui_font_scale_contract.py b/tests/studio/test_ui_font_scale_contract.py index 1153eea643..65d3215374 100644 --- a/tests/studio/test_ui_font_scale_contract.py +++ b/tests/studio/test_ui_font_scale_contract.py @@ -98,6 +98,39 @@ def test_cn_knows_the_ui_typography_tokens(): assert "/^ui-\\d+(p5)?$/.test(value)" in UTILS +def test_icons_follow_the_ui_font_size_itself(): + """Standard glyphs render at --ui-icon-size, which follows the UI font + size itself: matches it below the 16px default and grows at half the + change above it (setting 20 gives 18px icons), so icons track the text + when shrinking and read slightly smaller than it when growing. Sub 16px + glyphs keep their proportions through the same curve as a factor. + Sonner toast text and action labels are text, so they follow at full + rate everywhere.""" + assert ( + "--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), " + "calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));" + ) in INDEX_CSS + assert "--icon-size: var(--ui-icon-size);" in INDEX_CSS + assert "& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }" in INDEX_CSS + assert "font-size: calc(13px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS + assert "font-size: calc(12px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS + # Menu rules that outrank the scoped block must carry the token too, + # without flattening the smaller thinking ticks. + assert "width: var(--ui-icon-size) !important;" in INDEX_CSS + assert "svg:not(.unsloth-tick) {" in INDEX_CSS + # Oversized art glyphs stay proportional instead of uniform. + assert "& svg.size-6 { width: min(calc(1.5rem" in INDEX_CSS + for scope in ( + "[data-slot='dropdown-menu-content']", + "[data-slot='select-content']", + "[data-slot='select-trigger']", + "[data-slot='combobox-content']", + "[data-sonner-toast]", + ".aui-root", + ): + assert scope in INDEX_CSS + + def test_no_raw_pixel_text_utilities(): offenders = [] for path in _frontend_sources(): From 91a89806d7e39b21ecd7609de772d0f33f9c47c9 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Fri, 24 Jul 2026 21:01:12 -0300 Subject: [PATCH 4/6] Studio: prevent empty responses after model thinking (#7418) * Fix reasoning-only Qwen3.6 completions in Studio * Address reasoning-only review findings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 51 +++++++- studio/backend/routes/inference.py | 2 + .../backend/tests/test_anthropic_messages.py | 49 +++++++- .../backend/tests/test_llama_cpp_tool_loop.py | 101 ++++++++++++++-- .../src/features/chat/api/chat-adapter.ts | 11 +- .../src/features/chat/api/chat-api.ts | 111 +++++++++++++++++- .../test_generation_length_ui_contract.py | 26 ++++ 7 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 tests/studio/test_generation_length_ui_contract.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1fe134c3f9..147174451e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -307,6 +307,26 @@ def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> "list[str]": _DEFAULT_MAX_TOKENS_FLOOR = 32768 _DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min + +def _finalize_reasoning_only_cumulative( + cumulative: str, reasoning_text: str, finish_reason: Optional[str], promote_reasoning_only: bool +) -> str: + """Close a live thinking block and promote it only after a clean stop. + + Local inference streams cumulative snapshots. Replacing ``...`` with + bare reasoning at EOF makes the final snapshot shorter, so suffix-based + route consumers drop the intended fallback. Keep the snapshot append-only. + A length-truncated thought is not a final answer, so close it without + promotion and let the client surface the ``length`` terminal state. Raw + consumers that do not split reasoning from visible content can disable the + fallback to avoid returning the same reasoning twice. + """ + visible_fallback = ( + reasoning_text if promote_reasoning_only and finish_reason != "length" else "" + ) + return cumulative + "" + visible_fallback + + # Only large streamed tool payloads get an early provisional card; render_html # is exempt because it needs immediate artifact feedback. _PROVISIONAL_ARGS_MIN_CHARS = 256 @@ -10556,6 +10576,7 @@ class LlamaCppBackend: reasoning_effort: Optional[str] = None, preserve_thinking: Optional[bool] = None, seed: Optional[int] = None, + promote_reasoning_only: bool = True, _allow_respawn_retry: bool = True, ) -> Generator[Union[str, dict], None, None]: """ @@ -10638,7 +10659,12 @@ class LlamaCppBackend: # model put its whole reply in reasoning # (e.g. Qwen3 always-think). Show it as # the main response, not a thinking block. - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield cumulative _stream_done = True break # exit inner while @@ -10735,6 +10761,7 @@ class LlamaCppBackend: reasoning_effort = reasoning_effort, preserve_thinking = preserve_thinking, seed = seed, + promote_reasoning_only = promote_reasoning_only, _allow_respawn_retry = False, ) return @@ -10776,6 +10803,7 @@ class LlamaCppBackend: confirm_tool_calls: bool = False, bypass_permissions: bool = False, permission_mode: Optional[str] = None, + promote_reasoning_only: bool = True, ) -> Generator[dict, None, None]: """ Agentic loop: let the model call tools, execute them, and continue. @@ -11118,7 +11146,12 @@ class LlamaCppBackend: ), } else: - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -11582,7 +11615,12 @@ class LlamaCppBackend: if _reasoning_started_at is not None and not _reasoning_summary_emitted: _reasoning_summary_emitted = True yield _reasoning_summary_event(_reasoning_started_at) - cumulative_display = reasoning_accum + cumulative_display = _finalize_reasoning_only_cumulative( + cumulative_display, + reasoning_accum, + _iter_finish_reason, + promote_reasoning_only, + ) if not _suppress_visible_output: yield { "type": "content", @@ -12146,7 +12184,12 @@ class LlamaCppBackend: "text": _strip_tool_markup(cumulative, final = True), } else: - cumulative = reasoning_text + cumulative = _finalize_reasoning_only_cumulative( + cumulative, + reasoning_text, + _metadata_finish_reason, + promote_reasoning_only, + ) yield {"type": "content", "text": cumulative} _stream_done = True break # exit inner while diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 445a26f04d..4e74e1d32d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -13355,6 +13355,7 @@ async def anthropic_messages( disable_parallel_tool_use = _disable_parallel, bypass_permissions = bool(payload.bypass_permissions), permission_mode = getattr(payload, "permission_mode", None), + promote_reasoning_only = False, ) if payload.stream: @@ -13394,6 +13395,7 @@ async def anthropic_messages( max_tokens = payload.max_tokens, stop = stop, cancel_event = cancel_event, + promote_reasoning_only = False, ) if payload.stream: diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 9ccc3f44dd..621ac9aaca 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -68,16 +68,15 @@ def _emitter_client_text(events: list[str]) -> str: def test_anthropic_emitter_closes_reasoning_only_think_block(): - # A reasoning-only reply streams X live then shrinks to bare X at EOF. - # This emitter diffs cumulative snapshots and drops the shrink, so without a - # closing pass the client text would end on an unclosed . finish() - # must balance it. + # Anthropic asks the GGUF generator not to promote reasoning into a duplicate + # visible fallback, so its final cumulative snapshot only balances the block. emitter = AnthropicStreamEmitter() events = emitter.start("msg_1", "m") events += emitter.feed({"type": "content", "text": "The capital"}) events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) - # The generator's final bare-text shrink (dropped by the cumulative diff). - events += emitter.feed({"type": "content", "text": "The capital of France is Paris."}) + events += emitter.feed( + {"type": "content", "text": "The capital of France is Paris."} + ) events += emitter.finish() assert _emitter_client_text(events) == "The capital of France is Paris." @@ -1563,6 +1562,44 @@ class TestAnthropicMessagesToolRouting: assert entry["context_length"] == 2048 assert monitor.active_count() == 0 + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("with_tools", [False, True]) + def test_reasoning_only_output_is_not_duplicated(self, monkeypatch, stream, with_tools): + reasoning = "The capital of France is Paris." + + def _gen_plain(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield f"{reasoning}" + yield f"{reasoning}" + + def _gen_tools(**kwargs): + assert kwargs["promote_reasoning_only"] is False + yield {"type": "content", "text": f"{reasoning}"} + yield {"type": "content", "text": f"{reasoning}"} + + _mock_backend( + monkeypatch, + generate_chat_completion = _gen_plain, + generate_chat_completion_with_tools = _gen_tools, + ) + payload_fields = {"stream": stream} + if with_tools: + payload_fields.update( + { + "enable_tools": True, + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + } + ) + payload = _basic_payload(**payload_fields) + + response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t")) + if stream: + body = self._sse_blob(self._consume_response(response)) + assert body.count(reasoning) == 1 + else: + body = json.loads(response.body) + assert body["content"][0]["text"] == f"{reasoning}" + def test_tool_use_non_streaming_records_api_monitor_reply(self, monkeypatch): import routes.inference as inf_mod diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index e99e227d40..cf9fde7118 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -37,6 +37,24 @@ def _done() -> str: return "data: [DONE]\n" +def _finish(reason: str) -> str: + return ( + "data: " + + json.dumps( + { + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": reason, + } + ] + } + ) + + "\n" + ) + + def _make_backend(monkeypatch, streams: list[list[str]], payloads: list[dict]): backend = LlamaCppBackend.__new__(LlamaCppBackend) backend._process = object() @@ -299,9 +317,8 @@ def test_reasoning_streams_incrementally_with_tools(monkeypatch): def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): # A reasoning-only turn (whole answer in reasoning_content, no content, no # tool) with a tool active streams the reasoning live, then resolves to the - # bare reasoning text -- identical to the no-tool generate_chat_completion - # path -- so the non-streaming drain still returns it as `content`, not an - # empty answer. + # same text on the visible channel. The final cumulative snapshot stays + # append-only so route suffix extraction cannot drop that fallback. stream = [ _sse({"reasoning_content": "The capital of France is Paris."}), _done(), @@ -321,8 +338,49 @@ def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch): content_texts = [e["text"] for e in events if e["type"] == "content"] # Reasoning streamed live during BUFFERING (the fix). assert content_texts[0] == "The capital of France is Paris." - # Resolves to bare reasoning, matching the no-tool sibling. - assert content_texts[-1] == "The capital of France is Paris." + assert content_texts[-1] == ( + "The capital of France is Paris.The capital of France is Paris." + ) + + +def _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, with_tools): + stream = [ + _sse({"reasoning_content": "The capital of France is Paris."}), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + if with_tools: + items = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "capital of France?"}], + tools = [{"type": "function", "function": {"name": "web_search"}}], + max_tool_iterations = 1, + promote_reasoning_only = False, + ) + ) + cumulatives = [item["text"] for item in items if item.get("type") == "content"] + else: + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "capital of France?"}], + promote_reasoning_only = False, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert cumulatives[-1] == "The capital of France is Paris." + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + + +def test_reasoning_only_raw_consumer_without_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, False) + + +def test_reasoning_only_raw_consumer_with_tools_gets_one_balanced_think_block(monkeypatch): + _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, True) def test_reasoning_before_structured_tool_closes_think_block(monkeypatch): @@ -392,8 +450,8 @@ def _replay_route_reasoning_extractor(cumulatives: list[str]) -> tuple[str, str] def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): # Parity contract: a reasoning-only reply must reach the client identically # whether tools are on or off. Both generators stream live then - # resolve to the bare reasoning text; the route's suffix-diff + extractor - # must therefore produce the same (visible, reasoning) split for both. + # append a balanced close plus visible fallback; the route's suffix-diff + + # extractor must therefore produce the same split for both. stream = [ _sse({"reasoning_content": "The capital"}), _sse({"reasoning_content": " of France is Paris."}), @@ -430,10 +488,37 @@ def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch): no_tool_out = _replay_route_reasoning_extractor(no_tool_cumulatives) assert tool_out == no_tool_out # Pin the shared contract so a change to either path shows up here. - _visible, reasoning = tool_out + visible, reasoning = tool_out + assert visible == "The capital of France is Paris." assert reasoning == "The capital of France is Paris." +def test_length_truncated_reasoning_stays_append_only_without_visible_promotion(monkeypatch): + stream = [ + _sse({"reasoning_content": "The proof begins by assuming finitely many primes."}), + _finish("length"), + _done(), + ] + backend = _make_backend(monkeypatch, [stream], []) + + items = list( + backend.generate_chat_completion( + messages = [{"role": "user", "content": "Prove infinitely many primes"}], + max_tokens = 16, + ) + ) + cumulatives = [item for item in items if isinstance(item, str)] + + assert all( + current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives) + ) + assert cumulatives[-1] == ("The proof begins by assuming finitely many primes.") + visible, reasoning = _replay_route_reasoning_extractor(cumulatives) + assert visible == "" + assert reasoning == "The proof begins by assuming finitely many primes." + assert items[-1]["finish_reason"] == "length" + + def test_reasoning_before_bare_json_tool_closes_think_block(monkeypatch): # _drain_silently sibling of the structured-tool close: a bare-JSON tool call # with a live reasoning prefix must also close before draining, and diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index d4861e8a3a..b7323777b2 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -89,6 +89,7 @@ import { import { resolveLoadMaxSeqLength } from "../presets/preset-policy"; import { generateAudio, + GenerationLengthError, listCachedGguf, listCachedModels, listGgufVariants, @@ -4093,7 +4094,15 @@ export function createOpenAIStreamAdapter( ); if (!abortSignal.aborted) { const msg = err instanceof Error ? err.message : String(err); - if (err instanceof StreamInterruptedError) { + if (err instanceof GenerationLengthError) { + toast.error("Response ran out of tokens", { + description: + "The model used the full Max Tokens budget while thinking " + + "and did not produce a final answer. Increase Max Tokens in " + + "chat Settings or turn off thinking, then retry.", + duration: 8000, + }); + } else if (err instanceof StreamInterruptedError) { // Connection dropped mid-turn: surface it explicitly (the rethrow // below also marks the message with an inline error + Retry). toast.error("Response interrupted", { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ffaf099f29..4d123e98ab 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -50,6 +50,21 @@ export class StreamInterruptedError extends Error { } } +/** + * Thrown when a reasoning model consumes its output budget before emitting any + * standard content. Keeping this distinct from a dropped connection lets the + * chat UI explain why a completed stream contains only a thinking panel. + */ +export class GenerationLengthError extends Error { + constructor() { + super( + "The model reached the Max Tokens limit before producing a final answer. " + + "Increase Max Tokens or disable thinking, then retry.", + ); + this.name = "GenerationLengthError"; + } +} + export function notifyChatHistoryUpdated(): void { if (typeof window !== "undefined") { window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT)); @@ -982,6 +997,61 @@ function parseSseEvent(rawEvent: string): string[] { return dataLines; } +function hasNonWhitespaceText(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.some((item) => hasNonWhitespaceText(item)); + } + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ["thinking", "text", "content", "reasoning", "summary"].some( + (key) => key in record && hasNonWhitespaceText(record[key]), + ); +} + +function classifyStructuredDeltaContent(content: unknown): { + hasAssistantContent: boolean; + hasReasoningContent: boolean; +} { + if (typeof content === "string") { + return { + hasAssistantContent: hasNonWhitespaceText(content), + hasReasoningContent: false, + }; + } + if (!Array.isArray(content)) { + return { + hasAssistantContent: false, + hasReasoningContent: false, + }; + } + + let hasAssistantContent = false; + let hasReasoningContent = false; + for (const part of content) { + if (typeof part === "string") { + hasAssistantContent ||= hasNonWhitespaceText(part); + continue; + } + if (!part || typeof part !== "object") { + continue; + } + const record = part as Record; + if (record.type === "thinking" || record.type === "reasoning") { + hasReasoningContent ||= hasNonWhitespaceText(record); + } else if (record.type === "text" || record.type === "output_text") { + const text = + typeof record.text === "string" ? record.text : record.content; + hasAssistantContent ||= hasNonWhitespaceText(text); + } + } + return { hasAssistantContent, hasReasoningContent }; +} + export async function* streamChatCompletions( payload: OpenAIChatCompletionsRequest, signal: AbortSignal, @@ -1009,6 +1079,19 @@ export async function* streamChatCompletions( // EOF without `[DONE]` or a finish_reason chunk means the stream was cut // mid-generation: surface as interrupted, not silent success. let sawTerminalSignal = false; + let terminalFinishReason: string | null = null; + let sawAssistantContent = false; + let sawReasoningContent = false; + + const throwIfReasoningOnlyLength = () => { + if ( + terminalFinishReason === "length" && + sawReasoningContent && + !sawAssistantContent + ) { + throw new GenerationLengthError(); + } + }; try { while (true) { @@ -1018,6 +1101,7 @@ export async function* streamChatCompletions( if (!sawTerminalSignal) { throw new StreamInterruptedError(); } + throwIfReasoningOnlyLength(); break; } @@ -1039,6 +1123,7 @@ export async function* streamChatCompletions( if (dataText === "[DONE]") { completed = true; sawTerminalSignal = true; + throwIfReasoningOnlyLength(); return; } @@ -1094,11 +1179,31 @@ export async function* streamChatCompletions( } // finish_reason is a valid terminal signal for providers that close // the stream without an explicit [DONE] sentinel. - const finishReason = ( + const parsedChoices = ( parsed as { - choices?: Array<{ finish_reason?: string | null }>; + choices?: Array<{ + delta?: Record; + finish_reason?: string | null; + }>; } - ).choices?.[0]?.finish_reason; + ).choices; + for (const choice of parsedChoices ?? []) { + const delta = choice.delta; + if (delta) { + const contentState = classifyStructuredDeltaContent(delta.content); + sawAssistantContent ||= contentState.hasAssistantContent; + sawReasoningContent ||= contentState.hasReasoningContent; + const reasoning = + delta.reasoning_content ?? + delta.reasoning ?? + delta.reasoning_details; + sawReasoningContent ||= hasNonWhitespaceText(reasoning); + } + if (choice.finish_reason) { + terminalFinishReason = choice.finish_reason; + } + } + const finishReason = parsedChoices?.[0]?.finish_reason; if (finishReason) { sawTerminalSignal = true; } diff --git a/tests/studio/test_generation_length_ui_contract.py b/tests/studio/test_generation_length_ui_contract.py new file mode 100644 index 0000000000..cc25f91080 --- /dev/null +++ b/tests/studio/test_generation_length_ui_contract.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +from pathlib import Path + + +CHAT_API = ( + Path(__file__).resolve().parents[2] + / "studio" + / "frontend" + / "src" + / "features" + / "chat" + / "api" + / "chat-api.ts" +) + + +def test_length_detection_classifies_visible_and_reasoning_content(): + source = CHAT_API.read_text(encoding = "utf-8") + + assert "return value.trim().length > 0;" in source + assert 'record.type === "thinking" || record.type === "reasoning"' in source + assert 'record.type === "text" || record.type === "output_text"' in source + assert "sawAssistantContent ||= contentState.hasAssistantContent;" in source + assert "sawReasoningContent ||= contentState.hasReasoningContent;" in source From 938e786eb927e42bfb792eef39b06d0e08ff946c Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Fri, 24 Jul 2026 20:37:00 -0500 Subject: [PATCH 5/6] Recipe Studio: full-height canvas and in-app maximize control (#7394) * studio recipes: full-height canvas and in-app maximize control - Recipe editor fills its container (drop the outer padding and the fixed 75vh height); the canvas reaches the window edges - Viewport controls: the fit button now reads as center (it always fit/centered); add an expand-to-full-view button that collapses the sidebar and maximizes the canvas in-app, toggling back to restore * recipe studio: exit full view when leaving the editor tab Addresses review: the Exit full view control lives inside the editor canvas, which unmounts on the Easy/Runs tabs. Clear maximized (and restore the sidebar) when activeView leaves "editor" so those views aren't left stuck under the fixed full-view overlay. * recipe studio: keep full view below titlebar and off the sidebar state --- .../components/controls/viewport-controls.tsx | 42 ++++++++++++++--- .../recipe-studio/recipe-studio-page.tsx | 45 +++++++++++++++++-- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx index 0a0eff849b..e777826315 100644 --- a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx +++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx @@ -1,10 +1,18 @@ // 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 ReactElement, useCallback } from "react"; -import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react"; -import { Panel, useReactFlow } from "@xyflow/react"; import { Button } from "@/components/ui/button"; +import { Panel, useReactFlow } from "@xyflow/react"; +import { + Focus, + Lock, + LockOpen, + Maximize2, + Minimize2, + Minus, + Plus, +} from "lucide-react"; +import { type ReactElement, useCallback } from "react"; import { buildFitViewOptions } from "../../utils/graph/fit-view"; import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class"; @@ -12,12 +20,16 @@ type ViewportControlsProps = { interactive: boolean; lockDisabled?: boolean; onToggleInteractive: () => void; + maximized: boolean; + onToggleMaximize: () => void; }; export function ViewportControls({ interactive, lockDisabled = false, onToggleInteractive, + maximized, + onToggleMaximize, }: ViewportControlsProps): ReactElement { const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow(); @@ -61,9 +73,23 @@ export function ViewportControls({ size="icon" className={RECIPE_FLOATING_ICON_BUTTON_CLASS} onClick={handleFitView} - aria-label="Fit view" + aria-label="Center view" > - + + + ); diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index 30302b86a7..f1f5e0958e 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -237,6 +237,7 @@ export function RecipeStudioPage({ }, [setActiveView]); const [processorsOpen, setProcessorsOpen] = useState(false); const [interactive, setInteractive] = useState(true); + const [maximized, setMaximized] = useState(false); const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false); const [recentCompletedExecution, setRecentCompletedExecution] = useState(null); @@ -569,6 +570,16 @@ export function RecipeStudioPage({ [reactFlowInstance], ); + const toggleMaximize = useCallback(() => { + // The maximized surface is a fixed z-50 overlay that already covers the + // app sidebar (z-10/z-20), so we don't touch the sidebar's own state — that + // state is persisted in pin mode and mutating it here would leak the + // temporary collapse into the next page/session. + setMaximized((prev) => !prev); + // Container size changes; refit once the layout settles. + scheduleFitView({ delayMs: TAB_SWITCH_FIT_DELAY_MS }); + }, [scheduleFitView]); + useEffect(() => { if ( previousActiveViewRef.current !== activeView && @@ -587,6 +598,15 @@ export function RecipeStudioPage({ } }, [activeView, reactFlowInstance]); + // The "Exit full view" control lives inside the editor canvas, which unmounts + // on other tabs. Drop full-view mode when leaving the editor so Easy/Runs + // aren't left under the fixed overlay. + useEffect(() => { + if (activeView !== "editor" && maximized) { + setMaximized(false); + } + }, [activeView, maximized]); + useEffect(() => { if ( !reactFlowInstance || @@ -732,6 +752,8 @@ export function RecipeStudioPage({ interactive={canvasInteractive} lockDisabled={executionLocked} onToggleInteractive={toggleInteractive} + maximized={maximized} + onToggleMaximize={toggleMaximize} /> {islandExecution && (isExecutionInProgress(islandExecution.status) || @@ -773,10 +795,25 @@ export function RecipeStudioPage({ } return ( -

-
+