diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8da836de38..38c0261f5a 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -437,6 +437,7 @@ class LlamaCppBackend: self._effective_context_length: Optional[int] = None self._max_context_length: Optional[int] = None self._chat_template: Optional[str] = None + self._chat_template_override: Optional[str] = None self._supports_reasoning: bool = False self._reasoning_always_on: bool = False self._reasoning_style: str = "enable_thinking" @@ -621,6 +622,10 @@ class LlamaCppBackend: def chat_template(self) -> Optional[str]: return self._chat_template + @property + def chat_template_override(self) -> Optional[str]: + return self._chat_template_override + @property def supports_reasoning(self) -> bool: return self._supports_reasoning @@ -2221,12 +2226,12 @@ class LlamaCppBackend: self._speculative_type = None # Apply custom chat template override if provided + self._chat_template_override = chat_template_override if chat_template_override: import tempfile - self._chat_template = chat_template_override flags = detect_reasoning_flags( - self._chat_template, + chat_template_override, self._model_identifier, log_source = "GGUF chat template override", ) @@ -2525,6 +2530,7 @@ class LlamaCppBackend: self._effective_context_length = None self._max_context_length = None self._chat_template = None + self._chat_template_override = None self._supports_reasoning = False self._reasoning_always_on = False self._reasoning_style = "enable_thinking" @@ -4211,6 +4217,8 @@ class LlamaCppBackend: return "csm" if len(_tok("<|startoftranscript|>")) == 1: return "whisper" + if len(_tok("")) == 1: + return "audio_vlm" if ( len(_tok("<|bicodec_semantic_0|>")) == 1 and len(_tok("<|bicodec_global_0|>")) == 1 diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py index a4fbbbe6ee..7addca02ca 100644 --- a/studio/backend/models/__init__.py +++ b/studio/backend/models/__init__.py @@ -15,6 +15,7 @@ from .training import ( TrainingRunMetrics, TrainingRunDetailResponse, TrainingRunDeleteResponse, + TrainingRunUpdateRequest, ) from .models import ( CheckpointInfo, @@ -81,6 +82,7 @@ __all__ = [ "TrainingRunMetrics", "TrainingRunDetailResponse", "TrainingRunDeleteResponse", + "TrainingRunUpdateRequest", # Model management schemas "ModelDetails", "LocalModelInfo", diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 43087cc5bf..7a4c7d0b3c 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -11,7 +11,14 @@ import time import uuid from typing import Annotated, Any, Dict, Literal, Optional, List, Union -from pydantic import BaseModel, Discriminator, Field, Tag, model_validator +from pydantic import ( + BaseModel, + Discriminator, + Field, + Tag, + field_validator, + model_validator, +) class LoadRequest(BaseModel): @@ -43,6 +50,16 @@ class LoadRequest(BaseModel): None, description = "Custom Jinja2 chat template to use instead of the model's default", ) + + @field_validator("chat_template_override") + @classmethod + def normalize_blank_chat_template_override( + cls, value: Optional[str] + ) -> Optional[str]: + if value is not None and value.strip() == "": + return None + return value + cache_type_kv: Optional[str] = Field( None, description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')", @@ -299,10 +316,6 @@ class InferenceStatusResponse(BaseModel): supports_tools: bool = Field( False, description = "Whether the active model supports tool calling" ) - chat_template: Optional[str] = Field( - None, - description = "Jinja2 chat template string for the active model", - ) context_length: Optional[int] = Field( None, description = "Context length of the active model" ) @@ -314,6 +327,17 @@ class InferenceStatusResponse(BaseModel): None, description = "Model's native context length from GGUF metadata (not capped by VRAM)", ) + cache_type_kv: Optional[str] = Field( + None, + description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default", + ) + chat_template: Optional[str] = Field( + None, description = "Model's default chat template (Jinja2 source), if any" + ) + chat_template_override: Optional[str] = Field( + None, + description = "Active chat template override applied at load time, or None if model is using its default", + ) speculative_type: Optional[str] = Field( None, description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled", diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 8127af1ee6..0c5825c54e 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -5,7 +5,7 @@ Pydantic schemas for Training API """ -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from typing import Any, Optional, List, Dict, Literal @@ -224,6 +224,7 @@ class TrainingRunSummary(BaseModel): status: Literal["running", "completed", "stopped", "error"] model_name: str dataset_name: str + display_name: Optional[str] = None started_at: str ended_at: Optional[str] = None total_steps: Optional[int] = None @@ -237,6 +238,14 @@ class TrainingRunSummary(BaseModel): resumed_later: bool = False +class TrainingRunUpdateRequest(BaseModel): + """Mutable fields on a training run.""" + + model_config = ConfigDict(extra = "forbid") + + display_name: Optional[str] = Field(None, max_length = 120) + + class TrainingRunListResponse(BaseModel): """Response for listing training runs.""" diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index a6b00360af..6b559b9c45 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -474,7 +474,6 @@ async def load_model( f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload" ) inference_config = load_inference_config(llama_backend.model_identifier) - from utils.models import is_audio_input_type _gguf_audio = ( llama_backend._audio_type @@ -495,9 +494,7 @@ async def load_model( is_gguf = True, is_audio = _gguf_is_audio, audio_type = _gguf_audio, - has_audio_input = is_audio_input_type(_gguf_audio) - if _gguf_audio - else False, + has_audio_input = False, inference = inference_config, requires_trust_remote_code = bool( inference_config.get("trust_remote_code", False) @@ -658,9 +655,10 @@ async def load_model( f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}" ) - # Detect TTS audio by probing the loaded model's vocabulary - from utils.models import is_audio_input_type - + # Detect TTS/audio marker tokens by probing the loaded model's vocabulary. + # GGUF audio input is not wired through the chat path yet, so do not + # advertise has_audio_input for GGUF models until uploaded audio is + # actually forwarded to llama-server. _gguf_audio = llama_backend.detect_audio_type() _gguf_is_audio = _gguf_audio in ("snac", "bicodec", "dac") llama_backend._is_audio = _gguf_is_audio @@ -686,7 +684,7 @@ async def load_model( is_gguf = True, is_audio = _gguf_is_audio, audio_type = _gguf_audio, - has_audio_input = is_audio_input_type(_gguf_audio), + has_audio_input = False, inference = inference_config, requires_trust_remote_code = bool( inference_config.get("trust_remote_code", False) @@ -1156,13 +1154,15 @@ async def get_status( ): _display_model_id = os.path.basename(_model_id) _inference_cfg = load_inference_config(_model_id) if _model_id else None + _audio_type = getattr(llama_backend, "_audio_type", None) return InferenceStatusResponse( active_model = _display_model_id, is_vision = llama_backend.is_vision, is_gguf = True, gguf_variant = llama_backend.hf_variant, is_audio = getattr(llama_backend, "_is_audio", False), - audio_type = getattr(llama_backend, "_audio_type", None), + audio_type = _audio_type, + has_audio_input = False, loading = [], loaded = [_display_model_id] if _display_model_id else [], inference = _inference_cfg, @@ -1178,6 +1178,8 @@ async def get_status( context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, native_context_length = llama_backend.native_context_length, + cache_type_kv = llama_backend.cache_type_kv, + chat_template_override = llama_backend.chat_template_override, speculative_type = llama_backend.speculative_type, ) @@ -1669,6 +1671,12 @@ async def openai_chat_completions( and not _effective_enable_tools(payload) and (_tools_passthrough or _has_response_format) ): + if payload.audio_base64: + raise HTTPException( + status_code = 400, + detail = "Audio input is not supported for GGUF chat models yet.", + ) + # Preserve the vision guard that would otherwise run in the # non-passthrough path below: text-only tool-capable GGUFs # should return a clear 400 here rather than forwarding the @@ -1716,6 +1724,12 @@ async def openai_chat_completions( # ── GGUF path: proxy to llama-server /v1/chat/completions ── if using_gguf: + if payload.audio_base64: + raise HTTPException( + status_code = 400, + detail = "Audio input is not supported for GGUF chat models yet.", + ) + # Reject images if this GGUF model doesn't support vision image_b64 = extracted_image_b64 or payload.image_base64 if image_b64 and not llama_backend.is_vision: diff --git a/studio/backend/routes/training_history.py b/studio/backend/routes/training_history.py index 6f34321959..771d9f1e35 100644 --- a/studio/backend/routes/training_history.py +++ b/studio/backend/routes/training_history.py @@ -18,8 +18,15 @@ from models import ( TrainingRunListResponse, TrainingRunMetrics, TrainingRunSummary, + TrainingRunUpdateRequest, +) +from storage.studio_db import ( + delete_run, + get_run, + get_run_metrics, + list_runs, + update_run_display_name, ) -from storage.studio_db import delete_run, get_run, get_run_metrics, list_runs logger = get_logger(__name__) @@ -73,6 +80,34 @@ async def get_training_run_detail( ) +@router.patch("/runs/{run_id}", response_model = TrainingRunSummary) +async def update_training_run( + run_id: str, + payload: TrainingRunUpdateRequest, + current_subject: str = Depends(get_current_subject), +): + """Update mutable fields on a training run (currently only display_name).""" + run = get_run(run_id) + if run is None: + raise HTTPException(status_code = 404, detail = f"Run {run_id} not found") + + if "display_name" in payload.model_fields_set: + next_display = payload.display_name + if next_display is not None: + next_display = next_display.strip() or None + update_run_display_name(run_id, next_display) + + refreshed = get_run(run_id) + if refreshed is None: + raise HTTPException(status_code = 404, detail = f"Run {run_id} not found") + return TrainingRunSummary( + **{ + **{k: v for k, v in refreshed.items() if k != "config_json"}, + "can_resume": can_resume_run(refreshed), + } + ) + + @router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse) async def delete_training_run( run_id: str, diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py index 29e787c196..8dc29a9f24 100644 --- a/studio/backend/storage/studio_db.py +++ b/studio/backend/storage/studio_db.py @@ -75,10 +75,16 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: output_dir TEXT, error_message TEXT, duration_seconds REAL, - loss_sparkline TEXT + loss_sparkline TEXT, + display_name TEXT ) """ ) + existing_cols = { + row[1] for row in conn.execute("PRAGMA table_info(training_runs)").fetchall() + } + if "display_name" not in existing_cols: + conn.execute("ALTER TABLE training_runs ADD COLUMN display_name TEXT") conn.execute( """ CREATE TABLE IF NOT EXISTS training_metrics ( @@ -261,6 +267,18 @@ def insert_metrics_batch(run_id: str, metrics: list[dict]) -> None: conn.close() +def update_run_display_name(id: str, display_name: Optional[str]) -> None: + conn = get_connection() + try: + conn.execute( + "UPDATE training_runs SET display_name = ? WHERE id = ?", + (display_name, id), + ) + conn.commit() + finally: + conn.close() + + def list_runs(limit: int = 50, offset: int = 0) -> dict: conn = get_connection() try: @@ -270,7 +288,7 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict: SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at, r.ended_at, r.total_steps, r.final_step, r.final_loss, r.output_dir, r.duration_seconds, r.error_message, - r.loss_sparkline, + r.loss_sparkline, r.display_name, CASE WHEN r.status = 'stopped' AND r.output_dir IS NOT NULL diff --git a/studio/backend/tests/test_inference_model_validation.py b/studio/backend/tests/test_inference_model_validation.py new file mode 100644 index 0000000000..219affade3 --- /dev/null +++ b/studio/backend/tests/test_inference_model_validation.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +import os +import sys + +_backend = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, _backend) + +from models.inference import LoadRequest + + +def _base_load_request(**overrides): + data = { + "model_path": "unsloth/test-model-GGUF", + "hf_token": None, + "max_seq_length": 4096, + "load_in_4bit": True, + "is_lora": False, + "gguf_variant": "Q4_K_M", + } + data.update(overrides) + return LoadRequest.model_validate(data) + + +def test_blank_chat_template_override_normalizes_to_none(): + req = _base_load_request(chat_template_override = " \n\t") + + assert req.chat_template_override is None + + +def test_nonblank_chat_template_override_is_preserved_verbatim(): + template = " {{ messages }} " + req = _base_load_request(chat_template_override = template) + + assert req.chat_template_override == template diff --git a/studio/backend/tests/test_training_history_update.py b/studio/backend/tests/test_training_history_update.py new file mode 100644 index 0000000000..d8a0c93622 --- /dev/null +++ b/studio/backend/tests/test_training_history_update.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +import asyncio +import os +import sys + +import pytest +from pydantic import ValidationError + +_backend = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, _backend) + +from models.training import TrainingRunUpdateRequest +from routes import training_history + + +BASE_RUN = { + "id": "run-1", + "status": "stopped", + "model_name": "unsloth/test-model", + "dataset_name": "test-dataset", + "display_name": "Existing name", + "started_at": "2026-01-01T00:00:00Z", + "ended_at": "2026-01-01T00:01:00Z", + "total_steps": 10, + "final_step": 5, + "output_dir": "/tmp/run-1", + "resumed_later": False, +} + + +def _patch_run(monkeypatch: pytest.MonkeyPatch, payload: TrainingRunUpdateRequest): + stored = dict(BASE_RUN) + calls: list[str | None] = [] + + def fake_get_run(run_id: str): + assert run_id == "run-1" + return dict(stored) + + def fake_update_run_display_name(run_id: str, display_name: str | None): + assert run_id == "run-1" + calls.append(display_name) + stored["display_name"] = display_name + + monkeypatch.setattr(training_history, "get_run", fake_get_run) + monkeypatch.setattr( + training_history, + "update_run_display_name", + fake_update_run_display_name, + ) + monkeypatch.setattr(training_history, "can_resume_run", lambda run: True) + + result = asyncio.run( + training_history.update_training_run( + "run-1", + payload, + current_subject = "test-user", + ) + ) + return result, calls + + +def test_update_run_omitted_display_name_is_noop(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run(monkeypatch, TrainingRunUpdateRequest.model_validate({})) + + assert calls == [] + assert result.display_name == "Existing name" + assert result.can_resume is True + + +def test_update_run_explicit_null_clears_display_name(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run( + monkeypatch, + TrainingRunUpdateRequest.model_validate({"display_name": None}), + ) + + assert calls == [None] + assert result.display_name is None + assert result.can_resume is True + + +def test_update_run_whitespace_clears_display_name(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run( + monkeypatch, + TrainingRunUpdateRequest.model_validate({"display_name": " "}), + ) + + assert calls == [None] + assert result.display_name is None + + +def test_update_run_rejects_unknown_fields(): + with pytest.raises(ValidationError): + TrainingRunUpdateRequest.model_validate({"unknown": "value"}) + + +def test_update_run_rejects_overlong_display_name(): + with pytest.raises(ValidationError): + TrainingRunUpdateRequest.model_validate({"display_name": "x" * 121}) diff --git a/studio/frontend/public/blacklogo-c.png b/studio/frontend/public/blacklogo-c.png deleted file mode 100644 index 7ab9959536..0000000000 Binary files a/studio/frontend/public/blacklogo-c.png and /dev/null differ diff --git a/studio/frontend/public/blacklogo.png b/studio/frontend/public/blacklogo.png deleted file mode 100644 index e74c19040a..0000000000 Binary files a/studio/frontend/public/blacklogo.png and /dev/null differ diff --git a/studio/frontend/public/sidebar-logo-black.png b/studio/frontend/public/sidebar-logo-black.png deleted file mode 100644 index 3db8fea46a..0000000000 Binary files a/studio/frontend/public/sidebar-logo-black.png and /dev/null differ diff --git a/studio/frontend/public/sidebar-logo-white.png b/studio/frontend/public/sidebar-logo-white.png deleted file mode 100644 index f76b2ea396..0000000000 Binary files a/studio/frontend/public/sidebar-logo-white.png and /dev/null differ diff --git a/studio/frontend/public/unsloth-beta-black.png b/studio/frontend/public/unsloth-beta-black.png deleted file mode 100644 index beb3f6e82f..0000000000 Binary files a/studio/frontend/public/unsloth-beta-black.png and /dev/null differ diff --git a/studio/frontend/public/unsloth-beta-white.png b/studio/frontend/public/unsloth-beta-white.png deleted file mode 100644 index be689ff874..0000000000 Binary files a/studio/frontend/public/unsloth-beta-white.png and /dev/null differ diff --git a/studio/frontend/public/whitelogo-c.png b/studio/frontend/public/whitelogo-c.png deleted file mode 100644 index ee15955092..0000000000 Binary files a/studio/frontend/public/whitelogo-c.png and /dev/null differ diff --git a/studio/frontend/public/whitelogo.png b/studio/frontend/public/whitelogo.png deleted file mode 100644 index 9db7c0e943..0000000000 Binary files a/studio/frontend/public/whitelogo.png and /dev/null differ diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 13b8adfa48..f00381b91d 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -28,6 +28,16 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { @@ -35,8 +45,8 @@ import { ColumnInsertIcon, CursorInfo02Icon, Delete02Icon, - Download03Icon, - GemIcon, + DownloadSquare01Icon, + Edit03Icon, Globe02Icon, HelpCircleIcon, Search01Icon, @@ -44,6 +54,7 @@ import { PencilEdit02Icon, LayoutAlignLeftIcon, Settings02Icon, + TestTube01Icon, ZapIcon, } from "@hugeicons/core-free-icons"; import { @@ -52,25 +63,34 @@ import { } from "@/components/ui/tooltip"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react"; +import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; -import { useTrainingRuntimeStore } from "@/features/training"; +import { + ChatSearchDialog, + deleteChatItem, + renameChatItem, + useChatRuntimeStore, + useChatSearchStore, + useChatSidebarItems, + type SidebarItem, +} from "@/features/chat"; import { useSettingsDialogStore } from "@/features/settings"; import { useEffectiveProfile, UserAvatar } from "@/features/profile"; import { usePlatformStore } from "@/config/env"; import { TOUR_OPEN_EVENT } from "@/features/tour"; import { - useChatSidebarItems, - deleteChatItem, -} from "@/features/chat/hooks/use-chat-sidebar-items"; -import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; -import { useChatSearchStore } from "@/features/chat/stores/chat-search-store"; -import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog"; -import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training"; + deleteTrainingRun, + emitTrainingRunDeleted, + emitTrainingRunUpdated, + removeTrainingUnloadGuard, + renameTrainingRun, + useTrainingHistorySidebarItems, + useTrainingRuntimeStore, +} from "@/features/training"; import type { TrainingRunSummary } from "@/features/training"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { ShutdownDialog } from "@/components/shutdown-dialog"; -import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard"; function getTourId(pathname: string): string | null { if (pathname.startsWith("/studio")) return "studio"; @@ -79,6 +99,16 @@ function getTourId(pathname: string): string | null { return null; } +// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4 +// and #5 of the 5-path definition). Slicing to the first three paths +// keeps the test-tube outline + horizontal cap + liquid line, dropping +// the bubbles. The original export stays untouched, and HugeiconsIcon +// renders this trimmed array exactly the same way. +const TestTubeOutlineIcon = TestTube01Icon.slice( + 0, + 3, +) as typeof TestTube01Icon; + function runStatusDotClass(status: TrainingRunSummary["status"]): string { switch (status) { case "running": @@ -141,10 +171,10 @@ function NavItem({ onClick={onClick} isActive={active} data-tour={dataTour} - className="h-[32px] rounded-[10px] gap-[8.5px] px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[11px] group-data-[collapsible=icon]:mx-auto" + className="sidebar-nav-btn h-[35px] 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" > - - {label} + + {label} {children} @@ -181,6 +211,17 @@ export function AppSidebar() { useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]); useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]); + const scrollRef = useRef(null); + const [scrolled, setScrolled] = useState(false); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const handler = () => setScrolled(el.scrollTop > 0); + handler(); + el.addEventListener("scroll", handler, { passive: true }); + return () => el.removeEventListener("scroll", handler); + }, []); + const isRecipesRoute = pathname.startsWith("/data-recipes"); const { displayTitle, avatarDataUrl } = useEffectiveProfile(); @@ -195,7 +236,7 @@ export function AppSidebar() { : undefined; // Training runs - const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems( + const { items: runItems } = useTrainingHistorySidebarItems( !chatOnly && isStudioRoute, ); const activeJobId = useTrainingRuntimeStore((s) => s.jobId); @@ -213,6 +254,93 @@ export function AppSidebar() { }); } + type RenameTarget = + | { kind: "chat"; item: SidebarItem; current: string } + | { kind: "run"; run: TrainingRunSummary; current: string }; + const [renamingTarget, setRenamingTarget] = useState( + null, + ); + const [renameDraft, setRenameDraft] = useState(""); + const renameTrimmed = renameDraft.trim(); + const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null; + const renameDirty = + renamingTarget !== null && + (renamingTarget.kind === "chat" + ? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current + : renameTrimmed.length > 0 + ? renameTrimmed !== renamingTarget.current + : renamingTarget.run.display_name != null); + + function openRenameChat(item: SidebarItem) { + setRenameDraft(item.title); + setRenamingTarget({ kind: "chat", item, current: item.title }); + } + function openRenameRun(run: TrainingRunSummary) { + const current = run.display_name ?? run.model_name; + setRenameDraft(current); + setRenamingTarget({ kind: "run", run, current }); + } + async function commitRename() { + const target = renamingTarget; + if (!target || !renameDirty) return; + setRenamingTarget(null); + if (target.kind === "chat") { + try { + await renameChatItem(target.item, renameTrimmed); + } catch (err) { + toast.error("Failed to rename chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } + try { + const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); + emitTrainingRunUpdated(updated); + } catch (err) { + toast.error("Failed to rename run", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + type DeleteTarget = + | { kind: "chat"; item: SidebarItem } + | { kind: "run"; run: TrainingRunSummary }; + const [confirmingDelete, setConfirmingDelete] = + useState(null); + + async function commitDelete() { + const target = confirmingDelete; + if (!target) return; + setConfirmingDelete(null); + if (target.kind === "chat") { + try { + await handleDeleteThread(target.item); + } catch (err) { + toast.error("Failed to delete chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } + if (target.run.status === "running") { + toast.error("Cannot delete a running training run"); + return; + } + try { + await deleteTrainingRun(target.run.id); + if (selectedHistoryRunId === target.run.id) { + setSelectedHistoryRunId(null); + } + emitTrainingRunDeleted(target.run.id); + } catch (err) { + toast.error("Failed to delete run", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + return ( <> - + {/* Expanded: compact logo + close toggle */}
unsloth - + BETA @@ -259,13 +384,17 @@ export function AppSidebar() { - + Close sidebar @@ -274,19 +403,23 @@ export function AppSidebar() { {/* Collapsed: panel icon doubles as expand trigger */} {!isMobile && ( -
+
- + Open sidebar @@ -294,7 +427,7 @@ export function AppSidebar() { )} - + - - {/* Navigate (no header) */} - - - - { - if (chatOnly) return; - navigate({ to: "/studio" }); - closeMobileIfOpen(); - }} - /> + + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> - { - navigate({ to: "/data-recipes" }); - closeMobileIfOpen(); - }} - /> + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> - { - if (chatOnly) return; - navigate({ to: "/export" }); - closeMobileIfOpen(); - }} - /> - - - + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + + + {/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */} {!isStudioRoute && chatItems.length > 0 && ( - - + + Recents - + {chatItems.map((item) => ( { navigate({ to: "/chat", @@ -410,17 +542,38 @@ export function AppSidebar() { > {item.title} - + + + + + + openRenameChat(item)}> + + Rename + + setConfirmingDelete({ kind: "chat", item })} + > + + Delete + + + ))} @@ -433,15 +586,15 @@ export function AppSidebar() { {/* Recent Runs */} {isStudioRoute && runItems.length > 0 && !chatOnly && ( - - + + Recents - + {runItems.map((run) => { const isActiveRun = @@ -453,7 +606,7 @@ export function AppSidebar() { > { setSelectedHistoryRunId(run.id); closeMobileIfOpen(); @@ -468,7 +621,7 @@ export function AppSidebar() { aria-hidden /> - {run.model_name} + {run.display_name ?? run.model_name} {formatRelativeShort(run.started_at)} @@ -478,25 +631,41 @@ export function AppSidebar() { {run.dataset_name} - + + + openRenameRun(run)}> + + Rename + + + setConfirmingDelete({ kind: "run", run }) } - await refreshRuns(); - } catch { - // ignore — next refresh will reconcile - } - }} - title="Delete" - className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[10px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100" - > - - + > + + Delete + + + ); })} @@ -516,7 +685,7 @@ export function AppSidebar() {
- {displayTitle} - Unsloth + {displayTitle} + Unsloth
@@ -536,13 +705,13 @@ export function AppSidebar() { useSettingsDialogStore.getState().openDialog()} > - + Settings ⌘, @@ -559,7 +728,7 @@ export function AppSidebar() { ref={anchorRef as React.Ref} onSelect={(e) => { e.preventDefault(); toggleTheme(); }} > - {isDark ? : } + {isDark ? : } {isDark ? "Light Mode" : "Dark Mode"} - + Guided Tour @@ -582,11 +751,11 @@ export function AppSidebar() { useSettingsDialogStore.getState().openDialog("about")} > - + Help setShutdownOpen(true)}> - + Shutdown @@ -601,6 +770,96 @@ export function AppSidebar() { onOpenChange={setShutdownOpen} onAfterShutdown={removeTrainingUnloadGuard} /> + { + if (!open) setConfirmingDelete(null); + }} + > + + + + {confirmingDelete?.kind === "run" + ? "Delete training run" + : "Delete chat"} + + + {confirmingDelete?.kind === "run" ? ( + <> + Are you sure you want to delete this run{" "} + {confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}? + + ) : confirmingDelete?.kind === "chat" ? ( + <> + Are you sure you want to delete this chat{" "} + {confirmingDelete.item.title}? + + ) : null} + + + + + + + + + { + if (!open) setRenamingTarget(null); + }} + > + + + + {renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"} + + + setRenameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } + }} + autoFocus + maxLength={120} + placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"} + aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"} + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + ); } diff --git a/studio/frontend/src/components/assistant-ui/attachment.tsx b/studio/frontend/src/components/assistant-ui/attachment.tsx index 074dba5320..b5b2810008 100644 --- a/studio/frontend/src/components/assistant-ui/attachment.tsx +++ b/studio/frontend/src/components/assistant-ui/attachment.tsx @@ -184,7 +184,7 @@ const AttachmentUI: FC = () => { {isComposer && } - + diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 7eb4b21ba7..d2c6208fda 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -7,7 +7,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { preprocessLaTeX } from "@/lib/latex"; import { openLink } from "@/lib/open-link"; import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; -import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons"; +import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { createCodePlugin } from "./code-plugin"; import { createMathPlugin } from "@streamdown/math"; @@ -50,9 +50,9 @@ const COPY_RESET_MS = 2000; const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i; const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; const ACTION_PANEL_CLASS = - "pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur dark:border-white/10 dark:bg-code-block dark:supports-[backdrop-filter]:bg-code-block"; + "pointer-events-auto flex shrink-0 items-center gap-1"; const ACTION_BUTTON_CLASS = - "cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"; + "flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50"; type CodeFence = { language: string | null; @@ -289,8 +289,9 @@ function MermaidCopyButton({ source }: { source: string }) { }} > ); @@ -308,7 +309,7 @@ function CodeBlockActions({ const { copied, showCopied } = useCopiedState(); return ( -
+
diff --git a/studio/frontend/src/components/assistant-ui/message-timing.tsx b/studio/frontend/src/components/assistant-ui/message-timing.tsx index df233812b4..5ad1bdabed 100644 --- a/studio/frontend/src/components/assistant-ui/message-timing.tsx +++ b/studio/frontend/src/components/assistant-ui/message-timing.tsx @@ -51,7 +51,7 @@ export const MessageTiming: FC<{ data-slot="message-timing-trigger" aria-label="Message timing" className={cn( - "flex items-center rounded-md p-1 font-mono text-muted-foreground text-xs tabular-nums transition-colors hover:bg-accent hover:text-accent-foreground", + "flex items-center rounded-[10px] p-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover", className, )} > @@ -62,7 +62,8 @@ export const MessageTiming: FC<{ side={side} sideOffset={8} data-slot="message-timing-popover" - className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md" + variant="rich" + className="[&_span>svg]:hidden!" >
{st ? ( diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 795bcb6d08..22bd7412ab 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -78,9 +78,9 @@ function ModelSelectorTrigger({ className={cn( "flex min-w-0 items-center gap-2 transition-colors", variant === "outline" && - "rounded-[8px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2e3035]", - variant === "ghost" && "rounded-[8px] hover:bg-[#ececec] dark:hover:bg-[#2e3035]", - variant === "muted" && "rounded-[8px] bg-muted hover:bg-muted/80", + "rounded-[10px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", + variant === "ghost" && "rounded-[10px] hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", + variant === "muted" && "rounded-[10px] bg-muted hover:bg-muted/80", size === "sm" && "h-8 px-3 text-xs", size === "default" && "h-9 px-3.5 text-sm", size === "lg" && "h-10 px-4 text-sm", @@ -145,7 +145,7 @@ function ModelSelectorContent({ align="start" data-tour={dataTour} className={cn( - "w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2", + "menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 p-2", className, )} > diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index fae3c22caf..a0f97967ef 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -175,7 +175,10 @@ function ModelRow({ return ( {content} - + {label} {vramTooltipText} @@ -187,7 +190,10 @@ function ModelRow({ return ( {content} - + {tooltipText} diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx index 81c8b0c213..3a55c3fa78 100644 --- a/studio/frontend/src/components/assistant-ui/sources.tsx +++ b/studio/frontend/src/components/assistant-ui/sources.tsx @@ -104,7 +104,7 @@ function Source({ variant={variant} size={size} className={cn( - "cursor-pointer outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", + "rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} > @@ -137,7 +137,7 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => { const displayTitle = source.title || domain; return ( - + @@ -146,16 +146,21 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => { - +

{source.title || domain}

-

{domain}

+

{domain}

{source.description && ( -

+

{source.description}

)} @@ -245,7 +250,7 @@ const SourcesGroup: FC = () => { const hiddenCount = sources.length - (visibleCount ?? sources.length); return ( -
+
{/* Hidden measurement container — renders all badges to measure row positions */}
{ onClick={() => setExpanded(true)} className={cn( badgeVariants({ variant: "outline", size: "default" }), - "cursor-pointer text-muted-foreground hover:text-foreground", + "rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!", )} > +{hiddenCount} more @@ -285,7 +290,7 @@ const SourcesGroup: FC = () => { onClick={() => setExpanded(false)} className={cn( badgeVariants({ variant: "outline", size: "default" }), - "cursor-pointer text-muted-foreground hover:text-foreground", + "rounded-full cursor-pointer text-muted-foreground hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover!", )} > Show less diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0d6cd3bbf9..31a1fb21e0 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -54,10 +54,8 @@ import { import { ArrowDownIcon, ArrowUpIcon, - CheckIcon, ChevronLeftIcon, ChevronRightIcon, - CopyIcon, DownloadIcon, GlobeIcon, HeadphonesIcon, @@ -66,14 +64,13 @@ import { LoaderIcon, MicIcon, MoreHorizontalIcon, - PencilIcon, RefreshCwIcon, SquareIcon, TerminalIcon, - Trash2Icon, XIcon, } from "lucide-react"; -import { motion } from "motion/react"; +import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { type FC, type FormEvent, @@ -108,9 +105,9 @@ export const Thread: FC<{ @@ -121,7 +118,7 @@ export const Thread: FC<{ scrollToBottomOnInitialize={false} scrollToBottomOnThreadSwitch={false} className={cn( - "aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5", + "aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5", hideComposer ? "pt-4" : "pt-[48px]", )} > @@ -164,7 +161,7 @@ export const Thread: FC<{ {!hideComposer && ( hideWelcome || !thread.isEmpty}> -
+
-

- LLMs can make mistakes. Double-check all responses. +

+ LLMs can make mistakes. Double-check responses.

@@ -204,7 +201,7 @@ const ThreadScrollToBottom: FC = () => { isAtBottom && "invisible pointer-events-none", )} > - + ); }; @@ -253,14 +250,9 @@ const GeneratingSpinner: FC = () => { const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => { return (
- +
- +
); }; @@ -306,7 +298,7 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { = ({ disabled }) => { {isTauri ? ( // Phase 1 native model drops own Tauri local-path drops. Restore browser // attachment drops in Tauri when Phase 1d adds attachment-token bridging. -
+
{composerContent}
) : ( - + {composerContent} )} @@ -455,14 +447,8 @@ const ReasoningToggle: FC = () => { setReasoningEnabled(next); applyQwenThinkingParams(next); }} - className={cn( - "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", - disabled - ? "cursor-not-allowed opacity-40" - : reasoningEnabled - ? "bg-primary/10 text-primary hover:bg-primary/20" - : "bg-muted text-muted-foreground hover:bg-muted-foreground/15", - )} + className="composer-pill-btn" + data-active={reasoningEnabled && !disabled ? "true" : "false"} aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"} > {reasoningEnabled && !disabled ? ( @@ -527,14 +513,8 @@ const WebSearchToggle: FC = () => { type="button" disabled={disabled} onClick={() => setToolsEnabled(!toolsEnabled)} - className={cn( - "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", - disabled - ? "cursor-not-allowed opacity-40" - : toolsEnabled - ? "bg-primary/10 text-primary hover:bg-primary/20" - : "bg-muted text-muted-foreground hover:bg-muted-foreground/15", - )} + className="composer-pill-btn" + data-active={toolsEnabled && !disabled ? "true" : "false"} aria-label={toolsEnabled ? "Disable web search" : "Enable web search"} > @@ -557,14 +537,8 @@ const CodeToolsToggle: FC = () => { type="button" disabled={disabled} onClick={() => setCodeToolsEnabled(!codeToolsEnabled)} - className={cn( - "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", - disabled - ? "cursor-not-allowed opacity-40" - : codeToolsEnabled - ? "bg-primary/10 text-primary hover:bg-primary/20" - : "bg-muted text-muted-foreground hover:bg-muted-foreground/15", - )} + className="composer-pill-btn" + data-active={codeToolsEnabled && !disabled ? "true" : "false"} aria-label={ codeToolsEnabled ? "Disable code execution" : "Enable code execution" } @@ -635,7 +609,7 @@ const ToolStatusDisplay: FC = () => { const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => { return ( -
+
@@ -725,10 +699,10 @@ const GeneratingIndicator: FC = () => { const AssistantMessage: FC = () => { return ( -
+
{
-
- +
+
@@ -789,9 +763,13 @@ const DeleteMessageButton: FC = () => { tooltip="Delete message" disabled={isRunning} onClick={handleDelete} - className="text-muted-foreground hover:text-destructive" + className="text-chat-icon-fg hover:text-destructive" > - + ); }; @@ -817,7 +795,11 @@ const CopyButton: FC = () => { return ( - {copied ? : } + ); }; @@ -826,40 +808,39 @@ const AssistantActionBar: FC = () => { return ( - + - - + e.preventDefault()} className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md" > - + Export as Markdown + ); }; @@ -884,22 +865,21 @@ const UserMessageAudio: FC = () => { const UserMessage: FC = () => { return (
-
+
-
+
+
- - ); }; @@ -908,12 +888,12 @@ const UserActionBar: FC = () => { return ( - + @@ -981,23 +961,31 @@ const BranchPicker: FC = ({ - - - + - - / + + / - - - + ); diff --git a/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx b/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx index e498999068..4d72285101 100644 --- a/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx +++ b/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx @@ -37,7 +37,9 @@ export const TooltipIconButton = forwardRef< {tooltip} - {tooltip} + + {tooltip} + ); }); diff --git a/studio/frontend/src/components/assistant-ui/use-intent-aware-autoscroll.tsx b/studio/frontend/src/components/assistant-ui/use-intent-aware-autoscroll.tsx index ad01afcdaf..d5927f77c1 100644 --- a/studio/frontend/src/components/assistant-ui/use-intent-aware-autoscroll.tsx +++ b/studio/frontend/src/components/assistant-ui/use-intent-aware-autoscroll.tsx @@ -60,6 +60,16 @@ const UPWARD_DETACH_THRESHOLD_PX = 2; // keeps the viewport pinned as long as content keeps arriving; settles // this long after the last change. const FOLLOW_SETTLE_MS = 600; +// Maximum stabilizer compensation. The stabilizer is meant to absorb +// sub-frame transients (~5-15px shiki re-renders, ~8px action-bar +// reservation drift). Anything larger is almost certainly an intentional +// content removal — message delete, regenerate's old-content clear, +// reasoning-panel collapse — and should *not* be silently padded over, +// which would leave persistent empty space below the last message. +// Above this threshold we release the stabilizer immediately and let +// the autoscroll re-pin to the new content height, which is the natural +// behavior the user expects for those actions. +const STABILIZER_MAX_PX = 64; export type ScrollToBottom = (behavior?: ScrollBehavior) => void; @@ -202,6 +212,21 @@ export function useIntentAwareAutoScroll(): { return false; }; + // Stabilizer state — see `stabilize` below for the full + // explanation. Lives in this closure so it resets naturally + // whenever the viewport remounts (Compare-pane swap, thread + // switch with remount, etc.). + let stabilizerPx = 0; + let maxContentHeight = 0; + + const releaseStabilizer = (): void => { + if (stabilizerPx === 0) { + return; + } + stabilizerPx = 0; + el.style.removeProperty("--aui-scroll-stabilizer"); + }; + const extendFollow = (): void => { if (userDetachedRef.current) { return; @@ -212,6 +237,13 @@ export function useIntentAwareAutoScroll(): { const detach = (): void => { userDetachedRef.current = true; followUntilRef.current = 0; + // The stabilizer is only meaningful while we're actively + // pinning to the bottom. Once the user scrolls up, drop any + // residual padding so the bottom stays flush whenever they + // come back. Safe here because the user is mid-content — + // shrinking scrollHeight cannot cap their scrollTop. + releaseStabilizer(); + maxContentHeight = el.scrollHeight; }; const requestTick = (): void => { @@ -334,21 +366,116 @@ export function useIntentAwareAutoScroll(): { requestTick(); }; - const resizeObserver = new ResizeObserver(() => { - extendFollow(); - requestTick(); - }); + // Scroll stabilizer. + // + // Problem: when a trailing code block finalizes at stream end + // (Streamdown flips `isAnimating` → false, shiki re-renders the + //
 with highlight spans), the block's rendered height
+      // briefly dips and then recovers a frame later. That dip shrinks
+      // `scrollHeight`, which the browser handles by *synchronously*
+      // capping `scrollTop` to the new (smaller) `scrollHeight −
+      // clientHeight`. The cap is visible as a one-frame upward jump;
+      // the recovery a frame or two later is the "snap back" the user
+      // perceives as a flicker. No amount of programmatic re-scrolling
+      // can prevent this — once `scrollHeight` drops, the cap has
+      // already happened and `scrollTop` cannot be pushed past the new
+      // max.
+      //
+      // Fix: keep `scrollHeight` monotonic across the follow window.
+      // We track the maximum *content* height (scrollHeight minus our
+      // own padding contribution) seen during follow, and compensate
+      // for any shortfall by writing the deficit into a CSS custom
+      // property `--aui-scroll-stabilizer`, which the viewport's
+      // `padding-bottom` reads. A 5px content shrink instantly grows
+      // the padding by 5px, so the browser sees no scrollHeight change
+      // and never caps scrollTop. As content naturally grows past its
+      // prior high-water mark (e.g. the next message streams in), the
+      // padding shrinks back toward zero.
+      //
+      // Self-contained: lives entirely on the viewport element via a
+      // CSS variable. Doesn't touch the composer, the action bar, the
+      // message footer, the spacer, or any other UI.
+      //
+      // Returns the post-adjustment scrollHeight so a single layout
+      // read per observer callback can feed both stabilization and
+      // pinning, avoiding a redundant flush.
+      const stabilize = (): number => {
+        const sh = el.scrollHeight;
+        const currentContent = sh - stabilizerPx;
+        const followActive =
+          !userDetachedRef.current &&
+          performance.now() < followUntilRef.current;
+        if (!followActive) {
+          // Outside the follow window we stop adjusting, but we keep
+          // `maxContentHeight` aligned with reality so the next follow
+          // session starts from the current content size, not stale.
+          maxContentHeight = currentContent;
+          return sh;
+        }
+        if (currentContent > maxContentHeight) {
+          maxContentHeight = currentContent;
+        }
+        const shrink = maxContentHeight - currentContent;
+        // Large shrinks (over STABILIZER_MAX_PX) are intentional content
+        // removals — message delete, regenerate clearing the old
+        // assistant turn, reasoning-panel collapse. Compensating for
+        // those would leave persistent empty space at the bottom of the
+        // viewport, which the user reads as "weird empty gap." Release
+        // the stabilizer instead and rebase the high-water mark; the
+        // pinIfFollowing call right after will smoothly re-anchor to
+        // the new (smaller) bottom.
+        if (shrink > STABILIZER_MAX_PX) {
+          maxContentHeight = currentContent;
+          if (stabilizerPx !== 0) {
+            stabilizerPx = 0;
+            el.style.removeProperty("--aui-scroll-stabilizer");
+          }
+          return currentContent;
+        }
+        const needed = Math.max(0, shrink);
+        if (needed !== stabilizerPx) {
+          stabilizerPx = needed;
+          el.style.setProperty(
+            "--aui-scroll-stabilizer",
+            `${stabilizerPx}px`,
+          );
+        }
+        return currentContent + stabilizerPx;
+      };
 
-      const mutationObserver = new MutationObserver(() => {
-        extendFollow();
-        requestTick();
-      });
+      // Synchronous pin-to-bottom. Observer callbacks run in the event-
+      // loop's "update the rendering" step (after layout, before paint),
+      // so the scrollTo here is composited in the same frame as the
+      // mutation that triggered the observer.
+      const pinIfFollowing = (scrollHeight: number): void => {
+        if (userDetachedRef.current) {
+          return;
+        }
+        if (performance.now() >= followUntilRef.current) {
+          return;
+        }
+        if (scrollHeight <= el.clientHeight) {
+          return;
+        }
+        el.scrollTo({ top: scrollHeight, behavior: "instant" });
+      };
 
-      const onViewportResize = () => {
+      // All three layout-change signals fan in here so there's a
+      // single place to understand "what runs when the viewport's
+      // content shape changes". Order matters: extend first so the
+      // stabilizer sees the follow window as active; stabilize before
+      // pinning so we scroll to the post-adjustment scrollHeight.
+      const onLayoutChange = (): void => {
         extendFollow();
+        const scrollHeight = stabilize();
+        pinIfFollowing(scrollHeight);
         requestTick();
       };
 
+      const resizeObserver = new ResizeObserver(onLayoutChange);
+      const mutationObserver = new MutationObserver(onLayoutChange);
+      const onViewportResize = onLayoutChange;
+
       // Fresh attach always starts pinned. `userDetachedRef` survives
       // ref rebinds (it's hook-scoped), so if the viewport element is
       // ever unmounted and remounted without an AUI lifecycle event
@@ -366,7 +493,13 @@ export function useIntentAwareAutoScroll(): {
       setIsAtBottom(true);
       requestTick();
 
-      resizeObserver.observe(el);
+      // Observe the border box, not the content box. The stabilizer
+      // writes `padding-bottom`, which shrinks the content box; if we
+      // observed that, every stabilizer adjustment would echo back as
+      // a resize and re-enter onLayoutChange. Border-box stays put
+      // through padding changes but still tracks parent-driven
+      // resizes (window, sidebar toggle) — which is all we need.
+      resizeObserver.observe(el, { box: "border-box" });
       mutationObserver.observe(el, {
         childList: true,
         subtree: true,
diff --git a/studio/frontend/src/components/ui/button.tsx b/studio/frontend/src/components/ui/button.tsx
index e95ab8faa7..9e27446989 100644
--- a/studio/frontend/src/components/ui/button.tsx
+++ b/studio/frontend/src/components/ui/button.tsx
@@ -1,69 +1,69 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
 
-/* eslint-disable react-refresh/only-export-components */
-
-import { type VariantProps, cva } from "class-variance-authority";
-import { Slot } from "radix-ui";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-export const buttonVariants = cva(
-  "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
-  {
-    variants: {
-      variant: {
-        default: "bg-primary text-primary-foreground hover:bg-primary/80",
-        dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
-        outline:
-          "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
-        secondary:
-          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
-        ghost:
-          "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
-        destructive:
-          "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
-        link: "text-primary underline-offset-4 hover:underline",
-      },
-      size: {
-        default:
-          "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
-        xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
-        sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
-        lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
-        icon: "size-9",
-        "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
-        "icon-sm": "size-8",
-        "icon-lg": "size-10",
-      },
-    },
-    defaultVariants: {
-      variant: "default",
-      size: "default",
-    },
-  },
-);
-
-export function Button({
-  className,
-  variant = "default",
-  size = "default",
-  asChild = false,
-  ...props
-}: React.ComponentProps<"button"> &
-  VariantProps & {
-    asChild?: boolean;
-  }): React.ReactElement {
-  const Comp = asChild ? Slot.Root : "button";
-
-  return (
-    
-  );
-}
+/* eslint-disable react-refresh/only-export-components */
+
+import { type VariantProps, cva } from "class-variance-authority";
+import { Slot } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export const buttonVariants = cva(
+  "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/80",
+        dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
+        outline:
+          "border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+        ghost:
+          "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+        destructive:
+          "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default:
+          "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
+        xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+        icon: "size-9",
+        "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+export function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps & {
+    asChild?: boolean;
+  }): React.ReactElement {
+  const Comp = asChild ? Slot.Root : "button";
+
+  return (
+    
+  );
+}
diff --git a/studio/frontend/src/components/ui/select.tsx b/studio/frontend/src/components/ui/select.tsx
index f65d7c3676..4044c164e5 100644
--- a/studio/frontend/src/components/ui/select.tsx
+++ b/studio/frontend/src/components/ui/select.tsx
@@ -1,244 +1,257 @@
 // 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 { Select as SelectPrimitive } from "radix-ui";
-import type * as React from "react";
-import { createContext, useContext, useState } from "react";
-
-import { cn } from "@/lib/utils";
-import { useDialogPortalContainer } from "@/components/ui/dialog";
-import {
-  ArrowDown01Icon,
-  ArrowUp01Icon,
-  Tick02Icon,
-  UnfoldMoreIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-const SelectOpenContext = createContext(false);
-
-function Select({
-  onOpenChange,
-  ...props
-}: React.ComponentProps) {
-  const [isOpen, setIsOpen] = useState(false);
-  return (
-    
-       {
-          setIsOpen(open);
-          onOpenChange?.(open);
-        }}
-        {...props}
-      />
-    
-  );
-}
-
-function SelectGroup({
-  className,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-  );
-}
-
-function SelectValue({
-  ...props
-}: React.ComponentProps) {
-  return ;
-}
-
-function SelectTrigger({
-  className,
-  size = "default",
-  children,
-  ...props
-}: React.ComponentProps & {
-  size?: "sm" | "default";
-}) {
-  const isOpen = useContext(SelectOpenContext);
-
-  return (
-    
-      {children}
-      
-        
-      
-    
-  );
-}
-
-function SelectContent({
-  className,
-  children,
-  position = "item-aligned",
-  align = "center",
-  container,
-  ...props
-}: React.ComponentProps & {
-  container?: HTMLElement | null;
-}) {
-  const dialogContainer = useDialogPortalContainer();
-  return (
-    
-      
-        
-        
-          {children}
-        
-        
-      
-    
-  );
-}
-
-function SelectLabel({
-  className,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-  );
-}
-
-function SelectItem({
-  className,
-  children,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-      
-        
-          
-        
-      
-      {children}
-    
-  );
-}
-
-function SelectSeparator({
-  className,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-  );
-}
-
-function SelectScrollUpButton({
-  className,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-      
-    
-  );
-}
-
-function SelectScrollDownButton({
-  className,
-  ...props
-}: React.ComponentProps) {
-  return (
-    
-      
-    
-  );
-}
-
-export {
-  Select,
-  SelectContent,
-  SelectGroup,
-  SelectItem,
-  SelectLabel,
-  SelectScrollDownButton,
-  SelectScrollUpButton,
-  SelectSeparator,
-  SelectTrigger,
-  SelectValue,
-};
+"use client";
+
+import { Select as SelectPrimitive } from "radix-ui";
+import type * as React from "react";
+import { createContext, useContext, useState } from "react";
+
+import { cn } from "@/lib/utils";
+import { useDialogPortalContainer } from "@/components/ui/dialog";
+import {
+  ArrowDown01Icon,
+  ArrowUp01Icon,
+  Tick02Icon,
+  UnfoldMoreIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+const SelectOpenContext = createContext(false);
+
+function Select({
+  onOpenChange,
+  ...props
+}: React.ComponentProps) {
+  const [isOpen, setIsOpen] = useState(false);
+  return (
+    
+       {
+          setIsOpen(open);
+          onOpenChange?.(open);
+        }}
+        {...props}
+      />
+    
+  );
+}
+
+function SelectGroup({
+  className,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+  );
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps) {
+  return ;
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  icon,
+  iconClassName,
+  animateRadius = true,
+  ...props
+}: React.ComponentProps & {
+  size?: "sm" | "default";
+  icon?: typeof UnfoldMoreIcon;
+  iconClassName?: string;
+  animateRadius?: boolean;
+}) {
+  const isOpen = useContext(SelectOpenContext);
+
+  return (
+    
+      {children}
+      
+        
+      
+    
+  );
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "item-aligned",
+  align = "center",
+  container,
+  ...props
+}: React.ComponentProps & {
+  container?: HTMLElement | null;
+}) {
+  const dialogContainer = useDialogPortalContainer();
+  return (
+    
+      
+        
+        
+          {children}
+        
+        
+      
+    
+  );
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+  );
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+      
+        
+          
+        
+      
+      {children}
+    
+  );
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+  );
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+      
+    
+  );
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps) {
+  return (
+    
+      
+    
+  );
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+};
diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx
index 8eb8c51491..6be77d01b9 100644
--- a/studio/frontend/src/components/ui/sidebar.tsx
+++ b/studio/frontend/src/components/ui/sidebar.tsx
@@ -1,768 +1,770 @@
-// 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 * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Separator } from "@/components/ui/separator"
-import {
-  Sheet,
-  SheetContent,
-  SheetDescription,
-  SheetHeader,
-  SheetTitle,
-} from "@/components/ui/sheet"
-import { Skeleton } from "@/components/ui/skeleton"
-import {
-  Tooltip,
-  TooltipContent,
-  TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { useIsMobile } from "@/hooks/use-mobile"
-import { HugeiconsIcon } from "@hugeicons/react"
-import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
-
-const noop = () => {}
-
-const SIDEBAR_WIDTH = "16rem"
-const SIDEBAR_WIDTH_ICON = "3rem"
-const SIDEBAR_KEYBOARD_SHORTCUT = "b"
-
-type SidebarContextProps = {
-  state: "expanded" | "collapsed"
-  open: boolean
-  setOpen: (open: boolean) => void
-  openMobile: boolean
-  setOpenMobile: (open: boolean) => void
-  isMobile: boolean
-  toggleSidebar: () => void
-  hasPinMode: boolean
-  pinned: boolean
-  setPinned: (value: boolean) => void
-  togglePinned: () => void
-}
-
-const SidebarContext = React.createContext(null)
-
-function useSidebar() {
-  const context = React.useContext(SidebarContext)
-  if (!context) {
-    throw new Error("useSidebar must be used within a SidebarProvider.")
-  }
-
-  return context
-}
-
-function SidebarProvider({
-  defaultOpen = true,
-  open: openProp,
-  onOpenChange: setOpenProp,
-  pinned: pinnedProp,
-  setPinned: setPinnedProp,
-  togglePinned: togglePinnedProp,
-  className,
-  style,
-  children,
-  ...props
-}: React.ComponentProps<"div"> & {
-  defaultOpen?: boolean
-  open?: boolean
-  onOpenChange?: (open: boolean) => void
-  pinned?: boolean
-  setPinned?: (value: boolean) => void
-  togglePinned?: () => void
-}) {
-  const isMobile = useIsMobile()
-  const [openMobile, setOpenMobile] = React.useState(false)
-
-  const prevIsMobileRef = React.useRef(isMobile)
-  React.useEffect(() => {
-    if (prevIsMobileRef.current && !isMobile) {
-      setOpenMobile(false)
-    }
-    prevIsMobileRef.current = isMobile
-  }, [isMobile])
-
-  // Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
-  const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
-
-  // This is the internal state of the sidebar.
-  // We use openProp and setOpenProp for control from outside the component.
-  const [_open, _setOpen] = React.useState(defaultOpen)
-
-  // When pin mode is active, open is driven entirely by `pinned` (explicit
-  // user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
-  const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
-
-  const setOpen = React.useCallback(
-    (value: boolean | ((value: boolean) => boolean)) => {
-      const openState = typeof value === "function" ? value(open) : value
-
-      if (hasPinMode) {
-        // In pin mode, setOpen controls pinned state.
-        setPinnedProp?.(openState)
-        return
-      }
-
-      if (setOpenProp) {
-        setOpenProp(openState)
-      } else {
-        _setOpen(openState)
-      }
-    },
-    [setOpenProp, open, hasPinMode, setPinnedProp]
-  )
-
-  // Helper to toggle the sidebar.
-  const toggleSidebar = React.useCallback(() => {
-    if (isMobile) return setOpenMobile((open) => !open)
-    if (hasPinMode && togglePinnedProp) return togglePinnedProp()
-    return setOpen((open) => !open)
-  }, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
-
-  // Adds a keyboard shortcut to toggle the sidebar.
-  React.useEffect(() => {
-    const handleKeyDown = (event: KeyboardEvent) => {
-      if (
-        event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
-        (event.metaKey || event.ctrlKey)
-      ) {
-        event.preventDefault()
-        toggleSidebar()
-      }
-    }
-
-    window.addEventListener("keydown", handleKeyDown)
-    return () => window.removeEventListener("keydown", handleKeyDown)
-  }, [toggleSidebar])
-
-  // We add a state so that we can do data-state="expanded" or "collapsed".
-  // This makes it easier to style the sidebar with Tailwind classes.
-  const state = open ? "expanded" : "collapsed"
-
-  const pinned = pinnedProp ?? false
-  const setPinned = setPinnedProp ?? noop
-  const togglePinned = togglePinnedProp ?? noop
-
-  const contextValue = React.useMemo(
-    () => ({
-      state,
-      open,
-      setOpen,
-      isMobile,
-      openMobile,
-      setOpenMobile,
-      toggleSidebar,
-      hasPinMode,
-      pinned,
-      setPinned,
-      togglePinned,
-    }),
-    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
-  )
-
-  return (
-    
-      
- {children} -
-
- ) -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - dir, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" -}) { - const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar() - - if (collapsible === "none") { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
-
-
- {children} -
-
-
- ) -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps) { - const { toggleSidebar } = useSidebar() - - return ( - - ) -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar() - - return ( - + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + - + Open configuration diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 08c5ef4080..f20d621d08 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -22,11 +22,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; import { InputGroup, InputGroupAddon, - InputGroupButton, InputGroupInput, } from "@/components/ui/input-group"; import { @@ -50,23 +48,20 @@ import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { ArrowDown01Icon, - CodeIcon, - Delete02Icon, - FloppyDiskIcon, - Settings02Icon, - Settings05Icon, - SlidersHorizontalIcon, - Wrench01Icon, + ArrowTurnBackwardIcon, + InformationCircleIcon, + LayoutAlignRightIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Tooltip, TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip as TooltipPrimitive } from "radix-ui"; -import { AnimatePresence, motion } from "motion/react"; +import { ChevronDown } from "lucide-react"; import { Fragment, type ReactNode } from "react"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { @@ -174,7 +169,10 @@ function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); return presets; } - const mergedPresets = normalizeCustomPresets([...presets, ...importedPresets]); + const mergedPresets = normalizeCustomPresets([ + ...presets, + ...importedPresets, + ]); saveCustomPresets(mergedPresets); try { localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); @@ -232,6 +230,139 @@ function loadSavedActivePreset(): string { } } +function InfoHint({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + ); +} + +/** + * Editable numeric value display. + * + * Renders as a single that *looks* like text by default — + * transparent background, no border, no ring — and only shows a faint + * surface tint on hover/focus to signal editability. When unfocused, + * the input shows the formatted display string (`displayValue ?? value`, + * so labels like "Off" / "Max" still render); on focus, it switches to + * the raw numeric value, selects it, and accepts free text input. + * Commit happens on blur or Enter; Escape reverts. The clamp-to-range + * happens on commit so users can type intermediate values without the + * input fighting them mid-keystroke. Single component shared by every + * slider value and the Context Length input so the click-to-edit + * affordance is consistent across the panel. + */ +function snapToStep( + value: number, + step: number, + min?: number, + max?: number, +): number { + const lo = min ?? Number.NEGATIVE_INFINITY; + const hi = max ?? Number.POSITIVE_INFINITY; + const clamped = Math.min(Math.max(value, lo), hi); + const stepStr = String(step); + const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0; + const base = Number.isFinite(lo) ? lo : 0; + const snapped = base + Math.round((clamped - base) / step) * step; + const reclamped = Math.min(Math.max(snapped, lo), hi); + return Number(reclamped.toFixed(decimals)); +} + +function NumericValueInput({ + value, + min, + max, + step, + onChange, + displayValue, + className, + ariaLabel, + size: sizeAttr, +}: { + value: number; + min?: number; + max?: number; + step: number; + onChange: (v: number) => void; + displayValue?: string; + className?: string; + ariaLabel?: string; + size?: number; +}) { + const [focused, setFocused] = useState(false); + const [draft, setDraft] = useState(""); + const cancelBlurCommitRef = useRef(false); + + const commit = (raw: string) => { + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) { + return; + } + const final = snapToStep(parsed, step, min, max); + if (final !== value) { + onChange(final); + } + }; + + return ( + { + cancelBlurCommitRef.current = false; + setDraft(String(value)); + setFocused(true); + // Defer the select() so it runs after the value swap above. + const target = e.currentTarget; + requestAnimationFrame(() => target.select()); + }} + onBlur={() => { + if (cancelBlurCommitRef.current) { + cancelBlurCommitRef.current = false; + } else { + commit(draft); + } + setFocused(false); + }} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } else if (e.key === "Escape") { + cancelBlurCommitRef.current = true; + setDraft(String(value)); + e.currentTarget.blur(); + } + }} + className={cn("panel-number-input", className)} + /> + ); +} + function ParamSlider({ label, value, @@ -240,6 +371,8 @@ function ParamSlider({ step, onChange, displayValue, + info, + valueSize, }: { label: string; value: number; @@ -248,21 +381,36 @@ function ParamSlider({ step: number; onChange: (v: number) => void; displayValue?: string; + info?: ReactNode; + valueSize?: number; }) { return ( -
-
- {label} - - {displayValue ?? value} - +
+
+
+ + {label} + + {info && {info}} +
+
onChange(v)} + onValueChange={([v]) => onChange(snapToStep(v, step, min, max))} + className="panel-slider" />
); @@ -306,15 +454,15 @@ function saveCollapsibleOpen(label: string, open: boolean) { } function CollapsibleSection({ - icon, label, children, defaultOpen = false, + first = false, }: { - icon: Parameters[0]["icon"]; label: string; children?: ReactNode; defaultOpen?: boolean; + first?: boolean; }) { const [open, setOpen] = useState(() => { const saved = loadCollapsibleState(); @@ -322,7 +470,12 @@ function CollapsibleSection({ }); return ( -
+
- - {open && ( - -
{children}
-
+ className={cn( + "flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0", + first ? "pt-4 pb-5" : "py-5", )} -
+ > + {label} + + + + + {open &&
{children}
}
); } @@ -378,18 +517,26 @@ export function ChatSettingsPanel({ }: ChatSettingsPanelProps) { const isMobile = useIsMobile(); const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; + const hasModelContent = isGguf || Boolean(params.checkpoint); const speculativeType = useChatRuntimeStore((s) => s.speculativeType); const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType); const loadedSpeculativeType = useChatRuntimeStore( (s) => s.loadedSpeculativeType, ); - const currentModels = useChatRuntimeStore((s) => s.models); const modelRequiresTrustRemoteCode = useChatRuntimeStore( (s) => s.modelRequiresTrustRemoteCode, ); const currentCheckpoint = params.checkpoint; - const currentModelIsVision = - currentModels.find((m) => m.id === currentCheckpoint)?.isVision ?? false; + const currentModelIsMultimodal = useChatRuntimeStore((s) => { + if (s.loadedIsMultimodal) return true; + const m = s.models.find((m) => m.id === currentCheckpoint); + return ( + Boolean(m?.isVision) || + Boolean(m?.isAudio) || + Boolean(m?.hasAudioInput) || + m?.audioType === "audio_vlm" + ); + }); const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const ggufMaxContextLength = useChatRuntimeStore( (s) => s.ggufMaxContextLength, @@ -415,6 +562,16 @@ export function ChatSettingsPanel({ const ctxDirty = customContextLength !== null; const specDirty = speculativeType !== loadedSpeculativeType; const modelSettingsDirty = kvDirty || ctxDirty || specDirty; + const chatTemplateOverride = useChatRuntimeStore( + (s) => s.chatTemplateOverride, + ); + const loadedChatTemplateOverride = useChatRuntimeStore( + (s) => s.loadedChatTemplateOverride, + ); + const setChatTemplateOverride = useChatRuntimeStore( + (s) => s.setChatTemplateOverride, + ); + const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride; const [customPresets, setCustomPresets] = useState(() => loadSavedCustomPresets(), ); @@ -424,10 +581,6 @@ export function ChatSettingsPanel({ const [presetNameInput, setPresetNameInput] = useState(() => loadSavedActivePreset(), ); - const presetControlRowRef = useRef(null); - const [presetMenuWidthPx, setPresetMenuWidthPx] = useState< - number | undefined - >(undefined); const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false); const [systemPromptDraft, setSystemPromptDraft] = useState(""); const [activePresetBaseline, setActivePresetBaseline] = useState(params); @@ -442,19 +595,18 @@ export function ChatSettingsPanel({ () => customPresets.find((preset) => preset.name === activePreset) ?? null, [activePreset, customPresets], ); + const activeBuiltinPreset = useMemo( + () => + BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null, + [activePreset], + ); const hasUnsavedPresetChanges = useMemo( () => { if (activePresetDefinition == null) { return false; } - if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) { - if (activePresetDefinition.name === "Default") { - return activePresetSource === "modified"; - } - return ( - activePresetSource === "modified" || - !isSamePresetConfig(activePresetDefinition.params, params) - ); + if (activePresetDefinition.name === "Default") { + return activePresetSource === "modified"; } return !isSamePresetConfig(activePresetDefinition.params, params); }, @@ -520,7 +672,10 @@ export function ChatSettingsPanel({ : trimmed; setCustomPresets((prev) => { const next = prev.filter((p) => p.name !== saveName); - const merged = [...next, { name: saveName, params: toPresetParams(params) }]; + const merged = [ + ...next, + { name: saveName, params: toPresetParams(params) }, + ]; saveCustomPresets(merged); return merged; }); @@ -544,7 +699,8 @@ export function ChatSettingsPanel({ return; } const fallbackPreset = - BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null; + BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? + null; setCustomPresets((prev) => { const next = prev.filter((preset) => preset.name !== name); saveCustomPresets(next); @@ -587,28 +743,6 @@ export function ChatSettingsPanel({ useEffect(() => { if (presets.some((preset) => preset.name === activePreset)) { const expectedSource = getPresetSource(activePreset); - if (activePresetDefinition != null) { - if (BUILTIN_PRESET_NAMES.has(activePresetDefinition.name)) { - if (activePresetDefinition.name === "Default") { - if ( - activePresetSource !== "modified" && - activePresetSource !== expectedSource - ) { - setActivePresetSource(expectedSource); - } - return; - } - const matchesActivePreset = isSamePresetConfig( - activePresetDefinition.params, - params, - ); - const nextSource = matchesActivePreset ? expectedSource : "modified"; - if (activePresetSource !== nextSource) { - setActivePresetSource(nextSource); - } - return; - } - } if ( activePresetSource !== "modified" && activePresetSource !== expectedSource @@ -628,9 +762,7 @@ export function ChatSettingsPanel({ } }, [ activePreset, - activePresetDefinition, activePresetSource, - params, presets, setActivePresetSource, ]); @@ -645,307 +777,302 @@ export function ChatSettingsPanel({ } }, [open]); - useLayoutEffect(() => { - const el = presetControlRowRef.current; - if (!el || !open) return; - const measure = () => { - setPresetMenuWidthPx(el.getBoundingClientRect().width); - }; - measure(); - const ro = new ResizeObserver(measure); - ro.observe(el); - return () => ro.disconnect(); - }, [open]); - - const modelSection = ( - -
- {isGguf && ( - <> -
-
- Context Length - { - const raw = e.target.value; - if (raw === "") { - setCustomContextLength(null); - return; - } - const v = Number.parseInt(raw, 10); - if (!Number.isNaN(v) && v >= 0) { - const maxCtx = ctxMaxValue ?? Number.POSITIVE_INFINITY; - const clamped = Math.min(v, maxCtx); - setCustomContextLength( - clamped === (ggufContextLength ?? 0) ? null : clamped, - ); - } - }} - /> -
- { - setCustomContextLength( - v === (ggufContextLength ?? 0) ? null : v, - ); - }} - /> - {ggufMaxContextLength != null && - typeof ctxDisplayValue === "number" && - ctxDisplayValue > ggufMaxContextLength && ( -

- Exceeds estimated VRAM capacity ( - {ggufMaxContextLength.toLocaleString()} tokens). The model - may use system RAM. -

- )} -
-
-
-
KV Cache Dtype
-
- Quantize KV cache to reduce VRAM. -
-
-
- -
-
- {!currentModelIsVision && ( -
-
-
- Speculative Decoding -
-
- Speed up generation with no VRAM cost. -
-
-
- -
-
- )} - {modelSettingsDirty && ( -
- - -
- )} - - )} - {!isGguf && params.checkpoint && ( - <> -
-
-
Enable custom code
-
- Allow models with custom code (e.g. Nemotron). Only enable if - sure. -
-
- -
- {trustRemoteCodeMissing && ( - - - Keep custom code enabled for this model - - - This model requires custom code to load. You can edit the - toggle, but loading will stay blocked until it is turned back - on. - - - )} - - )} -
-
- ); - const settingsContent = ( <>
-
+
{isMobile ? ( - + Configuration ) : ( <> + + Configuration + - + Close configuration - - Configuration - )}
-
- {/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */} -
-
-
- - - setPresetNameInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && presetSaveState.canSubmit) { - e.preventDefault(); - savePresetWithName(presetNameInput); +
+ {hasModelContent && ( + +
+ {isGguf && ( + <> +
+
+ + Context Length + + { + setCustomContextLength( + v === (ggufContextLength ?? 0) ? null : v, + ); + }} + ariaLabel="Context Length" + size={8} + /> +
+ { + const snapped = Math.round(v); + setCustomContextLength( + snapped === (ggufContextLength ?? 0) ? null : snapped, + ); }} - placeholder="Preset name" - maxLength={80} - autoComplete="off" - className={cn( - "!h-8 min-h-0 min-w-0 self-stretch !pl-2.5 !pr-2 pt-1 pb-1 text-sm leading-10 md:text-sm", - presetSaveState.isSaveReady && - "text-foreground placeholder:text-primary/45", - )} - aria-label="Inference preset name" + className="panel-slider" /> - - - ggufMaxContextLength && ( +

+ Exceeds estimated VRAM capacity ( + {ggufMaxContextLength.toLocaleString()} tokens). The + model may use system RAM. +

+ )} +
+
+
+ + KV Cache Dtype + + + Lower KV cache precision to save VRAM at the cost of some + quality. f16/bf16 are full precision; q8_0/q5_1/q4_1 are + quantized. + +
+
+ +
+
+ {!currentModelIsMultimodal && ( +
+
+ + Speculative Decoding + + + N-gram speculation; faster generation with negligible + VRAM overhead. Text-only models. + +
+ { + setSpeculativeType(checked ? "default" : null); + }} + /> +
+ )} + + )} + {!isGguf && params.checkpoint && ( + <> +
+
+ + Enable custom code + + + Run custom Python from the model repo (e.g. Nemotron). + Only enable for trusted sources. + +
+ +
+ {trustRemoteCodeMissing && ( + + + Keep custom code enabled for this model + + + This model requires custom code to load. You can edit the + toggle, but loading will stay blocked until it is turned + back on. + + + )} + + )} + + {(modelSettingsDirty || templateDirty) && ( +
+ + +
+ )} +
+
+ )} + + +
+ + +
+ + setPresetNameInput(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" && presetSaveState.canSubmit) { + e.preventDefault(); + savePresetWithName(presetNameInput); + } + e.stopPropagation(); + }} + placeholder="Preset name" + maxLength={80} + autoComplete="off" + className={cn( + "!h-9 min-h-0 min-w-0 self-stretch !pl-3.5 !pr-2 py-0 text-[13px] font-medium leading-9 text-nav-fg md:text-[13px]", + presetSaveState.isSaveReady && + "placeholder:text-primary/50", + )} + aria-label="Inference preset name" + /> + + - - - {presets.map((p, index) => ( - - applyPreset(p.name)}> - {p.name} - - {index === BUILTIN_PRESETS.length - 1 && - presets.length > BUILTIN_PRESETS.length && ( - - )} - - ))} - - -
-
+ + + +
+
+ + {presets.map((p, index) => ( + + applyPreset(p.name)} + className="flex min-h-9 items-center px-3 py-0 text-[13px] font-medium leading-[1.4] tracking-nav" + > + {p.name} + + {index === BUILTIN_PRESETS.length - 1 && + presets.length > BUILTIN_PRESETS.length && ( + + )} + + ))} + +
+
-
+ -
-
- - -
-