Merge remote-tracking branch 'origin/main' into ci/workflow-permissions-and-fewer-skips

# Conflicts:
#	tests/studio/test_chat_preset_builtin_invariants.py
This commit is contained in:
Daniel Han 2026-05-07 10:58:10 +00:00
commit a810e8fbbb
57 changed files with 4182 additions and 1978 deletions

View file

@ -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("<audio_soft_token>")) == 1:
return "audio_vlm"
if (
len(_tok("<|bicodec_semantic_0|>")) == 1
and len(_tok("<|bicodec_global_0|>")) == 1

View file

@ -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",

View file

@ -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",

View file

@ -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."""

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

View file

@ -28,6 +28,16 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import {
@ -35,8 +45,8 @@ import {
ColumnInsertIcon,
CursorInfo02Icon,
Delete02Icon,
Download03Icon,
GemIcon,
DownloadSquare01Icon,
Edit03Icon,
Globe02Icon,
HelpCircleIcon,
Search01Icon,
@ -44,6 +54,7 @@ import {
PencilEdit02Icon,
LayoutAlignLeftIcon,
Settings02Icon,
TestTube01Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import {
@ -52,25 +63,34 @@ import {
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react";
import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { useTrainingRuntimeStore } from "@/features/training";
import {
ChatSearchDialog,
deleteChatItem,
renameChatItem,
useChatRuntimeStore,
useChatSearchStore,
useChatSidebarItems,
type SidebarItem,
} from "@/features/chat";
import { useSettingsDialogStore } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { usePlatformStore } from "@/config/env";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import {
useChatSidebarItems,
deleteChatItem,
} from "@/features/chat/hooks/use-chat-sidebar-items";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
deleteTrainingRun,
emitTrainingRunDeleted,
emitTrainingRunUpdated,
removeTrainingUnloadGuard,
renameTrainingRun,
useTrainingHistorySidebarItems,
useTrainingRuntimeStore,
} from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
function getTourId(pathname: string): string | null {
if (pathname.startsWith("/studio")) return "studio";
@ -79,6 +99,16 @@ function getTourId(pathname: string): string | null {
return null;
}
// Hugeicons' TestTube01Icon ships with two interior bubbles (paths #4
// and #5 of the 5-path definition). Slicing to the first three paths
// keeps the test-tube outline + horizontal cap + liquid line, dropping
// the bubbles. The original export stays untouched, and HugeiconsIcon
// renders this trimmed array exactly the same way.
const TestTubeOutlineIcon = TestTube01Icon.slice(
0,
3,
) as typeof TestTube01Icon;
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
switch (status) {
case "running":
@ -141,10 +171,10 @@ function NavItem({
onClick={onClick}
isActive={active}
data-tour={dataTour}
className="h-[32px] rounded-[10px] gap-[8.5px] px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[11px] group-data-[collapsible=icon]:mx-auto"
className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
>
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-[18px]! shrink-0 group-hover/menu-button:animate-icon-pop" />
<span className="text-[14px] leading-[18px] tracking-[0.01em]">{label}</span>
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
</SidebarMenuButton>
</div>
{children}
@ -181,6 +211,17 @@ export function AppSidebar() {
useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]);
useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]);
const scrollRef = useRef<HTMLDivElement | null>(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<RenameTarget | null>(
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<DeleteTarget | null>(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 (
<>
<Sidebar
@ -220,7 +348,7 @@ export function AppSidebar() {
variant="sidebar"
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
>
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[12px] group-data-[collapsible=icon]:px-0">
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[8px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
<Link
@ -246,10 +374,7 @@ export function AppSidebar() {
<span className="font-heading text-[21px] font-semibold tracking-[-0.01em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
unsloth
</span>
<span
style={{ fontFamily: '"Inter Variable", ui-sans-serif, system-ui, sans-serif' }}
className="ml-0.5 inline-flex items-center justify-center rounded-full border border-[#e0ded6] px-[5px] py-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-[#62605a] antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:border-[#3a3c3f] dark:text-[#9d9fa5] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
BETA
</span>
</Link>
@ -259,13 +384,17 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
Close sidebar
</TooltipContent>
</Tooltip>
@ -274,19 +403,23 @@ export function AppSidebar() {
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex h-[34px] items-center justify-center w-full">
<div className="hidden group-data-[collapsible=icon]:flex h-[35px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#f0f0f0] dark:hover:bg-[#2a2c2f] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="right" sideOffset={8}>
<TooltipContent
side="right"
sideOffset={8}
className="tooltip-compact"
>
Open sidebar
</TooltipContent>
</Tooltip>
@ -294,7 +427,7 @@ export function AppSidebar() {
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px] shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -337,66 +470,65 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Navigate (no header) */}
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[10px] pb-[14px]">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={GemIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label="Recipes"
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label="Recipes"
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={Download03Icon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavItem
icon={DownloadSquare01Icon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
{!isStudioRoute && chatItems.length > 0 && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
isActive={activeThreadId === item.id}
className="h-[32px] rounded-[10px] pl-2.5 pr-7 text-[14px] leading-[18px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
onClick={() => {
navigate({
to: "/chat",
@ -410,17 +542,38 @@ export function AppSidebar() {
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteThread(item);
}}
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/recent-item:scale-100 group-hover/recent-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Chat options"
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
@ -433,15 +586,15 @@ export function AppSidebar() {
{/* Recent Runs */}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{runItems.map((run) => {
const isActiveRun =
@ -453,7 +606,7 @@ export function AppSidebar() {
>
<SidebarMenuButton
isActive={isActiveRun}
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[10px] pl-2.5 pr-7 text-[14px] tracking-[0.01em] font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-active:bg-[#f0f0f0]! dark:data-active:bg-[#2a2c2f]! data-active:text-black! dark:data-active:text-white!"
className="sidebar-nav-btn h-auto flex-col items-start gap-0.5 py-[5px] rounded-[10px] pl-2.5 pr-7 text-[14.5px] tracking-nav font-medium"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
@ -468,7 +621,7 @@ export function AppSidebar() {
aria-hidden
/>
<span className="truncate">
{run.model_name}
{run.display_name ?? run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
@ -478,25 +631,41 @@ export function AppSidebar() {
{run.dataset_name}
</span>
</SidebarMenuButton>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
try {
await deleteTrainingRun(run.id);
if (selectedHistoryRunId === run.id) {
setSelectedHistoryRunId(null);
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Run options"
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={run.status === "running"}
onSelect={() =>
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"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
})}
@ -516,7 +685,7 @@ export function AppSidebar() {
<SidebarMenuButton
size="lg"
aria-label={`${displayTitle} account menu`}
className="!h-[50px] gap-[8px] rounded-[10px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#f0f0f0]! dark:hover:bg-[#2a2c2f]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#f0f0f0]! dark:data-[state=open]:bg-[#2a2c2f]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
>
<div className="shrink-0">
<UserAvatar
@ -527,8 +696,8 @@ export function AppSidebar() {
/>
</div>
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13px] tracking-[0.02em] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
<span className="truncate text-[11px] tracking-[0.01em] text-muted-foreground">Unsloth</span>
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
@ -536,13 +705,13 @@ export function AppSidebar() {
<DropdownMenuContent
side="top"
align="start"
className="w-[15rem] py-2.5 font-heading [&_[data-slot=dropdown-menu-group]]:flex [&_[data-slot=dropdown-menu-group]]:flex-col [&_[data-slot=dropdown-menu-group]]:gap-px [&_[data-slot=dropdown-menu-item]]:h-[32px] [&_[data-slot=dropdown-menu-item]]:px-2.5! [&_[data-slot=dropdown-menu-item]]:py-0! [&_[data-slot=dropdown-menu-item]]:gap-[8.5px]! [&_[data-slot=dropdown-menu-item]]:rounded-[10px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[14px] [&_[data-slot=dropdown-menu-item]]:leading-[18px] [&_[data-slot=dropdown-menu-item]]:tracking-[0.01em] [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]_svg]:!size-[18px] [&_[data-slot=dropdown-menu-item]_svg]:shrink-0 [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f0f0f0] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2a2c2f] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
className="app-user-menu menu-soft-surface-up ring-0 w-[15rem] py-2.5 font-heading rounded-[14px] border-0"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()}
>
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
<span>Settings</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
@ -559,7 +728,7 @@ export function AppSidebar() {
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <Sun strokeWidth={1.75} className="size-[18px]" /> : <Moon strokeWidth={1.75} className="size-[18px]" />}
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
</DropdownMenuItem>
<DropdownMenuItem
@ -574,7 +743,7 @@ export function AppSidebar() {
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>Guided Tour</span>
</DropdownMenuItem>
</DropdownMenuGroup>
@ -582,11 +751,11 @@ export function AppSidebar() {
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
>
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
<span>Help</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-[18px]" />
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
<span>Shutdown</span>
</DropdownMenuItem>
</DropdownMenuContent>
@ -601,6 +770,96 @@ export function AppSidebar() {
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
<Dialog
open={confirmingDelete !== null}
onOpenChange={(open) => {
if (!open) setConfirmingDelete(null);
}}
>
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>
{confirmingDelete?.kind === "run"
? "Delete training run"
: "Delete chat"}
</DialogTitle>
<DialogDescription>
{confirmingDelete?.kind === "run" ? (
<>
Are you sure you want to delete this run{" "}
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
</>
) : confirmingDelete?.kind === "chat" ? (
<>
Are you sure you want to delete this chat{" "}
<em>{confirmingDelete.item.title}</em>?
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setConfirmingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void commitDelete()}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={renamingTarget !== null}
onOpenChange={(open) => {
if (!open) setRenamingTarget(null);
}}
>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
</DialogTitle>
</DialogHeader>
<Input
value={renameDraft}
onChange={(event) => 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"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setRenamingTarget(null)}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitRename()}
disabled={!renameDirty}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -184,7 +184,7 @@ const AttachmentUI: FC = () => {
</AttachmentPreviewDialog>
{isComposer && <AttachmentRemove />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
<TooltipContent side="top" className="tooltip-compact">
<AttachmentPrimitive.Name />
</TooltipContent>
</Tooltip>

View file

@ -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 }) {
}}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-5"
icon={copied ? Tick02Icon : Copy01Icon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
);
@ -308,7 +309,7 @@ function CodeBlockActions({
const { copied, showCopied } = useCopiedState();
return (
<div className="pointer-events-none absolute top-3.5 right-3 z-20 flex items-center justify-end">
<div className="pointer-events-none absolute top-3 right-3 z-20 flex items-center justify-end">
<div className={ACTION_PANEL_CLASS}>
<button
type="button"
@ -323,8 +324,9 @@ function CodeBlockActions({
}}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-3.5"
icon={copied ? Tick02Icon : Copy01Icon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
<button
@ -336,7 +338,7 @@ function CodeBlockActions({
downloadTextFile(getCodeFilename(language), source);
}}
>
<DownloadIcon className="size-3.5" />
<DownloadIcon className="size-icon" />
</button>
</div>
</div>

View file

@ -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!"
>
<div className="grid min-w-40 gap-1.5 text-xs">
{st ? (

View file

@ -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,
)}
>

View file

@ -175,7 +175,10 @@ function ModelRow({
return (
<Tooltip>
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="tooltip-compact max-w-xs break-all"
>
{label}
<span className="block text-[10px] mt-1">{vramTooltipText}</span>
</TooltipContent>
@ -187,7 +190,10 @@ function ModelRow({
return (
<Tooltip>
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="tooltip-compact max-w-xs break-all"
>
{tooltipText}
</TooltipContent>
</Tooltip>

View file

@ -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 (
<HoverCard openDelay={300} closeDelay={100}>
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<span className="inline-block">
<Source href={source.url}>
@ -146,16 +146,21 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
</Source>
</span>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-72 p-3">
<HoverCardContent
side="top"
align="start"
className="!bg-black !text-white !w-72 !p-3 !rounded-2xl !shadow-md !ring-0 !duration-0"
style={{ animation: "none" }}
>
<div className="flex gap-2.5">
<SourceIcon url={source.url} size={4} className="mt-0.5 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold leading-tight truncate">
{source.title || domain}
</p>
<p className="text-xs text-muted-foreground truncate">{domain}</p>
<p className="text-xs text-white/60 truncate">{domain}</p>
{source.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
<p className="text-xs text-white/70 leading-relaxed line-clamp-3">
{source.description}
</p>
)}
@ -245,7 +250,7 @@ const SourcesGroup: FC = () => {
const hiddenCount = sources.length - (visibleCount ?? sources.length);
return (
<div className="relative mt-2">
<div className="relative mt-2 mb-3">
{/* Hidden measurement container — renders all badges to measure row positions */}
<div
ref={containerRef}
@ -273,7 +278,7 @@ const SourcesGroup: FC = () => {
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

View file

@ -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<{
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
style={{
["--thread-max-width" as string]: "44rem",
["--thread-max-width" as string]: "48rem",
["--thread-content-max-width" as string]:
"calc(var(--thread-max-width) - 2.5rem)",
"calc(var(--thread-max-width) - 1.5rem)",
}}
>
<IntentAwareScrollProvider value={autoScrollContext}>
@ -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 && (
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-[10px] z-20">
<div
aria-hidden={true}
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
@ -173,8 +170,8 @@ export const Thread: FC<{
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
<ComposerAnimated disabled={isComposerAttachPending} />
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
<p className="composer-footer-note">
LLMs can make mistakes. Double-check responses.
</p>
</div>
</div>
@ -204,7 +201,7 @@ const ThreadScrollToBottom: FC = () => {
isAtBottom && "invisible pointer-events-none",
)}
>
<ArrowDownIcon />
<ArrowDownIcon strokeWidth={1.75} className="size-icon" />
</TooltipIconButton>
);
};
@ -253,14 +250,9 @@ const GeneratingSpinner: FC = () => {
const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
<motion.div
layout={true}
layoutId="composer"
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
className="relative z-10 w-full"
>
<div className="relative z-10 w-full">
<Composer disabled={disabled} />
</motion.div>
</div>
</div>
);
};
@ -306,7 +298,7 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
className="aui-composer-input composer-input"
minRows={1}
maxRows={6}
autoFocus={!disabled}
@ -326,11 +318,11 @@ const Composer: FC<{ disabled?: boolean }> = ({ 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.
<div className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow">
<div className="aui-composer-attachment-dropzone chat-composer-surface">
{composerContent}
</div>
) : (
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
{composerContent}
</ComposerPrimitive.AttachmentDropzone>
)}
@ -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"}
>
<GlobeIcon className="size-3.5" />
@ -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 (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="aui-composer-action-wrapper composer-action-wrapper">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ComposerAudioUpload />
@ -725,10 +699,10 @@ const GeneratingIndicator: FC = () => {
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] font-[450] duration-150"
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
<GeneratingIndicator />
<MessagePrimitive.Parts
components={{
@ -751,8 +725,8 @@ const AssistantMessage: FC = () => {
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 flex">
<BranchPicker />
<div className="aui-assistant-message-footer mt-1.5 -ml-[var(--icon-btn-inset)] flex min-h-8">
<BranchPicker className="mr-0.5" />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
@ -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"
>
<Trash2Icon className="size-4" />
<HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-icon"
/>
</TooltipIconButton>
);
};
@ -817,7 +795,11 @@ const CopyButton: FC = () => {
return (
<TooltipIconButton tooltip="Copy" onClick={handleCopy}>
{copied ? <CheckIcon /> : <CopyIcon />}
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
strokeWidth={1.75}
className="size-icon"
/>
</TooltipIconButton>
);
};
@ -826,40 +808,39 @@ const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning={true}
autohide="always"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
>
<CopyButton />
<ActionBarPrimitive.Reload asChild={true}>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DeleteMessageButton />
<MessageTiming side="top" />
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild={true}>
<TooltipIconButton
tooltip="More"
className="data-[state=open]:bg-accent"
>
<MoreHorizontalIcon />
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</TooltipIconButton>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
side="bottom"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<ActionBarPrimitive.ExportMarkdown asChild={true}>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<DownloadIcon className="size-4" />
<DownloadIcon strokeWidth={1.75} className="size-icon" />
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
<MessageTiming side="top" className="h-8 px-2" />
</ActionBarPrimitive.Root>
);
};
@ -884,22 +865,21 @@ const UserMessageAudio: FC = () => {
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] font-[450] duration-150"
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em] duration-150"
data-role="user"
>
<UserMessageAttachments />
<UserMessageAudio />
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-card">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[24px] bg-[#f5f5f5] px-4 py-2.5 text-[#0d0d0d] dark:text-foreground dark:bg-card">
<MessagePrimitive.Parts />
</div>
<div className="mt-1 flex min-h-6">
<div className="mt-1 -mr-[var(--icon-btn-inset)] flex min-h-8 items-center">
<UserActionBar />
<BranchPicker className="aui-user-branch-picker ml-0.5" />
</div>
</div>
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
@ -908,12 +888,12 @@ const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
autohide="always"
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
className="aui-user-action-bar-root flex gap-1 text-chat-icon-fg [&_button]:size-8 [&_button]:!rounded-[10px] [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
>
<CopyButton />
<ActionBarPrimitive.Edit asChild={true}>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
<PencilIcon />
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
<DeleteMessageButton />
@ -981,23 +961,31 @@ const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
<BranchPickerPrimitive.Root
hideWhenSingleBranch={true}
className={cn(
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
"aui-branch-picker-root inline-flex items-center text-chat-icon-fg text-[13px]",
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild={true}>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
<button
type="button"
aria-label="Previous"
className="aui-branch-chevron-btn"
>
<ChevronLeftIcon strokeWidth={1.25} className="size-[36px]" />
</button>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
<span className="aui-branch-picker-state font-mono text-[13px] tabular-nums">
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild={true}>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
<button
type="button"
aria-label="Next"
className="aui-branch-chevron-btn"
>
<ChevronRightIcon strokeWidth={1.25} className="size-[36px]" />
</button>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);

View file

@ -37,7 +37,9 @@ export const TooltipIconButton = forwardRef<
<span className="aui-sr-only sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
<TooltipContent side={side} className="tooltip-compact">
{tooltip}
</TooltipContent>
</Tooltip>
);
});

View file

@ -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
// <pre> 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,

View file

@ -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<typeof buttonVariants> & {
asChild?: boolean;
}): React.ReactElement {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
/* 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<typeof buttonVariants> & {
asChild?: boolean;
}): React.ReactElement {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}

View file

@ -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<typeof SelectPrimitive.Root>) {
const [isOpen, setIsOpen] = useState(false);
return (
<SelectOpenContext.Provider value={isOpen}>
<SelectPrimitive.Root
data-slot="select"
onOpenChange={(open) => {
setIsOpen(open);
onOpenChange?.(open);
}}
{...props}
/>
</SelectOpenContext.Provider>
);
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
const isOpen = useContext(SelectOpenContext);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
style={{
borderRadius: isOpen ? "12px" : undefined,
transition: isOpen
? "border-radius 0ms"
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
}}
className={cn(
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<HugeiconsIcon
icon={UnfoldMoreIcon}
strokeWidth={2}
className="text-muted-foreground size-4 pointer-events-none"
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
container,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
container?: HTMLElement | null;
}) {
const dialogContainer = useDialogPortalContainer();
return (
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
position === "popper" && "",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollDownButton>
);
}
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<typeof SelectPrimitive.Root>) {
const [isOpen, setIsOpen] = useState(false);
return (
<SelectOpenContext.Provider value={isOpen}>
<SelectPrimitive.Root
data-slot="select"
onOpenChange={(open) => {
setIsOpen(open);
onOpenChange?.(open);
}}
{...props}
/>
</SelectOpenContext.Provider>
);
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
icon,
iconClassName,
animateRadius = true,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
icon?: typeof UnfoldMoreIcon;
iconClassName?: string;
animateRadius?: boolean;
}) {
const isOpen = useContext(SelectOpenContext);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
style={
animateRadius
? {
borderRadius: isOpen ? "12px" : undefined,
transition: isOpen
? "border-radius 0ms"
: "border-radius 150ms cubic-bezier(0.645, 0.045, 0.355, 1)",
}
: undefined
}
className={cn(
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 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 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<HugeiconsIcon
icon={icon ?? UnfoldMoreIcon}
strokeWidth={2}
className={cn(
"text-muted-foreground size-4 pointer-events-none",
iconClassName,
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
container,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
container?: HTMLElement | null;
}) {
const dialogContainer = useDialogPortalContainer();
return (
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-[var(--radix-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--radix-select-trigger-width)]",
position === "popper" && "",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl corner-squircle py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"bg-border/50 -mx-1 my-1 h-px pointer-events-none",
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,12 @@ import { cn } from "@/lib/utils";
type ToggleFn = () => void;
const TooltipToggleCtx = createContext<ToggleFn | null>(null);
// Default to instant open (no hover delay). Most tooltips in the app —
// chat-area icon labels, sidebar nav labels, the context/token
// calculators — should feel snappy. Consumers that want a delay still
// pass an explicit `delayDuration` prop.
function TooltipProvider({
delayDuration = 400,
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
@ -81,25 +85,35 @@ function TooltipTrigger({
);
}
type TooltipVariant = "default" | "rich" | "none";
// `default` applies the compact black-pill styling shared with the
// sidebar/chat icon labels. `rich` opts into the larger multi-row
// popover surface used for timing/context breakdowns. `none` is an
// escape hatch for tooltips that need to bring their own surface.
function TooltipContent({
variant = "default",
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
variant?: TooltipVariant;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-2xl corner-squircle px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background border border-foreground/40 shadow-lg z-[999999] w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
"z-[999999] w-fit max-w-xs",
variant === "default" && "tooltip-compact",
variant === "rich" && "tooltip-rich",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-[999999] translate-y-[calc(-50%_-_2px)]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);

View file

@ -17,6 +17,7 @@ import {
} from "./chat-api";
import { db } from "../db";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { isMultimodalResponse } from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import {
hasClosedThinkTag,
@ -396,6 +397,8 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
loadedIsMultimodal: isMultimodalResponse(loadResp),
});
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
@ -455,6 +458,9 @@ async function autoLoadSmallestModel(): Promise<{
if (!store.models.some((m) => m.id === repo.repo_id)) {
store.setModels([...store.models, sfModel]);
}
useChatRuntimeStore.setState({
loadedIsMultimodal: isMultimodalResponse(sfLoadResp),
});
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
@ -522,6 +528,7 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedIsMultimodal: isMultimodalResponse(loadResp),
});
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };

View file

@ -19,7 +19,7 @@ import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { CustomizeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
@ -272,10 +272,10 @@ function CompareShell({
>
{children}
</div>
<div className="shrink-0 bg-background px-5 pb-2 pt-1">
<div className="mx-auto w-full max-w-[44rem]">{composer}</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
<p className="composer-footer-note">
LLMs can make mistakes. Double-check responses.
</p>
</div>
</div>
@ -1073,14 +1073,22 @@ export function ChatPage(): ReactElement {
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open configuration"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
<HugeiconsIcon
icon={CustomizeIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
Open configuration
</TooltipContent>
</Tooltip>

File diff suppressed because it is too large Load diff

View file

@ -46,14 +46,14 @@ export const ContextUsageBar: FC<{
type="button"
aria-label={`Context usage: ${formatTokenCount(used)} of ${formatTokenCount(total)} tokens`}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1 text-xs font-mono tabular-nums text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
"flex items-center gap-2 rounded-[10px] px-2.5 py-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,
)}
>
<span>
{formatTokenCount(used)} / {formatTokenCount(total)}
</span>
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
<div className="h-1.5 w-16 rounded-full bg-black/10 dark:bg-white/15 overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", severity.bar)}
style={{ width: `${percent}%` }}
@ -64,7 +64,8 @@ export const ContextUsageBar: FC<{
<TooltipContent
side="bottom"
sideOffset={8}
className="[&_span>svg]:hidden! rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md"
variant="rich"
className="[&_span>svg]:hidden!"
>
<div className="grid min-w-44 gap-1.5 text-xs">
<div className="flex items-center justify-between gap-4">

View file

@ -28,24 +28,14 @@ import {
mergeBackendRecommendedInference,
resolveLoadMaxSeqLength,
} from "../presets/preset-policy";
import {
isMultimodalResponse,
} from "../types/api";
import type {
ChatLoraSummary,
ChatModelSummary,
} from "../types/runtime";
// The simplified Speculative Decoding control surfaces "default" (which
// maps to llama.cpp's --spec-default) and "off". A backend status / load
// response can still report the older manual modes (ngram-mod,
// ngram-simple) when a model is loaded via the API or carried over from an
// older Studio version. The Select would render an empty trigger for those
// values, so coerce them to "default" -- llama.cpp's own --spec-default
// picks an equivalent strategy and keeps the dropdown coherent.
function normalizeSpeculativeType(v: string | null | undefined): string | null {
if (v == null) return null;
if (v === "default" || v === "off") return v;
return "default";
}
type SelectedModelInput = {
id: string;
isLora?: boolean;
@ -147,6 +137,12 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string {
return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`;
}
function normalizeSpeculativeType(v: string | null | undefined): string | null {
if (v == null) return null;
if (v === "default" || v === "off") return v;
return "default";
}
export function useChatModelRuntime() {
const params = useChatRuntimeStore((state) => state.params);
const models = useChatRuntimeStore((state) => state.models);
@ -256,10 +252,19 @@ export function useChatModelRuntime() {
const ggufNativeContextLength = statusRes.is_gguf
? (statusRes.native_context_length ?? null)
: null;
const currentSpecType = normalizeSpeculativeType(statusRes.speculative_type);
const currentSpecType = normalizeSpeculativeType(
statusRes.speculative_type,
);
// Refresh runs both on F5 (fresh store needs hydration) AND right
// after a fresh load (store was already set by the load path). For
// the user-configurable model params we only hydrate when the shadow
// `loaded*` field is still null -- that signals "not yet hydrated".
// Otherwise we'd clobber the values the load path just applied and
// the UI would appear to revert the user's changes.
const prevState = useChatRuntimeStore.getState();
const nextDefaultChatTemplate =
statusRes.chat_template === undefined
? useChatRuntimeStore.getState().defaultChatTemplate
? prevState.defaultChatTemplate
: statusRes.chat_template;
useChatRuntimeStore.setState({
supportsReasoning,
@ -278,8 +283,22 @@ export function useChatModelRuntime() {
modelRequiresTrustRemoteCode:
statusRes.requires_trust_remote_code ?? false,
defaultChatTemplate: nextDefaultChatTemplate,
speculativeType: currentSpecType,
loadedSpeculativeType: currentSpecType,
loadedIsMultimodal: isMultimodalResponse(statusRes),
...(prevState.loadedSpeculativeType === null && {
speculativeType: currentSpecType,
loadedSpeculativeType: currentSpecType,
}),
...(statusRes.cache_type_kv !== undefined &&
prevState.loadedKvCacheDtype === null && {
kvCacheDtype: statusRes.cache_type_kv,
loadedKvCacheDtype: statusRes.cache_type_kv,
}),
...(statusRes.chat_template_override !== undefined &&
prevState.loadedChatTemplateOverride === null &&
prevState.chatTemplateOverride === null && {
chatTemplateOverride: statusRes.chat_template_override,
loadedChatTemplateOverride: statusRes.chat_template_override,
}),
});
// Set reasoning default for Qwen3.5/3.6 small models
@ -297,6 +316,7 @@ export function useChatModelRuntime() {
} else {
useChatRuntimeStore.setState({
modelRequiresTrustRemoteCode: false,
loadedIsMultimodal: false,
});
}
} catch (error) {
@ -492,6 +512,8 @@ export function useChatModelRuntime() {
maxSeqLength,
presetSource: activePresetSource,
});
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
const loadResponse = await loadModel({
model_path: modelId,
nativePathLease: loadNativePathLease,
@ -501,7 +523,7 @@ export function useChatModelRuntime() {
is_lora: isLora,
gguf_variant: ggufVariant ?? null,
trust_remote_code: trustRemoteCode,
chat_template_override: chatTemplateOverride,
chat_template_override: effectiveChatTemplateOverride,
cache_type_kv: kvCacheDtype,
speculative_type: speculativeType,
});
@ -531,7 +553,9 @@ export function useChatModelRuntime() {
}
}
const loadedKv = loadResponse.cache_type_kv ?? null;
const loadedSpec = normalizeSpeculativeType(loadResponse.speculative_type);
const loadedSpec = normalizeSpeculativeType(
loadResponse.speculative_type,
);
const nativeCtx = loadResponse.is_gguf
? (loadResponse.context_length ?? 131072)
: null;
@ -566,11 +590,16 @@ export function useChatModelRuntime() {
loadedSpeculativeType: loadedSpec,
customContextLength: keepCustomCtx,
defaultChatTemplate: loadResponse.chat_template ?? null,
chatTemplateOverride: null,
chatTemplateOverride: effectiveChatTemplateOverride,
loadedChatTemplateOverride: effectiveChatTemplateOverride,
loadedIsMultimodal: isMultimodalResponse(loadResponse),
activeNativePathToken: nativePathToken ?? null,
});
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
if (
modelId.toLowerCase().includes("qwen3") &&
(loadResponse.supports_reasoning ?? false)
) {
const store = useChatRuntimeStore.getState();
if (store.activePresetSource === "builtin-default") {
const mid = modelId.toLowerCase();

View file

@ -66,6 +66,29 @@ function cancelIfRunning(threadId: string): void {
cancelByThreadId[threadId]?.();
}
export async function renameChatItem(
item: SidebarItem,
nextTitle: string,
): Promise<void> {
const trimmed = nextTitle.trim();
if (!trimmed || trimmed === item.title) return;
if (item.type === "single") {
await db.threads.update(item.id, { title: trimmed });
return;
}
const pairThreads = await db.threads
.where("pairId")
.equals(item.id)
.toArray();
await db.transaction("rw", db.threads, async () => {
for (const t of pairThreads) {
await db.threads.update(t.id, { title: trimmed });
}
});
}
export async function deleteChatItem(
item: SidebarItem,
activeId: string | undefined,

View file

@ -9,5 +9,13 @@ export {
type Preset,
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatSearchStore } from "./stores/chat-search-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export {
deleteChatItem,
renameChatItem,
useChatSidebarItems,
type SidebarItem,
} from "./hooks/use-chat-sidebar-items";

View file

@ -27,44 +27,16 @@ export type PresetOwnedParams = Pick<
export const BUILTIN_PRESETS: Preset[] = [
{ name: "Default", params: { ...defaultInferenceParams } },
{
name: "Creative",
params: {
...defaultInferenceParams,
temperature: 1.5,
topP: 1.0,
topK: 0,
minP: 0.1,
repetitionPenalty: 1.0,
},
},
{
name: "Precise",
params: {
...defaultInferenceParams,
temperature: 0.1,
topP: 0.95,
topK: 80,
minP: 0.01,
repetitionPenalty: 1.0,
},
},
];
export const BUILTIN_PRESET_NAMES = new Set(
BUILTIN_PRESETS.map((preset) => preset.name),
);
export type ChatPresetSource =
| "builtin-default"
| "builtin-fixed"
| "custom"
| "modified";
export type ChatPresetSource = "builtin-default" | "custom" | "modified";
export function getPresetSource(name: string): ChatPresetSource {
if (name === "Default") return "builtin-default";
if (BUILTIN_PRESET_NAMES.has(name)) return "builtin-fixed";
return "custom";
return name === "Default" ? "builtin-default" : "custom";
}
export function getUniquePresetName(
@ -102,7 +74,9 @@ export function normalizeCustomPresets(presets: Preset[]): Preset[] {
return presets
.map((preset): Preset | null => {
const trimmedName = preset.name.trim();
if (!trimmedName) return null;
if (!trimmedName) {
return null;
}
const name = usedNames.has(trimmedName)
? getBuiltinVariantName(trimmedName, usedNames)
: trimmedName;
@ -119,6 +93,21 @@ export function getOrderedPresets(customPresets: Preset[]): Preset[] {
return [...BUILTIN_PRESETS, ...normalizeCustomPresets(customPresets)];
}
export function getPresetOwnedParams(
params: InferenceParams,
): PresetOwnedParams {
return {
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
minP: params.minP,
repetitionPenalty: params.repetitionPenalty,
presencePenalty: params.presencePenalty,
maxTokens: params.maxTokens,
systemPrompt: params.systemPrompt,
};
}
export function isSamePresetConfig(
a: InferenceParams,
b: InferenceParams,
@ -137,21 +126,6 @@ export function isSamePresetConfig(
);
}
export function getPresetOwnedParams(
params: InferenceParams,
): PresetOwnedParams {
return {
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
minP: params.minP,
repetitionPenalty: params.repetitionPenalty,
presencePenalty: params.presencePenalty,
maxTokens: params.maxTokens,
systemPrompt: params.systemPrompt,
};
}
export function getPresetOwnedConfigKey(params: InferenceParams): string {
return JSON.stringify(getPresetOwnedParams(params));
}
@ -211,7 +185,10 @@ export function getPresetSaveState({
}
if (BUILTIN_PRESET_NAMES.has(trimmedName)) {
const variantName = getBuiltinVariantName(trimmedName, new Set(presets.map((preset) => preset.name)));
const variantName = getBuiltinVariantName(
trimmedName,
new Set(presets.map((preset) => preset.name)),
);
return {
mode: "copy-builtin",
canSubmit: activePreset !== trimmedName || hasUnsavedPresetChanges,
@ -234,8 +211,7 @@ export function getPresetSaveState({
mode: isActiveMatch ? "overwrite-active" : "overwrite-other",
canSubmit: !isActiveMatch || hasUnsavedPresetChanges,
isSaveReady: !isActiveMatch || hasUnsavedPresetChanges,
buttonLabel:
isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
buttonLabel: isActiveMatch && !hasUnsavedPresetChanges ? "Saved" : "Save",
title: isActiveMatch
? hasUnsavedPresetChanges
? "Save current settings to this preset"
@ -307,7 +283,8 @@ export function mergeBackendRecommendedInference({
...next,
maxTokens: defaultMaxTokens,
temperature:
toFiniteNumber(inference?.temperature) ?? defaultInferenceParams.temperature,
toFiniteNumber(inference?.temperature) ??
defaultInferenceParams.temperature,
topP: toFiniteNumber(inference?.top_p) ?? defaultInferenceParams.topP,
topK: toFiniteNumber(inference?.top_k) ?? defaultInferenceParams.topK,
minP: toFiniteNumber(inference?.min_p) ?? defaultInferenceParams.minP,
@ -343,9 +320,17 @@ export function resolveLoadMaxSeqLength({
currentCheckpoint === modelId &&
(ggufVariant ?? null) === (activeGgufVariant ?? null);
if (customContextLength != null) return customContextLength;
if (isGgufLoad && presetSource === "builtin-default") return 0;
if (isReloadingCurrentGguf) return ggufContextLength ?? 0;
if (isGgufLoad) return 0;
if (customContextLength != null) {
return customContextLength;
}
if (isGgufLoad && presetSource === "builtin-default") {
return 0;
}
if (isReloadingCurrentGguf) {
return ggufContextLength ?? 0;
}
if (isGgufLoad) {
return 0;
}
return maxSeqLength;
}

View file

@ -4,6 +4,7 @@
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
@ -14,7 +15,6 @@ import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { isTauri } from "@/lib/api-base";
import { useAui } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
@ -358,6 +358,8 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@ -379,7 +381,7 @@ export function SharedComposer({
is_lora: sel.isLora,
gguf_variant: sel.ggufVariant ?? null,
trust_remote_code: trustRemoteCode,
chat_template_override: chatTemplateOverride,
chat_template_override: effectiveChatTemplateOverride,
});
if (validation.requires_trust_remote_code && !trustRemoteCode) {
throw new Error(
@ -395,7 +397,7 @@ export function SharedComposer({
is_lora: sel.isLora,
gguf_variant: sel.ggufVariant ?? null,
trust_remote_code: trustRemoteCode,
chat_template_override: chatTemplateOverride,
chat_template_override: effectiveChatTemplateOverride,
});
const store = useChatRuntimeStore.getState();
store.setCheckpoint(
@ -492,7 +494,7 @@ export function SharedComposer({
return (
<div
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
className={`chat-composer-surface ${dragging ? "border-ring bg-accent/50" : ""}`}
onDragOver={(e) => {
if (isTauri) return;
e.preventDefault();
@ -539,10 +541,10 @@ export function SharedComposer({
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Send to both models..."
className="mb-1 min-h-12 w-full resize-none overflow-y-hidden bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
className="composer-input"
rows={1}
/>
<div className="relative mx-2 mb-2 flex items-center justify-between">
<div className="composer-action-wrapper">
<div className="flex items-center gap-1">
<input
ref={fileInputRef}
@ -682,14 +684,8 @@ export function SharedComposer({
type="button"
disabled={toolsDisabled}
onClick={() => setToolsEnabled(!toolsEnabled)}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
toolsDisabled
? "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 && !toolsDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
>
<GlobeIcon className="size-3.5" />
@ -699,14 +695,8 @@ export function SharedComposer({
type="button"
disabled={toolsDisabled}
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
toolsDisabled
? "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 && !toolsDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<CodeToggleIcon className="size-3.5" />

View file

@ -212,9 +212,11 @@ type ChatRuntimeStore = {
loadedKvCacheDtype: string | null;
speculativeType: string | null;
loadedSpeculativeType: string | null;
loadedIsMultimodal: boolean;
customContextLength: number | null;
defaultChatTemplate: string | null;
chatTemplateOverride: string | null;
loadedChatTemplateOverride: string | null;
activeThreadId: string | null;
settingsPanelOpen: boolean;
pendingAudioBase64: string | null;
@ -297,9 +299,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
loadedKvCacheDtype: null,
speculativeType: "default",
loadedSpeculativeType: null,
loadedIsMultimodal: false,
customContextLength: null,
defaultChatTemplate: null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
activeThreadId: null,
settingsPanelOpen: false,
pendingAudioBase64: null,
@ -399,9 +403,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
loadedKvCacheDtype: null,
speculativeType: "default",
loadedSpeculativeType: null,
loadedIsMultimodal: false,
customContextLength: null,
defaultChatTemplate: null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
})),
setReasoningEnabled: (reasoningEnabled) => set({ reasoningEnabled }),
setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }),

View file

@ -70,6 +70,25 @@ export interface GgufVariantsResponse {
default_variant: string | null;
}
export function isMultimodalResponse(
response:
| {
is_vision?: boolean;
is_audio?: boolean;
audio_type?: string | null;
has_audio_input?: boolean;
}
| null
| undefined,
): boolean {
return (
Boolean(response?.is_vision) ||
Boolean(response?.is_audio) ||
Boolean(response?.has_audio_input) ||
response?.audio_type === "audio_vlm"
);
}
export interface LoadModelResponse {
status: string;
model: string;
@ -134,6 +153,8 @@ export interface InferenceStatusResponse {
context_length?: number | null;
max_context_length?: number | null;
native_context_length?: number | null;
cache_type_kv?: string | null;
chat_template_override?: string | null;
speculative_type?: string | null;
}

View file

@ -958,7 +958,7 @@ export function ExportPage() {
</div>
)}
<div className="rounded-xl bg-muted/50 p-3">
<div className="rounded-xl bg-foreground/[0.04] p-3">
<p className="text-[11px] text-muted-foreground">
Direct model exports currently support GGUF only.
</p>
@ -968,7 +968,7 @@ export function ExportPage() {
</AnimatePresence>
{sourceMode === "checkpoint" && (
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
<div className="rounded-xl bg-foreground/[0.04] p-3 flex flex-col gap-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
Training Info
</span>
@ -1010,7 +1010,7 @@ export function ExportPage() {
key={step}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-[10px] font-semibold">
{i + 1}
</span>
{step}

View file

@ -96,11 +96,11 @@ export function UsageExamples() {
};
return (
<section className="flex flex-col">
<section className="flex min-w-0 max-w-full flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Usage examples</h2>
<div className="overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
<div className="flex items-center gap-0.5">
<div className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-0.5">
{TABS.map((t) => {
const active = lang === t.id;
return (
@ -134,7 +134,7 @@ export function UsageExamples() {
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
{snippets[lang]}
</pre>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">

View file

@ -114,17 +114,17 @@ export function SettingsDialog() {
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
"relative flex h-[32px] items-center gap-2.5 rounded-[8px] px-2.5 text-[14.5px] leading-[19px] tracking-nav font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white",
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2e3035]"
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2d2f33]"
transition={
reduced
? { duration: 0 }
@ -139,8 +139,8 @@ export function SettingsDialog() {
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.5}
className="relative z-10 size-[18px]"
strokeWidth={1.75}
className="relative z-10 size-icon"
/>
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
{tab.badge ? (
@ -158,12 +158,12 @@ export function SettingsDialog() {
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close settings"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-6">
{renderTab(activeTab)}
</div>
</main>

View file

@ -61,8 +61,8 @@ export function ApiKeysTab() {
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<div className="flex min-w-0 max-w-full flex-col gap-6">
<header className="flex min-w-0 flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">API</h1>
<p className="text-xs text-muted-foreground">
Access Unsloth programmatically via the OpenAI-compatible API.{" "}
@ -111,7 +111,7 @@ export function ApiKeysTab() {
)}
</AnimatePresence>
<section className="flex flex-col">
<section className="flex min-w-0 flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Access tokens</h2>
{error ? (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
@ -131,7 +131,7 @@ export function ApiKeysTab() {
No API access yet.
</p>
) : (
<div className="flex flex-col">
<div className="flex min-w-0 flex-col">
{keys.map((k) => (
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
))}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { TrainingViewData } from "@/features/training";
import { getTrainingRun } from "@/features/training";
import { getTrainingRun, onTrainingRunUpdated } from "@/features/training";
import type { TrainingRunDetailResponse } from "@/features/training";
import { parseBackendTrainingMethod } from "@/features/training/lib/training-methods";
import { type ReactElement, useEffect, useState } from "react";
@ -70,7 +70,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
: run.error_message ?? "Training errored",
error: run.status === "error" ? run.error_message : null,
isTrainingRunning: false,
modelName: run.model_name,
modelName: run.display_name ?? run.model_name,
trainingMethod: parseBackendTrainingMethod(
detail.config?.training_type,
detail.config?.load_in_4bit,
@ -109,6 +109,14 @@ export function HistoricalTrainingView({
};
}, [runId]);
useEffect(() => {
const offUpdated = onTrainingRunUpdated((updated) => {
if (updated.id !== runId) return;
setDetail((prev) => (prev ? { ...prev, run: updated } : prev));
});
return offUpdated;
}, [runId]);
if (loading) {
return (
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">

View file

@ -15,7 +15,11 @@ import { Button } from "@/components/ui/button";
import type { TrainingRunSummary } from "@/features/training";
import {
deleteTrainingRun,
emitTrainingRunDeleted,
listTrainingRuns,
onTrainingRunDeleted,
onTrainingRunsChanged,
onTrainingRunUpdated,
useTrainingActions,
useTrainingRuntimeStore,
} from "@/features/training";
@ -180,6 +184,11 @@ export function HistoryCardGrid({
const pollControllerRef = useRef<AbortController | null>(null);
const fetchIdRef = useRef(0);
const pollIdRef = useRef(0);
const runsLengthRef = useRef(0);
useEffect(() => {
runsLengthRef.current = runs.length;
}, [runs.length]);
const fetchRuns = useCallback(async (offset = 0, append = false, limit = PAGE_SIZE) => {
// Cancel any in-flight poll so its stale response can't clobber this fresher fetch
@ -216,6 +225,27 @@ export function HistoryCardGrid({
};
}, [fetchRuns]);
useEffect(() => {
const offUpdated = onTrainingRunUpdated((updated) => {
setRuns((prev) =>
prev.map((run) => (run.id === updated.id ? updated : run)),
);
});
const offDeleted = onTrainingRunDeleted((runId) => {
setRuns((prev) => prev.filter((run) => run.id !== runId));
setTotal((prev) => Math.max(0, prev - 1));
});
const offChanged = onTrainingRunsChanged(() => {
const limit = Math.max(PAGE_SIZE, runsLengthRef.current);
void fetchRuns(0, false, limit);
});
return () => {
offUpdated();
offDeleted();
offChanged();
};
}, [fetchRuns]);
// Poll while any run is still "running" so the card shows live progress
const hasRunningRun = runs.some((r) => r.status === "running");
const visibleCount = runs.length;
@ -248,9 +278,7 @@ export function HistoryCardGrid({
setDeleteError(null);
try {
await deleteTrainingRun(deleteTarget);
// Optimistically remove the card so it disappears immediately
setRuns((prev) => prev.filter((r) => r.id !== deleteTarget));
setTotal((prev) => Math.max(0, prev - 1));
emitTrainingRunDeleted(deleteTarget);
// Re-fetch preserving visible count so offsets stay consistent for "Load more"
const currentCount = runs.length - 1;
const limit = Math.max(PAGE_SIZE, currentCount);
@ -366,11 +394,22 @@ export function HistoryCardGrid({
<div className="min-w-0">
<p
className="truncate text-sm font-medium"
title={run.model_name}
title={run.display_name ?? run.model_name}
>
{run.model_name}
{run.display_name ?? run.model_name}
</p>
<p className="truncate text-xs text-muted-foreground">
{run.display_name && (
<p
className="truncate text-xs text-muted-foreground"
title={run.model_name}
>
{run.model_name}
</p>
)}
<p
className="truncate text-xs text-muted-foreground"
title={run.dataset_name}
>
{run.dataset_name}
</p>
</div>

View file

@ -6,6 +6,7 @@ import type {
TrainingRunDeleteResponse,
TrainingRunDetailResponse,
TrainingRunListResponse,
TrainingRunSummary,
} from "../types/history";
async function readError(response: Response): Promise<string> {
@ -57,3 +58,20 @@ export async function deleteTrainingRun(
);
return parseJson<TrainingRunDeleteResponse>(response);
}
export async function renameTrainingRun(
runId: string,
displayName: string | null,
signal?: AbortSignal,
): Promise<TrainingRunSummary> {
const response = await authFetch(
`/api/train/runs/${encodeURIComponent(runId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name: displayName }),
signal,
},
);
return parseJson<TrainingRunSummary>(response);
}

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { TrainingRunSummary } from "./types/history";
type UpdateListener = (run: TrainingRunSummary) => void;
type DeleteListener = (runId: string) => void;
type ChangedListener = () => void;
const updateListeners = new Set<UpdateListener>();
const deleteListeners = new Set<DeleteListener>();
const changedListeners = new Set<ChangedListener>();
export function onTrainingRunUpdated(fn: UpdateListener): () => void {
updateListeners.add(fn);
return () => {
updateListeners.delete(fn);
};
}
export function onTrainingRunDeleted(fn: DeleteListener): () => void {
deleteListeners.add(fn);
return () => {
deleteListeners.delete(fn);
};
}
export function onTrainingRunsChanged(fn: ChangedListener): () => void {
changedListeners.add(fn);
return () => {
changedListeners.delete(fn);
};
}
export function emitTrainingRunUpdated(run: TrainingRunSummary): void {
for (const fn of updateListeners) {
fn(run);
}
}
export function emitTrainingRunDeleted(runId: string): void {
for (const fn of deleteListeners) {
fn(runId);
}
}
export function emitTrainingRunsChanged(): void {
for (const fn of changedListeners) {
fn();
}
}

View file

@ -5,6 +5,7 @@ import { primeNativeNotificationPermission } from "@/lib/native-notifications";
import { useCallback } from "react";
import { toast } from "sonner";
import { checkDatasetFormat } from "../api/datasets-api";
import { emitTrainingRunsChanged } from "../events";
import { getTrainingRun } from "../api/history-api";
import { buildTrainingStartPayload } from "../api/mappers";
import { resetTraining, startTraining, stopTraining } from "../api/train-api";
@ -140,6 +141,7 @@ export function useTrainingActions() {
}
runtimeStore.setStartQueued(response.job_id, response.message);
emitTrainingRunsChanged();
await syncTrainingRuntimeFromBackend();
return true;
} catch (error) {
@ -203,6 +205,7 @@ export function useTrainingActions() {
}
runtimeStore.setStartQueued(response.job_id, response.message);
emitTrainingRunsChanged();
await syncTrainingRuntimeFromBackend();
return true;
} catch (error) {

View file

@ -2,55 +2,195 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { listTrainingRuns } from "../api/history-api";
import {
onTrainingRunDeleted,
onTrainingRunsChanged,
onTrainingRunUpdated,
} from "../events";
import type { TrainingRunSummary } from "../types/history";
const SIDEBAR_LIMIT = 20;
const RUNNING_POLL_MS = 5000;
const INITIAL_RETRY_DELAYS_MS = [500, 1500, 3500];
const LOAD_FAILURE_TOAST_ID = "training-history-load-failure";
function isAbortError(err: unknown): boolean {
return err instanceof DOMException && err.name === "AbortError";
}
export function useTrainingHistorySidebarItems(enabled: boolean) {
const [items, setItems] = useState<TrainingRunSummary[]>([]);
const [loaded, setLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const inFlightRef = useRef(false);
const fetchRuns = useCallback(async () => {
if (inFlightRef.current) {
const fetchRuns = useCallback(async (): Promise<void> => {
if (controllerRef.current && !controllerRef.current.signal.aborted) {
return;
}
const controller = new AbortController();
controllerRef.current = controller;
inFlightRef.current = true;
try {
const result = await listTrainingRuns(SIDEBAR_LIMIT, 0, controller.signal);
const result = await listTrainingRuns(
SIDEBAR_LIMIT,
0,
controller.signal,
);
if (controller.signal.aborted) {
return;
}
setItems(result.runs);
setLoaded(true);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
toast.dismiss(LOAD_FAILURE_TOAST_ID);
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
inFlightRef.current = false;
}
}, []);
// Background refresh (rename/delete sync, polling): swallow errors so
// transient failures don't spam toasts; the next successful fetch heals.
const refresh = useCallback(async (): Promise<void> => {
try {
await fetchRuns();
} catch {
// intentionally ignored
}
}, [fetchRuns]);
// Initial load: bounded retry-with-backoff, then surface a toast on
// final failure with a Retry action so the user isn't stuck staring
// at an empty sidebar after F5 if the backend was slow to come up.
useEffect(() => {
if (!enabled) return;
void fetchRuns();
if (!enabled) {
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const showFailureToast = (err: unknown): void => {
toast.error("Couldn't load training runs", {
id: LOAD_FAILURE_TOAST_ID,
description: err instanceof Error ? err.message : undefined,
action: {
label: "Retry",
onClick: () => {
void (async () => {
try {
await fetchRuns();
} catch (retryErr) {
if (isAbortError(retryErr)) {
return;
}
showFailureToast(retryErr);
}
})();
},
},
});
};
const attempt = async (index: number): Promise<void> => {
if (cancelled) {
return;
}
try {
await fetchRuns();
} catch (err) {
if (cancelled || isAbortError(err)) {
return;
}
if (index < INITIAL_RETRY_DELAYS_MS.length) {
timer = setTimeout(
() => void attempt(index + 1),
INITIAL_RETRY_DELAYS_MS[index],
);
return;
}
showFailureToast(err);
}
};
void attempt(0);
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
controllerRef.current?.abort();
};
}, [enabled, fetchRuns]);
// Poll while there's a running run, but only when the tab is visible.
// Browsers throttle background timers but don't pause them — gating on
// visibility avoids hammering the API for tabs left open in the
// background, which is common during long training runs.
const hasRunning = items.some((r) => r.status === "running");
useEffect(() => {
if (!enabled || !hasRunning) return;
const timer = setInterval(() => {
void fetchRuns();
}, RUNNING_POLL_MS);
return () => clearInterval(timer);
}, [enabled, hasRunning, fetchRuns]);
if (!enabled || !hasRunning) {
return;
}
return { items, loaded, refresh: fetchRuns };
let timer: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (timer !== null) {
return;
}
timer = setInterval(() => void refresh(), RUNNING_POLL_MS);
};
const stop = () => {
if (timer === null) {
return;
}
clearInterval(timer);
timer = null;
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
void refresh();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") {
start();
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
stop();
controllerRef.current?.abort();
};
}, [enabled, hasRunning, refresh]);
useEffect(() => {
const offUpdated = onTrainingRunUpdated((updated) => {
controllerRef.current?.abort();
setItems((prev) =>
prev.map((run) => (run.id === updated.id ? updated : run)),
);
});
const offDeleted = onTrainingRunDeleted((runId) => {
controllerRef.current?.abort();
setItems((prev) => prev.filter((run) => run.id !== runId));
});
const offChanged = onTrainingRunsChanged(() => {
controllerRef.current?.abort();
void refresh();
});
return () => {
offUpdated();
offDeleted();
offChanged();
};
}, [refresh]);
return { items, loaded, refresh };
}

View file

@ -9,6 +9,7 @@ export {
export { useTrainingActions } from "./hooks/use-training-actions";
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
@ -23,6 +24,19 @@ export type {
TrainingRunDetailResponse,
TrainingRunDeleteResponse,
} from "./types/history";
export { listTrainingRuns, getTrainingRun, deleteTrainingRun } from "./api/history-api";
export {
listTrainingRuns,
getTrainingRun,
deleteTrainingRun,
renameTrainingRun,
} from "./api/history-api";
export {
onTrainingRunUpdated,
onTrainingRunDeleted,
onTrainingRunsChanged,
emitTrainingRunUpdated,
emitTrainingRunDeleted,
emitTrainingRunsChanged,
} from "./events";
export { parseYamlConfig, serializeConfigToYaml } from "./lib/yaml-config";
export { validateTrainingConfig } from "./lib/validation";

View file

@ -6,6 +6,7 @@ export interface TrainingRunSummary {
status: "running" | "completed" | "stopped" | "error";
model_name: string;
dataset_name: string;
display_name: string | null;
started_at: string;
ended_at: string | null;
total_steps: number | null;

View file

@ -63,7 +63,7 @@
--card-foreground: oklch(0.1281 0.0179 169.2764);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1281 0.0179 169.2764);
--primary: oklch(0.6929 0.1396 166.5513);
--primary: #17b88b;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9596 0.0275 167.8295);
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
@ -74,21 +74,21 @@
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9208 0.0101 164.8536);
--input: oklch(0.9208 0.0101 164.8536);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.6929 0.1396 166.5513);
--ring: #17b88b;
--chart-1: #17b88b;
--chart-2: oklch(0.694 0.1395 136.6059);
--chart-3: oklch(0.7014 0.1193 197.5897);
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 1.1rem;
--sidebar: oklch(0.99 0 0);
--sidebar: #f9faf9;
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary: #17b88b;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0.0279 166.55);
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
--sidebar-border: oklch(0.9208 0.0101 164.8536);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--sidebar-border: oklch(0.945 0.0101 164.8536);
--sidebar-ring: #17b88b;
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
@ -121,48 +121,97 @@
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
--tracking-normal: -0.01em;
/* Hex (not OKLCH) so the rendered surface matches the design mockup pixel-for-pixel. */
--nav-fg: #383835;
--nav-fg-muted: #858279;
--nav-surface-hover: #f0f0f0;
--nav-icon-idle: #8f8f8f;
--nav-beta-border: #e0ded6;
--panel-surface-hover: #ebebeb;
/* Right-side chat-parameters panel: matches the chat content
surface in both themes distinction from the left sidebar
(#f9faf9) is handled by the left border alone. Tracks
`--background` so any future tweaks to the chat surface flow
through automatically. */
--panel-surface: var(--background);
--panel-surface-fg: var(--foreground);
--panel-input-surface: #f5f5f5;
--panel-input-surface-hover: #efefef;
/* Muted gray with a one-step warmer-blue last channel (#779 vs flat #777)
so the tone has a faint hue rather than pure neutral keeps text and
sliders quiet but not lifeless. */
--panel-surface-fg-muted: #777779;
/* Slider track-fill / thumb / hover halo. Decoupled from
--panel-surface-fg-muted so the slider can be tuned independently
from muted text. Light mode: lighter than the muted-text gray for
a softer feel. Dark mode (further down) keeps parity with the
muted-text token. */
--panel-slider-fg: #9a9a9c;
/* Chat-message action icons (assistant action bar, branch picker
chevrons + numbers, message-timing token counter, code-block
copy/download, user action bar, delete button). One token drives
all of them so the row reads as a single coherent control strip.
Mid-dark gray on the light surface visible enough to read as
active controls, not so dark that they compete with message
text. */
--chat-icon-fg: #555555;
--chat-icon-fg-hover: var(--foreground);
--chat-icon-bg-hover: #ededec;
/* Standard interactive-icon size for nav, menus, action bars, and
in-message code-block actions. Sized one step above body text so
icons read as minimally larger than adjacent labels (~14px text).
Theme-independent declared once in :root. */
--icon-size: 18px;
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
button i.e. (32px icon-size) / 2. Use as a negative margin on a
chat-message action bar so the leftmost icon's visual edge aligns
with the message text edge. Auto-tracks --icon-size. */
--icon-btn-inset: calc((2rem - var(--icon-size)) / 2);
}
.dark {
/* Exact palette from apps/studio/index_chat.html mockup. Using hex so the
rendered surface matches the mockup pixel-for-pixel OKLCH conversion
drifted ~3% darker and shifted the neutral hue. */
--background: #1a1b1e;
--foreground: #d4d4d4;
--card: #222427;
--card-foreground: #d4d4d4;
--popover: #222427;
--popover-foreground: #d4d4d4;
--primary: oklch(0.6929 0.1396 166.5513);
--background: #1f2023;
--foreground: #ececee;
--card: #2d2e32;
--card-foreground: #ececee;
--popover: #2d2e32;
--popover-foreground: #ececee;
--primary: #17b88b;
--primary-foreground: oklch(1 0 0);
--secondary: #2e3035;
--secondary-foreground: #d4d4d4;
--secondary-foreground: #ececee;
--muted: #2e3035;
--muted-foreground: #999999;
--accent: #2e3035;
--accent-foreground: #d4d4d4;
--accent-foreground: #ececee;
--destructive: oklch(0.6368 0.2078 25.3313);
--border: #2e3035;
/* --input one step lighter than --muted so form borders stay visible on
muted surfaces (right config panel) and keep subtle contrast on card. */
/* --border / --input one step lighter than --muted so outlines and form
borders stay visible on muted surfaces (right config panel, export
tiles, quant chips) and keep subtle contrast on card. */
--border: #3a3d42;
--input: #3a3d42;
--ring: oklch(0.6929 0.1396 166.5513);
--ring: #17b88b;
--chart-1: oklch(0.7511 0.1407 166.2284);
--chart-2: oklch(0.75 0.14 136.5572);
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: #222427;
--sidebar-foreground: #d4d4d4;
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar: #18181a;
--sidebar-foreground: #ececee;
--sidebar-primary: #17b88b;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: #2e3035;
--sidebar-accent-foreground: #d4d4d4;
--sidebar-border: #2e3035;
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--sidebar-accent: #2f2f31;
--sidebar-accent-foreground: #ececee;
--sidebar-border: #2d2d2f;
--sidebar-ring: #17b88b;
--destructive-foreground: oklch(1 0 0);
--radius: 0.625rem;
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
@ -181,6 +230,34 @@
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--nav-fg: #c7c7c4;
--nav-fg-muted: #96979b;
--nav-surface-hover: #2e2e30;
--nav-icon-idle: #5c5c5c;
--nav-beta-border: #3a3c3f;
--panel-surface-hover: #3a3c42;
/* Right-side chat-parameters panel: matches the chat content
surface in both themes distinction from the left sidebar
(#18181a, the deepest surface) is handled by the left border
alone. Tracks `--background` so any future tweaks to the chat
surface flow through automatically. */
--panel-surface: var(--background);
--panel-surface-fg: var(--foreground);
--panel-input-surface: #2a2b2e;
--panel-input-surface-hover: #2e3033;
/* Soft neutral gray for muted text and sliders. Pure-ish #ababab
reads as quiet on the dark panel without going colored. */
--panel-surface-fg-muted: #ababab;
/* Dark-mode slider tone matches muted text user wants the dark
theme slider unchanged from the previous behavior. */
--panel-slider-fg: #ababab;
/* Chat-message action icons. A touch lighter than the previous
#b8b8b8 so the icons read clearly without going near pure white;
hover restores full --foreground for affordance. */
--chat-icon-fg: #d8d8d8;
--chat-icon-fg-hover: var(--foreground);
--chat-icon-bg-hover: #2d2e32;
}
@theme inline {
@ -251,6 +328,20 @@
/*--shadow-opacity: var(--shadow-opacity);*/
/*--color-shadow-color: var(--shadow-color);*/
--color-destructive-foreground: var(--destructive-foreground);
--color-nav-fg: var(--nav-fg);
--color-nav-fg-muted: var(--nav-fg-muted);
--color-nav-surface-hover: var(--nav-surface-hover);
--color-nav-icon-idle: var(--nav-icon-idle);
--color-nav-beta-border: var(--nav-beta-border);
--color-panel-surface-hover: var(--panel-surface-hover);
--color-panel-surface: var(--panel-surface);
--color-panel-surface-fg: var(--panel-surface-fg);
--color-panel-surface-fg-muted: var(--panel-surface-fg-muted);
--color-chat-icon-fg: var(--chat-icon-fg);
--color-chat-icon-fg-hover: var(--chat-icon-fg-hover);
--color-chat-icon-bg-hover: var(--chat-icon-bg-hover);
--animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes pulse {
@ -362,6 +453,300 @@
letter-spacing: -0.01em;
}
/* Dark mode loosens tracking to offset optical bloom on dark surfaces. */
.tracking-nav {
letter-spacing: 0.015em;
}
.dark .tracking-nav {
letter-spacing: 0.03em;
}
.nav-icon-btn {
@apply inline-flex h-7 w-7 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
}
/* Standard icon size drives every nav/menu/action-bar icon
(left sidebar, app-user menu, settings tabs, chat config toggle,
right-panel close, chat message action bars, code-block actions).
Pulls from --icon-size so a single edit retunes them all. */
.size-icon {
width: var(--icon-size);
height: var(--icon-size);
}
/* Pins Inter across themes; parent `font-heading` resolves to Geist in dark. */
.nav-badge {
font-family: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
}
.sidebar-nav-btn {
color: var(--nav-fg);
}
.sidebar-nav-btn:hover,
.sidebar-nav-btn[data-active="true"],
.sidebar-nav-btn[data-state="open"],
.group\/recent-item:hover .sidebar-nav-btn,
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.group\/run-item:hover .sidebar-nav-btn,
.group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
background-color: var(--nav-surface-hover) !important;
color: #000 !important;
}
.dark .sidebar-nav-btn:hover,
.dark .sidebar-nav-btn[data-active="true"],
.dark .sidebar-nav-btn[data-state="open"],
.dark .group\/recent-item:hover .sidebar-nav-btn,
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.dark .group\/run-item:hover .sidebar-nav-btn,
.dark .group\/run-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn {
color: #fff !important;
}
.sidebar-row-action {
@apply absolute top-0 bottom-0 right-0 inline-flex items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
}
.sidebar-row-action[data-state="open"] {
@apply opacity-100 pointer-events-auto;
}
.sidebar-row-action-glyph {
@apply inline-flex size-6 items-center justify-center rounded-[10px] text-sidebar-foreground/55;
}
/* Branch picker chevron buttons sit beside action bar icon buttons
(size-8, rounded-[10px]). Height + radius match for visual
alignment, but width is tighter so the small chevron glyph reads
as a compact control rather than a full-size icon button. */
.aui-branch-chevron-btn {
@apply inline-flex h-8 w-6 cursor-pointer items-center justify-center rounded-[10px] p-0 text-chat-icon-fg transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent;
}
.sidebar-row-action:hover .sidebar-row-action-glyph,
.sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
@apply bg-nav-surface-hover text-nav-fg;
}
.dark .sidebar-row-action:hover .sidebar-row-action-glyph,
.dark .sidebar-row-action[data-state="open"] .sidebar-row-action-glyph {
color: #fff;
}
.sidebar-sticky-label {
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150;
}
.sidebar-sticky-label.is-scrolled {
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)];
}
/* Neutral panel input surface sidesteps the green cast on
`--input` / `--border` (both have a small chroma at hue ~165 in
light mode). Same value drives the preset input pill, the system
prompt button, and the chat-template textarea so all three read
as one quiet gray family, matching the focused-edit-number tint. */
.panel-input-group {
@apply !h-9 min-h-9 min-w-0 items-stretch gap-0 rounded-[10px] pr-0 transition-colors focus-within:ring-0 focus-within:shadow-none;
border: 0 !important;
background-color: var(--panel-input-surface);
}
.panel-input-group:has([data-slot="input-group-control"]:focus-visible) {
border: 0 !important;
box-shadow: none;
}
/* Neutral surface for the larger panel text containers system
prompt preview button and chat-template textarea so they share
the same gray as the preset input pill and don't pick up the
theme's slight green-cast `--input`. */
.panel-text-surface {
@apply rounded-[20px] border-0 transition-colors;
background-color: var(--panel-input-surface);
}
.panel-text-surface:hover {
background-color: var(--panel-input-surface-hover);
}
/* Sidebar sliders: soft neutral grays active fill and thumb stay
in the same neutral family as the panel surface so the controls
read as quiet, modern, and uncluttered. Flat (no shadow), small
thumb, no ring. The track's translucent neutral adapts to either
theme; the fill/thumb pick a mid-gray with enough contrast to
read on the panel without competing with text. */
/* Inactive track: barely-there alpha so it reads as a faint hint
rather than a visible bar the active fill carries the value,
the track just suggests the slider's extent. Same alpha both
themes; the black/white base flips automatically per theme. */
.panel-slider [data-slot="slider-track"] {
height: 0.25rem !important;
background-color: rgb(0 0 0 / 0.025) !important;
}
.dark .panel-slider [data-slot="slider-track"] {
background-color: rgb(255 255 255 / 0.025) !important;
}
/* Sliders in the right-side parameters panel.
*
* Color: every interactive surface (active fill, thumb body, thumb
* border, hover/press halo) resolves through a single token
* `--panel-surface-fg-muted` so the slider always belongs to the
* same gray family as the panel's muted text. Theme switching and
* future tone tweaks happen in one place.
*
* Pressure feedback: only the halo expresses interaction. The
* shared <Slider /> component (components/ui/slider.tsx) applies
* `hover:scale-110`, `active:scale-95`, `hover:ring-4`, and
* `shadow-sm` to the thumb via Tailwind utilities. Suppressing
* `transform` and `box-shadow` on the base rule prevents those from
* competing with the halo's transition without that, two
* animations run on different durations/curves and the interaction
* reads as jittery.
*
* Track-press detection: Radix's slider thumb only exposes
* `data-disabled` and `data-orientation` (verified against the
* package source) there is no active-state attribute on the
* thumb. We anchor the press selector on the slider root
* (`.panel-slider:active`), which receives `:active` for any
* pointer-down inside the slider, including presses that start on
* the track. This is the only DOM-faithful way to show the halo
* during a track-press without patching the shared component.
*
* Halo lifecycle: deliberately no `:focus` pointer focus persists
* after release and would leave a stale halo. `:focus-visible`
* keeps keyboard navigation accessible (Tab + arrows). */
.panel-slider .bg-primary {
background-color: var(--panel-slider-fg) !important;
}
.panel-slider [data-slot="slider-thumb"] {
width: 0.875rem !important;
height: 0.875rem !important;
background-color: var(--panel-slider-fg) !important;
border-color: var(--panel-slider-fg) !important;
transform: none !important;
box-shadow: none !important;
transition: box-shadow 140ms ease-out !important;
}
.panel-slider [data-slot="slider-thumb"]:hover,
.panel-slider [data-slot="slider-thumb"]:focus-visible,
.panel-slider:active [data-slot="slider-thumb"] {
box-shadow: 0 0 0 10px
color-mix(in srgb, var(--panel-slider-fg) 18%, transparent) !important;
}
/* Active press: slightly larger / more opaque ring than passive
hover, so the haptic reads stronger when the user is actively
manipulating the value. */
.panel-slider:active [data-slot="slider-thumb"] {
box-shadow: 0 0 0 12px
color-mix(in srgb, var(--panel-slider-fg) 22%, transparent) !important;
}
/* Inline numeric input used for slider values and Context Length.
Designed to read as *editable text* rather than a pill button so
it doesn't mirror the Select/dropdown components on the panel.
Default: transparent box, no border, no ring, sized by the
`size` HTML attribute (each consumer picks 6 / 8 / etc. chars).
Hover/focus: very light bg fade-in to signal editability just
enough to read as interactive, not enough to compete with the
slider row's quiet aesthetic. */
.panel-number-input {
@apply h-7 rounded-md border-0 bg-transparent px-1.5 text-right text-[13px]! font-medium tabular-nums text-nav-fg shadow-none transition-colors hover:bg-black/[0.04] focus:bg-black/[0.05] focus-visible:ring-0! focus-visible:outline-none md:text-[13px]!;
}
.dark .panel-number-input {
@apply hover:bg-white/[0.04] focus:bg-white/[0.06];
}
/* Switch unsloth-green track when active, slider-gray thumb in
both states. Keeps the on-state recognizable as an "engaged"
primary control while the moving thumb sits in the same neutral
palette as the panel sliders, tying every control in the panel
to a single gray family. Unchecked track keeps shadcn's default
bg-input for the standard off affordance. */
.panel-switch[data-state="checked"] {
background-color: var(--primary) !important;
}
.panel-switch [data-slot="switch-thumb"] {
background-color: var(--panel-slider-fg) !important;
}
.panel-switch[data-state="checked"] [data-slot="switch-thumb"] {
background-color: #ffffff !important;
}
/* Compact tooltip small black pill with white text. Used for short
hover labels on chat-area icon buttons (Copy, Edit, Delete,
Refresh, More, code-block actions) and the panel's info-icon
hints. Corner radius is fixed at 8px so it tracks with the
underlying icon button corners (also 8px) keeps the tooltip
visually anchored to its trigger rather than reading as a much
larger floating pill. */
.tooltip-compact {
@apply rounded-[10px] border-transparent bg-black px-2 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md;
}
/* Rich tooltip used for the context-usage and token-counter
popups (multi-row metric breakdowns). Same black surface as the
compact tooltips so chat-area popovers feel like one family.
Corner radius matches the user-profile dropdown in the left
sidebar (14px) so panel-level menu surfaces share a single
roundness. Uses the heading font with tracking for the structured
content. Same in both themes. */
.tooltip-rich {
@apply rounded-[16px] border-transparent bg-black px-4 py-3 font-heading tracking-wide text-white shadow-[0_8px_28px_-6px_rgba(0,0,0,0.32)];
}
/* Row-label color override the popups reuse the existing prose
`text-muted-foreground` class. Fixed light gray on the black
surface keeps the label clearly legible while staying distinct
from the values (full white). */
.tooltip-rich .text-muted-foreground {
color: #b1b1b1 !important;
}
.tooltip-rich .border-border\/40 {
border-color: rgb(255 255 255 / 0.12) !important;
}
.app-user-menu [data-slot="dropdown-menu-item"] {
height: 32px;
padding: 0 0.625rem !important;
gap: 8.5px !important;
border-radius: 10px;
font-weight: 500;
font-size: 14.5px;
line-height: 19px;
letter-spacing: 0.015em;
color: var(--nav-fg);
}
.dark .app-user-menu [data-slot="dropdown-menu-item"] {
letter-spacing: 0.03em;
}
.app-user-menu [data-slot="dropdown-menu-item"] svg {
width: 19px !important;
height: 19px !important;
flex-shrink: 0;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus {
background-color: var(--nav-surface-hover);
color: #000;
}
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus {
color: #fff;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus * {
color: #000 !important;
}
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus * {
color: #fff !important;
}
.menu-flat-destructive {
--destructive: #dc4848;
}
.dark .menu-flat-destructive {
--destructive: #ed7878;
}
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"],
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
color: var(--destructive);
}
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
}
.app-user-menu [data-slot="dropdown-menu-item"][data-variant="destructive"]:focus * {
color: var(--destructive) !important;
}
/* Elevated surface shadow (use ring-* for borders) */
.shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
@ -374,7 +759,32 @@
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.menu-soft-surface,
.menu-soft-surface-up {
--menu-soft-edge: rgba(0, 0, 0, 0.14);
--menu-soft-shadow: rgba(0, 0, 0, 0.18);
--menu-soft-offset-y: 8px;
--menu-soft-blur: 28px;
--menu-soft-spread: -6px;
@apply bg-popover text-popover-foreground;
box-shadow:
inset 0 0 0 1px var(--menu-soft-edge),
0 var(--menu-soft-offset-y) var(--menu-soft-blur)
var(--menu-soft-spread) var(--menu-soft-shadow);
}
.menu-soft-surface-up {
--menu-soft-offset-y: -6px;
--menu-soft-spread: -8px;
}
.dark .menu-soft-surface,
.dark .menu-soft-surface-up {
--menu-soft-edge: rgba(255, 255, 255, 0.07);
--menu-soft-shadow: rgba(0, 0, 0, 0.28);
}
.chat-composer-surface {
@apply relative flex w-full flex-col rounded-[24px] bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow;
font-family: var(--font-sans);
border: 1px solid oklch(0.93 0 0 / 1);
background-clip: padding-box;
box-shadow:
@ -384,8 +794,28 @@
}
.dark .chat-composer-surface {
border-color: #2e3035;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
border-color: #34363a;
box-shadow: 0 -6px 36px -14px rgba(0, 0, 0, 0.15);
}
.composer-pill-btn {
@apply flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
}
.composer-pill-btn[data-active="true"] {
color: var(--primary);
}
.composer-input {
@apply mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
}
.composer-action-wrapper {
@apply relative mx-2 mb-2 flex items-center justify-between;
}
.composer-footer-note {
@apply mt-1.5 text-center text-[11px] tracking-[0.04em] text-muted-foreground;
font-family: var(--font-sans);
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
@ -426,6 +856,7 @@
[data-streamdown="code-block"] {
gap: 0.25rem;
padding: 0.75rem 1rem;
border-radius: 1.5rem;
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
max-width: 100%;
min-width: 0;
@ -469,6 +900,45 @@
font-family: var(--font-sans);
}
/* Normalize the trailing margin of the last element inside an
assistant message so the gap above the action bar is the same
regardless of whether the response ends with a paragraph
(margin-bottom: 0 by Tailwind preflight) or a streamdown block
like a code fence (margin-bottom: 1rem from `my-4`). Browser
block layout doesn't collapse trailing margin into a sibling
container, so we zero it explicitly along the deepest
`:last-child` path. Streamdown wraps content in several
nested divs, so code blocks land 45 levels deep the chain
walks that depth without using a generic descendant `:last-child`
(which would also zero last-paragraph-in-list margins). The
visible gap is then driven solely by the footer's own `mt-*`. */
.aui-assistant-message-content > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child,
.aui-assistant-message-content > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child > *:last-child {
margin-bottom: 0 !important;
}
/* The streamdown code-block wrapper carries `my-4` (16px top + 16px
bottom margin). The bottom margin is what stretches the gap
between the code-block box and the action-bar below it on a
trailing code block. We zero `margin-bottom` on every code block
inside an assistant message body. CSS margin collapsing handles
the non-trailing case correctly: when a code block is followed by
a paragraph (or any block with `my-4` mt), the rendered gap is
`max(prev.mb, next.mt)` so removing the code block's `mb` still
leaves the next element's `mt-4` as the visible spacer. The only
case actually affected is the trailing position (no next element),
where `mb=0` collapses the gap to just the footer's `mt-2`,
matching a text-trailing message. The wrapper's own
`padding-bottom` is preserved, so the last line of code keeps its
natural breathing room inside the box. */
.aui-assistant-message-content [data-streamdown="code-block"] {
margin-bottom: 0 !important;
}
/* Keep monospace for code fences and inline code (not KaTeX). */
.aui-thread-root [data-streamdown="code-block"] pre,
.aui-thread-root [data-streamdown="code-block"] code {
@ -491,7 +961,38 @@
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
--shiki-dark-bg: transparent;
background: var(--color-code-block);
border: 1px solid oklch(1 0 0 / 0.07);
border: 1px solid oklch(1 0 0 / 0.08);
}
/* Streamdown code-block stability hardening.
*
* Two streamdown internals cause a visible "reload"-style flicker on
* trailing code blocks the moment streaming ends. Both are addressable
* purely in CSS without patching the library:
*
* 1. `content-visibility: auto` + `contain-intrinsic-size: auto 200px`
* (set inline by streamdown's <ot> wrapper). The IntersectionObserver
* that gates content-visibility flips rendered height between the
* 200px placeholder and the actual code-block height as the block
* sits near the viewport edge during stream finalization. The
* height jump is small but visible, and the rendering optimization
* is unnecessary for chat content (thread length is bounded). We
* force `visible` to keep the rendered height of code blocks fully
* determined by their actual content at all times.
*
* 2. `[data-sd-animate]` (`sd-fadeIn`, 150ms). Streamdown wraps each
* streaming text segment in a span carrying this attribute. When
* shiki re-renders the code body at stream end, those wrapper
* spans get re-keyed and the fade animation replays across the
* whole block at once exactly the visual that reads as the chat
* area "reloading for a frame." We disable the animation only
* inside code blocks; prose token fade-in elsewhere is untouched. */
.aui-thread-root [data-streamdown="code-block"] {
content-visibility: visible !important;
contain-intrinsic-size: none !important;
}
.aui-thread-root [data-streamdown="code-block"] [data-sd-animate] {
animation: none !important;
}
}
@ -509,15 +1010,34 @@
::-webkit-scrollbar {
width: 8px;
height: 8px;
background: transparent;
border: none;
box-shadow: none;
}
::-webkit-scrollbar-track {
background: transparent;
border: none;
box-shadow: none;
}
::-webkit-scrollbar-track-piece {
background: transparent;
border: none;
box-shadow: none;
}
::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 0.54);
border-radius: 9999px;
border: none;
box-shadow: none;
}
::-webkit-scrollbar-corner {
background: transparent;
border: none;
box-shadow: none;
}
::-webkit-scrollbar-button {
@ -539,20 +1059,51 @@
scrollbar-gutter: stable;
}
/* Marker class applied only to actual streaming-thread viewports
(see ThreadPrimitive.Viewport in components/assistant-ui/thread.tsx).
Scoped separately from `.aui-thread-viewport` because that class is
reused elsewhere for shared scrollbar styling on non-streaming scroll
areas; the stabilizer must only attach to viewports the
useIntentAwareAutoScroll hook actually drives. */
.aui-stream-viewport {
/* Scroll stabilizer: compensates for transient scrollHeight shrinks
(most visibly, shiki re-highlighting a trailing code block the
instant streaming ends). The useIntentAwareAutoScroll hook sets
this variable to the exact pixel amount of any content shrink
observed while the follow window is active; that padding keeps
scrollHeight monotonic, so the browser never auto-clamps scrollTop,
so no jump is ever painted. Released back to 0 as content
genuinely grows past its prior high-water mark, and on user
detach so the bottom stays flush when they come back. */
padding-bottom: var(--aui-scroll-stabilizer, 0px);
}
.dark .aui-thread-viewport {
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
scrollbar-color: oklch(0.72 0 0 / 0.25) #23252a;
}
.dark .aui-thread-viewport::-webkit-scrollbar-thumb {
background: oklch(0.72 0 0 / 0.25);
}
.aui-thread-viewport::-webkit-scrollbar-track {
background: var(--sidebar);
}
.dark .aui-thread-viewport::-webkit-scrollbar-track {
background: #23252a;
}
[data-sidebar="content"] {
scrollbar-color: oklch(0.5 0 0 / 0.22) transparent;
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar);
}
.dark [data-sidebar="content"] {
scrollbar-color: oklch(0.72 0 0 / 0.25) transparent;
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar);
}
[data-sidebar="content"]::-webkit-scrollbar-track {
background: var(--sidebar);
}
[data-sidebar="content"]::-webkit-scrollbar-thumb {

View file

@ -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())

View file

@ -8,8 +8,19 @@ from pathlib import Path
import pytest
WORKDIR = Path(__file__).resolve().parents[2]
PRESET_POLICY = WORKDIR / "studio/frontend/src/features/chat/presets/preset-policy.ts"
RUNTIME_TYPES = WORKDIR / "studio/frontend/src/features/chat/types/runtime.ts"
def _source_path(relative_path: str) -> Path:
direct = WORKDIR / relative_path
if direct.exists():
return direct
return WORKDIR / "unsloth_repo" / relative_path
PRESET_POLICY = _source_path(
"studio/frontend/src/features/chat/presets/preset-policy.ts"
)
RUNTIME_TYPES = _source_path("studio/frontend/src/features/chat/types/runtime.ts")
TEMP = WORKDIR / "temp" / "chat_preset_builtin_invariants"
@ -18,6 +29,14 @@ def _require_node():
pytest.skip("node not available")
if not PRESET_POLICY.exists() or not RUNTIME_TYPES.exists():
pytest.skip("studio chat sources not present")
result = subprocess.run(
["node", "--experimental-strip-types", "--version"],
capture_output = True,
text = True,
timeout = 5,
)
if result.returncode != 0:
pytest.skip("node --experimental-strip-types not available")
def _ensure_harness():
@ -213,8 +232,20 @@ def test_apply_preset_params_preserves_model_owned_fields():
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, applyPresetParams }} from "{_policy_path()}";
const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative");
import {{ applyPresetParams }} from "{_policy_path()}";
const samplingPreset = {{
temperature: 1.5,
topP: 1,
topK: 0,
minP: 0.1,
repetitionPenalty: 1,
presencePenalty: 0,
maxSeqLength: 4096,
maxTokens: 2048,
systemPrompt: "",
checkpoint: "",
trustRemoteCode: false,
}};
const applied = applyPresetParams(
{{
temperature: 0.6,
@ -229,7 +260,7 @@ def test_apply_preset_params_preserves_model_owned_fields():
checkpoint: "foo/bar",
trustRemoteCode: true,
}},
creative.params,
samplingPreset,
);
console.log(JSON.stringify({{
checkpoint: applied.checkpoint,
@ -248,21 +279,16 @@ def test_apply_preset_params_preserves_model_owned_fields():
assert out["topK"] == 0
def test_creative_and_precise_builtins_differ_from_default():
def test_default_is_only_builtin_preset():
out = _run(
textwrap.dedent(
f"""
// @ts-nocheck
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
const creative = BUILTIN_PRESETS.find((p) => p.name === "Creative");
const precise = BUILTIN_PRESETS.find((p) => p.name === "Precise");
import {{ BUILTIN_PRESETS }} from "{_policy_path()}";
console.log(JSON.stringify({{
creativeDiffers: !isSamePresetConfig(def.params, creative.params),
preciseDiffers: !isSamePresetConfig(def.params, precise.params),
names: BUILTIN_PRESETS.map((p) => p.name),
}}));
"""
)
)
assert out["creativeDiffers"] is True
assert out["preciseDiffers"] is True
assert out["names"] == ["Default"]