From 4ab096970d81520d42a5452017fec77e1a8154f8 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 7 May 2026 10:17:23 +0100 Subject: [PATCH 1/3] Studio: API settings overflow with long Colab URLs (#5286) * fix: API settings overflow with long Colab URLs * fix: gentle wrapping for API usage snippets --------- Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- .../features/settings/components/usage-examples.tsx | 10 +++++----- .../frontend/src/features/settings/settings-dialog.tsx | 2 +- .../src/features/settings/tabs/api-keys-tab.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/settings/components/usage-examples.tsx b/studio/frontend/src/features/settings/components/usage-examples.tsx index a8f8ce31a6..0e68237857 100644 --- a/studio/frontend/src/features/settings/components/usage-examples.tsx +++ b/studio/frontend/src/features/settings/components/usage-examples.tsx @@ -96,11 +96,11 @@ export function UsageExamples() { }; return ( -
+

Usage examples

-
-
-
+
+
+
{TABS.map((t) => { const active = lang === t.id; return ( @@ -134,7 +134,7 @@ export function UsageExamples() { {copied ? "Copied" : "Copy"}
-
+        
           {snippets[lang]}
         
diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index 63c0a9d388..38002e2f14 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -163,7 +163,7 @@ export function SettingsDialog() { > -
+
{renderTab(activeTab)}
diff --git a/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx b/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx index ac9ec40543..6cbd28f14d 100644 --- a/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx @@ -61,8 +61,8 @@ export function ApiKeysTab() { }; return ( -
-
+
+

API

Access Unsloth programmatically via the OpenAI-compatible API.{" "} @@ -111,7 +111,7 @@ export function ApiKeysTab() { )} -

+

Access tokens

{error ? (
@@ -131,7 +131,7 @@ export function ApiKeysTab() { No API access yet.

) : ( -
+
{keys.map((k) => ( ))} From 7af8cac0148d209a0ca7b607756d6c1eea917af7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 7 May 2026 02:26:58 -0700 Subject: [PATCH 2/3] tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test (#5306) * tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test Adds tests/studio/install/smoke_test_parallel_studio_home.py to lock in the install-time and runtime isolation guarantees added by #5190. The runner spawns N concurrent install.sh --local --no-torch jobs, each with its own UNSLOTH_STUDIO_HOME and a redirected HOME, then launches N backends on dynamically allocated ports and cross-checks every install against its running process. Asserts: install-time - all N installs exit 0 - per-install bin / share / llama.cpp / unsloth_studio venv tree - shim symlink resolves into its own venv, no cross-resolution - share/studio_install_id is unique across the N installs - share/studio.conf exports UNSLOTH_EXE / UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH all pointing inside the install - share/launch-studio.sh has @@DATA_DIR@@ substituted to its own share/ at install time - the redirected HOME stays clean: no rc-file append, no .desktop file, no Studio.app stub, no shared marker runtime - /api/health returns 200 with status healthy and chat_only true - /api/health.studio_root_id matches share/studio_install_id (runtime resolver agrees with install-time write) - studio_root_id values are pairwise distinct - GET / and GET /api/chat return 200 on each backend - /proc/PID/exe is the install's own venv python Standalone smoke runner, not pytest collected. Default --n 4 finishes in about 60 seconds on a warm uv cache; artifacts are removed on PASS unless --keep is passed and kept on FAIL or ERROR for inspection. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests/studio/install: portability + log fd cleanup in parallel smoke Two cleanups on the parallel UNSLOTH_STUDIO_HOME smoke runner: - Skip the /proc/PID/exe runtime cross-resolution check on platforms without /proc (macOS, BSD, Windows). install.sh supports macOS, so the smoke should not hard-error there. The install-time symlink, studio.conf and launch-studio.sh assertions already pin the venv python target statically; the proc check stays as a Linux-only redundant cross-resolution catch and now returns None cleanly on other platforms instead of raising. - Wrap the per-backend log file in a with-statement so its parent fd is released deterministically at function return. The child still holds its own dup'd fd via Popen, so logging continues unchanged. The prior code relied on local-scope GC and was fine in CPython, but the with form makes the intent explicit. Smoke still passes locally: 4 parallel installs in 42s, 4 backends healthy in 5s, all install + runtime invariants hold. * tests/studio/install: pin UNSLOTH_STUDIO_HOME on backend launch The launch step copied os.environ unchanged except for HOME. If the parent shell already exports UNSLOTH_STUDIO_HOME or STUDIO_HOME (for example, when the developer is sourcing studio.conf from an existing install), every backend inherits it and the Studio resolver prioritises those env vars over the per-label sys.prefix inference. The runtime invariant block then reports the caller's install_id on every port instead of the per-label one, and the test fails spuriously rather than testing the right roots. Pin UNSLOTH_STUDIO_HOME to the per-label studio_home and pop the STUDIO_HOME alias for each launch, mirroring what _run_one_install already does for the install step. Verified by running the smoke with UNSLOTH_STUDIO_HOME=/nonexistent and STUDIO_HOME=/also-bogus exported in the parent env: PASS, all four backends report their own install_id rather than the parent value. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../smoke_test_parallel_studio_home.py | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 tests/studio/install/smoke_test_parallel_studio_home.py diff --git a/tests/studio/install/smoke_test_parallel_studio_home.py b/tests/studio/install/smoke_test_parallel_studio_home.py new file mode 100644 index 0000000000..133591fb33 --- /dev/null +++ b/tests/studio/install/smoke_test_parallel_studio_home.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Smoke test: N parallel install.sh runs with distinct UNSLOTH_STUDIO_HOME +values must produce N fully isolated installs whose backends can run +side by side without clashing. + +Covers the env-override path added in #5190: + + install-time + * N concurrent ``install.sh --local --no-torch`` runs against + this checkout, each pinned to its own UNSLOTH_STUDIO_HOME and + a redirected HOME, all exit 0. + * Each STUDIO_HOME contains its own bin/, share/, llama.cpp/ + and unsloth_studio/ venv, with no cross-install absolute + paths. + * share/studio_install_id is unique across the N installs. + * share/studio.conf exports UNSLOTH_EXE, UNSLOTH_STUDIO_HOME + and UNSLOTH_LLAMA_CPP_PATH, all pointing inside this install. + * share/launch-studio.sh has @@DATA_DIR@@ substituted to its + own share/ at install time. + * bin/unsloth is a symlink that resolves into its own venv. + * The redirected HOME is left clean: no shell-rc append, no + .desktop file, no Studio.app stub, no shared marker. + + runtime + * N concurrent ``bin/unsloth studio`` launches each bind their + own dynamically allocated free port and stay healthy. + * /api/health is 200, status is healthy, chat_only is true + under --no-torch. + * The studio_root_id reported by /api/health on each backend + equals that install's share/studio_install_id, so the + runtime resolver agrees with the install-time write. + * studio_root_id values are pairwise distinct. + * GET / and GET /api/chat are 200 on every backend. + * The Python interpreter behind each PID is the install's own + venv python (the bin/unsloth shim does not cross-resolve). + +This is an integration smoke runner, not a pytest unit test. It does +real installs (~1 minute end to end on a warm uv cache) and is meant +to be invoked explicitly: + + python tests/studio/install/smoke_test_parallel_studio_home.py + python tests/studio/install/smoke_test_parallel_studio_home.py --n 6 --keep + +Exits 0 on PASS, 1 on FAIL, 2 on infrastructure error. Artifacts land +under a temporary directory and are removed on PASS unless --keep is +set; on FAIL or ERROR they are kept regardless so logs can be +inspected. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +INSTALL_TIMEOUT_S = 600 +HEALTH_TIMEOUT_S = 120 +HEALTH_POLL_INTERVAL_S = 1.0 + + +class TestFailure(AssertionError): + pass + + +def _log(msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S") + print(f"[smoke {ts}] {msg}", flush = True) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _run_one_install( + label: str, + repo: Path, + studio_home: Path, + fake_home: Path, + uv_cache: Path, + log_path: Path, +) -> tuple[str, int]: + studio_home.mkdir(parents = True, exist_ok = True) + fake_home.mkdir(parents = True, exist_ok = True) + uv_cache.mkdir(parents = True, exist_ok = True) + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env["UV_CACHE_DIR"] = str(uv_cache) + env["NO_COLOR"] = "1" + with log_path.open("w") as fh: + proc = subprocess.run( + ["bash", "install.sh", "--local", "--no-torch"], + cwd = str(repo), + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + timeout = INSTALL_TIMEOUT_S, + ) + return label, proc.returncode + + +def _launch_backend( + studio_home: Path, fake_home: Path, port: int, log_path: Path +) -> subprocess.Popen: + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + # Pin UNSLOTH_STUDIO_HOME (and clear the alias) so the child cannot + # inherit a Studio root from the caller's shell. Without this, a shell + # that already exports either var would override the per-label sys.prefix + # inference and every backend would resolve to the caller's install. + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env.pop("STUDIO_HOME", None) + # The child process inherits a dup of stdout via Popen, so closing the + # parent's handle when this function returns is safe and avoids relying + # on GC timing to release the fd. + with log_path.open("w") as fh: + return subprocess.Popen( + [ + str(studio_home / "bin" / "unsloth"), + "studio", + "-H", + "127.0.0.1", + "-p", + str(port), + "--silent", + ], + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + start_new_session = True, + ) + + +def _wait_for_health(port: int, timeout: float) -> dict: + deadline = time.time() + timeout + last_err: Exception | None = None + url = f"http://127.0.0.1:{port}/api/health" + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout = 2) as r: + if r.status == 200: + return json.loads(r.read().decode()) + except (urllib.error.URLError, ConnectionError, OSError) as e: + last_err = e + time.sleep(HEALTH_POLL_INTERVAL_S) + raise TestFailure( + f"port {port}: /api/health never returned 200 (last_err={last_err})" + ) + + +def _http_status(port: int, path: str, timeout: float = 5.0) -> int: + url = f"http://127.0.0.1:{port}{path}" + try: + with urllib.request.urlopen(url, timeout = timeout) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + + +def _check_install_layout(label: str, studio_home: Path) -> dict: + for d in ("bin", "share", "llama.cpp", "unsloth_studio"): + if not (studio_home / d).is_dir(): + raise TestFailure(f"[{label}] missing {studio_home / d}") + + shim = studio_home / "bin" / "unsloth" + if not shim.is_symlink(): + raise TestFailure(f"[{label}] {shim} is not a symlink") + expected_target = (studio_home / "unsloth_studio" / "bin" / "unsloth").resolve() + if shim.resolve() != expected_target: + raise TestFailure( + f"[{label}] shim resolves to {shim.resolve()}, expected {expected_target}" + ) + + install_id_path = studio_home / "share" / "studio_install_id" + if not install_id_path.is_file(): + raise TestFailure(f"[{label}] missing {install_id_path}") + install_id = install_id_path.read_text().strip() + if len(install_id) < 32: + raise TestFailure(f"[{label}] studio_install_id too short: {install_id!r}") + + conf = (studio_home / "share" / "studio.conf").read_text() + must_contain = [ + f"UNSLOTH_EXE='{studio_home}/unsloth_studio/bin/unsloth'", + f"export UNSLOTH_STUDIO_HOME='{studio_home}'", + f"export UNSLOTH_LLAMA_CPP_PATH='{studio_home}/llama.cpp'", + ] + for needle in must_contain: + if needle not in conf: + raise TestFailure( + f"[{label}] studio.conf missing line:\n {needle}\n" f"actual:\n{conf}" + ) + + launcher = (studio_home / "share" / "launch-studio.sh").read_text() + if "@@DATA_DIR@@" in launcher: + raise TestFailure(f"[{label}] launch-studio.sh kept @@DATA_DIR@@ placeholder") + expected_data_dir_line = f"DATA_DIR='{studio_home}/share'" + if expected_data_dir_line not in launcher: + raise TestFailure( + f"[{label}] launch-studio.sh missing {expected_data_dir_line!r}" + ) + + return {"label": label, "studio_home": str(studio_home), "install_id": install_id} + + +def _check_fake_home_clean(fake_home: Path) -> None: + forbidden = [ + ".bashrc", + ".zshrc", + ".profile", + ".unsloth", + Path(".local") / "share" / "applications" / "unsloth-studio.desktop", + Path("Desktop") / "unsloth-studio.desktop", + Path("Applications") / "Unsloth Studio.app", + ] + leaked = [str(p) for p in forbidden if (fake_home / p).exists()] + if leaked: + raise TestFailure( + f"redirected HOME picked up persistent install pollution: {leaked}" + ) + + +def _backend_pid_python(pid: int) -> Path | None: + """Resolve the binary backing a running PID. Linux exposes this at + /proc/PID/exe; on platforms without /proc (macOS, BSD, Windows) we + skip this check and rely on the install-time symlink + studio.conf + invariants to catch cross-resolution. Returns None when /proc is + unavailable so the caller can skip cleanly.""" + if sys.platform != "linux": + return None + proc_exe = Path(f"/proc/{pid}/exe") + if not proc_exe.exists(): + return None + return proc_exe.resolve() + + +def run(n_installs: int, keep: bool) -> int: + if n_installs < 2: + raise TestFailure("--n must be >= 2 to test for clashes") + labels = [chr(ord("a") + i) for i in range(n_installs)] + + repo = PACKAGE_ROOT + if not (repo / "install.sh").is_file(): + raise TestFailure( + f"install.sh not found at {repo}; " "run from a clone of unslothai/unsloth" + ) + + test_root = Path(tempfile.mkdtemp(prefix = "unsloth_studio_clash_")) + _log(f"test root: {test_root}") + _log(f"repo: {repo}") + + backends: list[tuple[str, Path, Path, int, subprocess.Popen]] = [] + failed = False + try: + # ---- parallel installs -------------------------------------------- + _log(f"launching {n_installs} parallel installs (--local --no-torch)") + with ThreadPoolExecutor(max_workers = n_installs) as pool: + futures = [] + for label in labels: + futures.append( + pool.submit( + _run_one_install, + label, + repo, + test_root / "installs" / label, + test_root / "fake_homes" / label, + test_root / "uv_caches" / label, + test_root / "logs" / f"install_{label}.log", + ) + ) + for fut in as_completed(futures): + label, rc = fut.result() + _log(f" install {label}: exit {rc}") + if rc != 0: + raise TestFailure( + f"install {label} failed (rc={rc}); see " + f"{test_root / 'logs' / f'install_{label}.log'}" + ) + + # ---- install-layout invariants ------------------------------------ + _log("verifying install-time invariants") + observed = [] + for label in labels: + studio_home = test_root / "installs" / label + obs = _check_install_layout(label, studio_home) + observed.append(obs) + _check_fake_home_clean(test_root / "fake_homes" / label) + ids = [o["install_id"] for o in observed] + if len(set(ids)) != len(ids): + raise TestFailure(f"studio_install_id collision: {ids}") + _log(f" {len(ids)} unique studio_install_ids, all redirected HOMEs clean") + + # ---- parallel backend launches ------------------------------------ + _log(f"launching {n_installs} backends in parallel") + for label in labels: + port = _free_port() + studio_home = test_root / "installs" / label + fake_home = test_root / "fake_homes" / label + log_path = test_root / "logs" / f"run_{label}.log" + proc = _launch_backend(studio_home, fake_home, port, log_path) + backends.append((label, studio_home, fake_home, port, proc)) + _log(f" {label} -> port {port} (pid {proc.pid})") + + # ---- wait for health ---------------------------------------------- + _log("waiting for /api/health on each backend") + health_payloads: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers = n_installs) as pool: + fut_to_label = { + pool.submit(_wait_for_health, port, HEALTH_TIMEOUT_S): label + for (label, _sh, _fh, port, _p) in backends + } + for fut in as_completed(fut_to_label): + label = fut_to_label[fut] + health_payloads[label] = fut.result() + _log(f" {label}: healthy") + + # ---- runtime invariants ------------------------------------------- + _log("checking runtime invariants") + seen_root_ids: set[str] = set() + for (label, studio_home, _fh, port, proc), obs in zip(backends, observed): + health = health_payloads[label] + if health.get("status") != "healthy": + raise TestFailure(f"[{label}] health status != healthy: {health}") + if health.get("studio_root_id") != obs["install_id"]: + raise TestFailure( + f"[{label}] runtime studio_root_id " + f"{health.get('studio_root_id')!r} != install_id " + f"{obs['install_id']!r}" + ) + if not health.get("chat_only"): + raise TestFailure(f"[{label}] chat_only is not true under --no-torch") + if health["studio_root_id"] in seen_root_ids: + raise TestFailure( + f"[{label}] studio_root_id collision at runtime: " + f"{health['studio_root_id']}" + ) + seen_root_ids.add(health["studio_root_id"]) + + for path in ("/", "/api/chat"): + code = _http_status(port, path) + if code != 200: + raise TestFailure(f"[{label}] GET {path} -> {code}") + + exe = _backend_pid_python(proc.pid) + if exe is not None: + expected_python = ( + studio_home / "unsloth_studio" / "bin" / "python" + ).resolve() + if exe != expected_python: + raise TestFailure( + f"[{label}] PID {proc.pid} exe={exe}, expected {expected_python}" + ) + + versions = {h.get("version") for h in health_payloads.values()} + if len(versions) != 1: + raise TestFailure(f"version mismatch across installs: {versions}") + + _log( + f"PASS: all install + runtime invariants hold " + f"(version={next(iter(versions))})" + ) + return 0 + + except TestFailure as e: + _log(f"FAIL: {e}") + failed = True + return 1 + except Exception as e: + _log(f"ERROR: {type(e).__name__}: {e}") + failed = True + return 2 + finally: + for _lbl, _sh, _fh, _port, proc in backends: + if proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout = 10) + except Exception: + proc.kill() + + if keep or failed: + _log(f"artifacts kept at {test_root}") + else: + shutil.rmtree(test_root, ignore_errors = True) + _log(f"cleaned up {test_root}") + + +def main() -> int: + ap = argparse.ArgumentParser(description = __doc__) + ap.add_argument( + "--n", + type = int, + default = 4, + help = "number of parallel installs (default 4, must be >= 2)", + ) + ap.add_argument( + "--keep", + action = "store_true", + help = "leave the temp test root on disk even on PASS", + ) + args = ap.parse_args() + return run(args.n, args.keep) + + +if __name__ == "__main__": + raise SystemExit(main()) From b65a7450ca6091c230e0d8c721182b2ff7d20718 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 7 May 2026 11:33:31 +0100 Subject: [PATCH 3/3] Studio: Dark theme refactor, right sidebar redesign, and chat UI polish (#5150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dark theme refactor, right sidebar redesign, and chat UI polish - Dark theme refactor - Redesign right sidebar - Further left sidebar adjustments - Wider chat and content area; layout tweaks for chat content - Rounded corners across elements for consistency - Show chat message menu icons on menu-area hover, not only on message hover - Assistant message menu icons now always visible; user messages keep on-hover - Redesigned copy icon used consistently across chat blocks and messages - Redesigned trash icon, applied consistently - Unified icon sizing and style with the sidebar - Adjusted icon colors across chat - Fix on-hover background design for chat icons - Fix tooltip from 'more' button staying visible after clicking elsewhere - Adjust position and design of generation speed info text below messages - Adjust design of token speed info popup - Adjust sidebar scrollbar to cover recent chats only * Recents sidebar rename, UI/theme refactor, layout and chat polish UI & Theme: - Dark theme refactor - Consistent rounded corners across elements - CSS polish and cleanup - Remove unused logo image assets Recents sidebar: - Add 'more' button for options menu - Support renaming conversations and training runs - Confirmation dialog before deleting chats - Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config - New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override - Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow - Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed - Optimistic updates on rename and delete (apply locally + cache before background refresh) - Visible toast on rename/delete failure instead of swallowed errors Layout: - Redesigned right sidebar - Further left sidebar adjustments - Updated chat content layout; chat and content area slightly widened - Sidebar scrollbar covers recent chats only Icons: - Redesigned copy icon, unified across chat blocks and messages - Redesigned trash icon to match - Consistent icon sizing and style across chat and sidebar - Adjusted icon colors across chat - Fix icon on-hover background design Chat messages: - Menu icons now appear on hover over the menu area, not just the message - Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts) - Repositioned and restyled generation speed info text below messages - Restyled token generation speed popup Tooltips: - Removed tooltip on hover for previous/next assistant response icons - Unified tooltip design across sidebars and chat - Removed tooltip animations (also fixes related lag) Model & Chat Template config: - Merged Chat Template config into Model Configuration section - Added revert-to-original for chat template - Fix Chat Template config disappearing on page refresh until model reload Performance & scroll: - Removed chatbox movement animations across pages/navigation (fixes related UI lag) - Fix scroll flicker at end of streaming when a code block is the final element - Additional chat scroll improvements Bug fixes: - Fix 'more' button tooltip remaining visible after clicking elsewhere * Remove sidebar localStorage cache and optimistic updates Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount. * Fix missing cn import in shared-composer (regression from merge) * chore(sidebar): import sidebar deps from feature indexes Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change. * fix(studio/frontend): reload training Recents sidebar after F5 refresh The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation. Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update. Changes (all in use-training-history-sidebar.ts): - fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses. - Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data. - Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure. - Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery). - Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs. - Both effects own their teardown explicitly (abort + clear timer). * Apply unified tooltip design and behavior across remaining pages for consistency * UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon - Adjust tiny spacing between elements around the UI for subtle polish - Redesign tooltip on source icons for web search / tool use, consistent with the new design - Adjust chat text letter spacing - Smaller icon sizes - Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Adjust CSS for right sidebar * Fix scrollbar UI compatibility across browsers * fix: preserve chat preset settings on model load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): remove duplicate chat template status field * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore: remove creative preset assumption * fix(studio): align speculative decoding default * fix(studio/chat): snap numeric param inputs to step grid - Type a value in any param input (Temperature, Top K, Max Tokens, etc.) now clamps to [min, max] and snaps to the slider's step grid, killing off-grid values like 1.051234 and FP residue from slider drags. - Branch picker chevrons share the action bar's 32px height + 10px radius via a new .aui-branch-chevron-btn utility; hover area aligns visually while staying narrower than the sibling icon buttons. * fix(studio/chat): keep training-run polls converging and drop dead preset code - Keep training-run polls converging when responses outrun the 5s interval (don't unconditionally abort prior in-flight; skip if one is still pending, mutation race still guarded). - Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source variant + unreachable branches). * fix(studio): training-run cards show custom name + model + dataset - Training-run cards now display custom display_name + model + dataset, with cross-view sync on rename/delete. - Enhance clarity of borders and colors in dark theme on export etc. * fix(studio): match active state green to unsloth brand color * fix(studio): preserve can_resume on training rename * fix(studio): keep GGUF chat template override distinct * fix(studio): treat audio input models as multimodal * fix(studio): cancel numeric draft on Escape * fix(studio): use default speculative mode on toggle * fix(studio): detect GGUF audio VLM input models * fix(studio): address final PR review findings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload * fix: API and svg * fix(studio/sidebar): align run rename dirty check with displayed baseline * fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate --------- Co-authored-by: sneakr Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: shine1i --- studio/backend/core/inference/llama_cpp.py | 12 +- studio/backend/models/__init__.py | 2 + studio/backend/models/inference.py | 34 +- studio/backend/models/training.py | 11 +- studio/backend/routes/inference.py | 32 +- studio/backend/routes/training_history.py | 37 +- studio/backend/storage/studio_db.py | 22 +- .../tests/test_inference_model_validation.py | 36 + .../tests/test_training_history_update.py | 100 ++ studio/frontend/public/blacklogo-c.png | Bin 141545 -> 0 bytes studio/frontend/public/blacklogo.png | Bin 166049 -> 0 bytes studio/frontend/public/sidebar-logo-black.png | Bin 9170 -> 0 bytes studio/frontend/public/sidebar-logo-white.png | Bin 9057 -> 0 bytes studio/frontend/public/unsloth-beta-black.png | Bin 160502 -> 0 bytes studio/frontend/public/unsloth-beta-white.png | Bin 156421 -> 0 bytes studio/frontend/public/whitelogo-c.png | Bin 139810 -> 0 bytes studio/frontend/public/whitelogo.png | Bin 162842 -> 0 bytes .../frontend/src/components/app-sidebar.tsx | 491 ++++-- .../components/assistant-ui/attachment.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 20 +- .../assistant-ui/message-timing.tsx | 5 +- .../assistant-ui/model-selector.tsx | 8 +- .../assistant-ui/model-selector/pickers.tsx | 10 +- .../src/components/assistant-ui/sources.tsx | 21 +- .../src/components/assistant-ui/thread.tsx | 142 +- .../assistant-ui/tooltip-icon-button.tsx | 4 +- .../use-intent-aware-autoscroll.tsx | 153 +- studio/frontend/src/components/ui/button.tsx | 132 +- studio/frontend/src/components/ui/select.tsx | 495 +++--- studio/frontend/src/components/ui/sidebar.tsx | 1538 +++++++++-------- studio/frontend/src/components/ui/tooltip.tsx | 22 +- .../src/features/chat/api/chat-adapter.ts | 7 + .../frontend/src/features/chat/chat-page.tsx | 24 +- .../src/features/chat/chat-settings-sheet.tsx | 1083 +++++++----- .../chat/components/context-usage-bar.tsx | 7 +- .../chat/hooks/use-chat-model-runtime.ts | 71 +- .../chat/hooks/use-chat-sidebar-items.ts | 23 + studio/frontend/src/features/chat/index.ts | 8 + .../features/chat/presets/preset-policy.ts | 93 +- .../src/features/chat/shared-composer.tsx | 34 +- .../chat/stores/chat-runtime-store.ts | 6 + .../frontend/src/features/chat/types/api.ts | 21 + .../src/features/export/export-page.tsx | 6 +- .../src/features/settings/settings-dialog.tsx | 12 +- .../studio/historical-training-view.tsx | 12 +- .../src/features/studio/history-card-grid.tsx | 51 +- .../src/features/training/api/history-api.ts | 18 + .../frontend/src/features/training/events.ts | 51 + .../training/hooks/use-training-actions.ts | 3 + .../hooks/use-training-history-sidebar.ts | 174 +- .../frontend/src/features/training/index.ts | 16 +- .../src/features/training/types/history.ts | 1 + studio/frontend/src/index.css | 619 ++++++- .../test_chat_preset_builtin_invariants.py | 56 +- 54 files changed, 3752 insertions(+), 1973 deletions(-) create mode 100644 studio/backend/tests/test_inference_model_validation.py create mode 100644 studio/backend/tests/test_training_history_update.py delete mode 100644 studio/frontend/public/blacklogo-c.png delete mode 100644 studio/frontend/public/blacklogo.png delete mode 100644 studio/frontend/public/sidebar-logo-black.png delete mode 100644 studio/frontend/public/sidebar-logo-white.png delete mode 100644 studio/frontend/public/unsloth-beta-black.png delete mode 100644 studio/frontend/public/unsloth-beta-white.png delete mode 100644 studio/frontend/public/whitelogo-c.png delete mode 100644 studio/frontend/public/whitelogo.png create mode 100644 studio/frontend/src/features/training/events.ts 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 7ab9959536d21dc48ed49f7782d579033014b9f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141545 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
{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 && ( + + )} + + ))} + +
+
-
+ -
-
- - -
-