* Dark theme refactor, right sidebar redesign, and chat UI polish
- Dark theme refactor
- Redesign right sidebar
- Further left sidebar adjustments
- Wider chat and content area; layout tweaks for chat content
- Rounded corners across elements for consistency
- Show chat message menu icons on menu-area hover, not only on message hover
- Assistant message menu icons now always visible; user messages keep on-hover
- Redesigned copy icon used consistently across chat blocks and messages
- Redesigned trash icon, applied consistently
- Unified icon sizing and style with the sidebar
- Adjusted icon colors across chat
- Fix on-hover background design for chat icons
- Fix tooltip from 'more' button staying visible after clicking elsewhere
- Adjust position and design of generation speed info text below messages
- Adjust design of token speed info popup
- Adjust sidebar scrollbar to cover recent chats only
* Recents sidebar rename, UI/theme refactor, layout and chat polish
UI & Theme:
- Dark theme refactor
- Consistent rounded corners across elements
- CSS polish and cleanup
- Remove unused logo image assets
Recents sidebar:
- Add 'more' button for options menu
- Support renaming conversations and training runs
- Confirmation dialog before deleting chats
- Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config
- New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override
- Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow
- Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed
- Optimistic updates on rename and delete (apply locally + cache before background refresh)
- Visible toast on rename/delete failure instead of swallowed errors
Layout:
- Redesigned right sidebar
- Further left sidebar adjustments
- Updated chat content layout; chat and content area slightly widened
- Sidebar scrollbar covers recent chats only
Icons:
- Redesigned copy icon, unified across chat blocks and messages
- Redesigned trash icon to match
- Consistent icon sizing and style across chat and sidebar
- Adjusted icon colors across chat
- Fix icon on-hover background design
Chat messages:
- Menu icons now appear on hover over the menu area, not just the message
- Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts)
- Repositioned and restyled generation speed info text below messages
- Restyled token generation speed popup
Tooltips:
- Removed tooltip on hover for previous/next assistant response icons
- Unified tooltip design across sidebars and chat
- Removed tooltip animations (also fixes related lag)
Model & Chat Template config:
- Merged Chat Template config into Model Configuration section
- Added revert-to-original for chat template
- Fix Chat Template config disappearing on page refresh until model reload
Performance & scroll:
- Removed chatbox movement animations across pages/navigation (fixes related UI lag)
- Fix scroll flicker at end of streaming when a code block is the final element
- Additional chat scroll improvements
Bug fixes:
- Fix 'more' button tooltip remaining visible after clicking elsewhere
* Remove sidebar localStorage cache and optimistic updates
Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount.
* Fix missing cn import in shared-composer (regression from merge)
* chore(sidebar): import sidebar deps from feature indexes
Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change.
* fix(studio/frontend): reload training Recents sidebar after F5 refresh
The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation.
Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update.
Changes (all in use-training-history-sidebar.ts):
- fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses.
- Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data.
- Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure.
- Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery).
- Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs.
- Both effects own their teardown explicitly (abort + clear timer).
* Apply unified tooltip design and behavior across remaining pages for consistency
* UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon
- Adjust tiny spacing between elements around the UI for subtle polish
- Redesign tooltip on source icons for web search / tool use, consistent with the new design
- Adjust chat text letter spacing
- Smaller icon sizes
- Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Adjust CSS for right sidebar
* Fix scrollbar UI compatibility across browsers
* fix: preserve chat preset settings on model load
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): remove duplicate chat template status field
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* chore: remove creative preset assumption
* fix(studio): align speculative decoding default
* fix(studio/chat): snap numeric param inputs to step grid
- Type a value in any param input (Temperature, Top K, Max Tokens, etc.)
now clamps to [min, max] and snaps to the slider's step grid, killing
off-grid values like 1.051234 and FP residue from slider drags.
- Branch picker chevrons share the action bar's 32px height + 10px radius
via a new .aui-branch-chevron-btn utility; hover area aligns visually
while staying narrower than the sibling icon buttons.
* fix(studio/chat): keep training-run polls converging and drop dead preset code
- Keep training-run polls converging when responses outrun the 5s interval
(don't unconditionally abort prior in-flight; skip if one is still pending,
mutation race still guarded).
- Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source
variant + unreachable branches).
* fix(studio): training-run cards show custom name + model + dataset
- Training-run cards now display custom display_name + model + dataset,
with cross-view sync on rename/delete.
- Enhance clarity of borders and colors in dark theme on export etc.
* fix(studio): match active state green to unsloth brand color
* fix(studio): preserve can_resume on training rename
* fix(studio): keep GGUF chat template override distinct
* fix(studio): treat audio input models as multimodal
* fix(studio): cancel numeric draft on Escape
* fix(studio): use default speculative mode on toggle
* fix(studio): detect GGUF audio VLM input models
* fix(studio): address final PR review findings
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload
* fix: API and svg
* fix(studio/sidebar): align run rename dirty check with displayed baseline
* fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate
---------
Co-authored-by: sneakr <hauzin@hotmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
294 lines
9.3 KiB
Python
294 lines
9.3 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
WORKDIR = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
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"
|
|
|
|
|
|
def _require_node():
|
|
if shutil.which("node") is None:
|
|
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():
|
|
TEMP.mkdir(parents = True, exist_ok = True)
|
|
(TEMP / "register.mjs").write_text(
|
|
"import { register } from 'node:module';\n"
|
|
"register('./loader.mjs', import.meta.url);\n"
|
|
)
|
|
(TEMP / "loader.mjs").write_text(
|
|
"export function resolve(specifier, context, next) {\n"
|
|
" if (specifier.endsWith('/types/runtime')) return next(specifier + '.ts', context);\n"
|
|
" return next(specifier, context);\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
def _run(script: str):
|
|
_require_node()
|
|
_ensure_harness()
|
|
script_path = TEMP / "run.mts"
|
|
script_path.write_text(script)
|
|
env = dict(os.environ, NODE_NO_WARNINGS = "1")
|
|
result = subprocess.run(
|
|
[
|
|
"node",
|
|
"--experimental-strip-types",
|
|
"--import=./register.mjs",
|
|
"--no-warnings",
|
|
"run.mts",
|
|
],
|
|
cwd = str(TEMP),
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
env = env,
|
|
)
|
|
assert result.returncode == 0, f"stderr: {result.stderr}\nstdout: {result.stdout}"
|
|
last = [line for line in result.stdout.strip().splitlines() if line.strip()][-1]
|
|
return json.loads(last)
|
|
|
|
|
|
def _policy_path():
|
|
return os.path.relpath(PRESET_POLICY, TEMP).replace("\\", "/")
|
|
|
|
|
|
def _runtime_path():
|
|
return os.path.relpath(RUNTIME_TYPES, TEMP).replace("\\", "/")
|
|
|
|
|
|
def test_default_builtin_matches_default_inference_params():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
|
import {{ DEFAULT_INFERENCE_PARAMS }} from "{_runtime_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
console.log(JSON.stringify({{
|
|
found: !!def,
|
|
matches: def ? isSamePresetConfig(def.params, DEFAULT_INFERENCE_PARAMS) : null,
|
|
}}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["found"] is True
|
|
assert out["matches"] is True
|
|
|
|
|
|
def test_is_same_preset_config_detects_temperature_edit():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
const edited = {{ ...def.params, temperature: def.params.temperature + 0.1 }};
|
|
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["same"] is False
|
|
|
|
|
|
def test_is_same_preset_config_detects_system_prompt_edit():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
const edited = {{ ...def.params, systemPrompt: "you are a pirate" }};
|
|
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["same"] is False
|
|
|
|
|
|
def test_is_same_preset_config_ignores_checkpoint_difference():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
const withCheckpoint = {{ ...def.params, checkpoint: "meta-llama/Llama-3-8B" }};
|
|
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, withCheckpoint) }}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["same"] is True
|
|
|
|
|
|
def test_is_same_preset_config_ignores_model_owned_fields():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, isSamePresetConfig }} from "{_policy_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
const edited = {{
|
|
...def.params,
|
|
maxSeqLength: def.params.maxSeqLength + 1024,
|
|
trustRemoteCode: !def.params.trustRemoteCode,
|
|
}};
|
|
console.log(JSON.stringify({{ same: isSamePresetConfig(def.params, edited) }}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["same"] is True
|
|
|
|
|
|
def test_preset_owned_config_key_ignores_model_owned_fields():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS, getPresetOwnedConfigKey }} from "{_policy_path()}";
|
|
const def = BUILTIN_PRESETS.find((p) => p.name === "Default");
|
|
const edited = {{
|
|
...def.params,
|
|
checkpoint: "foo/bar",
|
|
maxSeqLength: def.params.maxSeqLength + 1024,
|
|
trustRemoteCode: !def.params.trustRemoteCode,
|
|
}};
|
|
console.log(JSON.stringify({{
|
|
same: getPresetOwnedConfigKey(def.params) === getPresetOwnedConfigKey(edited),
|
|
}}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["same"] is True
|
|
|
|
|
|
def test_to_preset_params_strips_model_owned_fields():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ toPresetParams }} from "{_policy_path()}";
|
|
const sanitized = toPresetParams({{
|
|
temperature: 0.9,
|
|
topP: 0.8,
|
|
topK: 40,
|
|
minP: 0.05,
|
|
repetitionPenalty: 1.1,
|
|
presencePenalty: 0.4,
|
|
maxSeqLength: 16384,
|
|
maxTokens: 2048,
|
|
systemPrompt: "hello",
|
|
checkpoint: "foo/bar",
|
|
trustRemoteCode: true,
|
|
}});
|
|
console.log(JSON.stringify({{
|
|
checkpoint: sanitized.checkpoint,
|
|
trustRemoteCode: sanitized.trustRemoteCode,
|
|
maxSeqLength: sanitized.maxSeqLength,
|
|
maxTokens: sanitized.maxTokens,
|
|
systemPrompt: sanitized.systemPrompt,
|
|
}}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["checkpoint"] == ""
|
|
assert out["trustRemoteCode"] is False
|
|
assert out["maxSeqLength"] == 4096
|
|
assert out["maxTokens"] == 2048
|
|
assert out["systemPrompt"] == "hello"
|
|
|
|
|
|
def test_apply_preset_params_preserves_model_owned_fields():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
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,
|
|
topP: 0.95,
|
|
topK: 20,
|
|
minP: 0.01,
|
|
repetitionPenalty: 1.0,
|
|
presencePenalty: 0.0,
|
|
maxSeqLength: 16384,
|
|
maxTokens: 8192,
|
|
systemPrompt: "keep me?",
|
|
checkpoint: "foo/bar",
|
|
trustRemoteCode: true,
|
|
}},
|
|
samplingPreset,
|
|
);
|
|
console.log(JSON.stringify({{
|
|
checkpoint: applied.checkpoint,
|
|
trustRemoteCode: applied.trustRemoteCode,
|
|
maxSeqLength: applied.maxSeqLength,
|
|
temperature: applied.temperature,
|
|
topK: applied.topK,
|
|
}}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["checkpoint"] == "foo/bar"
|
|
assert out["trustRemoteCode"] is True
|
|
assert out["maxSeqLength"] == 16384
|
|
assert out["temperature"] == 1.5
|
|
assert out["topK"] == 0
|
|
|
|
|
|
def test_default_is_only_builtin_preset():
|
|
out = _run(
|
|
textwrap.dedent(
|
|
f"""
|
|
// @ts-nocheck
|
|
import {{ BUILTIN_PRESETS }} from "{_policy_path()}";
|
|
console.log(JSON.stringify({{
|
|
names: BUILTIN_PRESETS.map((p) => p.name),
|
|
}}));
|
|
"""
|
|
)
|
|
)
|
|
assert out["names"] == ["Default"]
|