Studio frontend: reduce and tighten code comments (#6099)

Trim and tighten code comments across studio/frontend TS/JS. Comment-only: every changed file verified code-identical to main via the TypeScript printer signature comparison.
This commit is contained in:
Daniel Han 2026-06-08 23:10:35 -07:00 committed by GitHub
commit 85314ed162
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 1553 additions and 2141 deletions

View file

@ -48,11 +48,10 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
const win = getCurrentWindow();
// Decide first-launch vs restore from the on-disk state file BEFORE touching the
// window. Probing the window itself after restoreStateCurrent is unreliable:
// on GTK, set_size against a hidden window is deferred until show(), so
// innerSize() reads a stale value and any baseline fallback would overwrite the
// queued restore. On macOS the same probe works, hence the inconsistency
// between previous iterations of this code.
// window. Probing the window after restoreStateCurrent is unreliable: on GTK,
// set_size on a hidden window is deferred until show(), so innerSize() reads a
// stale value and a baseline fallback would overwrite the queued restore. On
// macOS the same probe works, hence the inconsistency between prior iterations.
const hasSavedState = await invoke<boolean>("has_saved_window_state");
if (!isCurrent()) return;
@ -60,9 +59,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
if (!isCurrent()) return;
if (hasSavedState) {
// Subsequent launch: the plugin handles size, position, and maximized,
// with built-in off-screen protection (monitor-intersection check) for
// positions saved on a now-disconnected display.
// Subsequent launch: plugin restores size/position/maximized, with built-in
// off-screen protection for positions saved on a now-disconnected display.
await restoreStateCurrent(
StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED,
);
@ -87,8 +85,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
if (!isCurrent()) return;
await win.show();
if (!isCurrent()) return;
// Apply constraints after restore/show. Setting constraints before plugin restore
// can emit a Resized event and overwrite the plugin's cached saved size.
// Apply constraints after restore/show: doing so before plugin restore can emit
// a Resized event and overwrite the plugin's cached saved size.
await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT });
}

View file

@ -55,8 +55,8 @@ function isChatOnlyAllowed(pathname: string): boolean {
export const Route = createRootRoute({
beforeLoad: async ({ location }) => {
// Ensure platform info is fetched before checking chat-only guard.
// fetchDeviceType caches after first call, so subsequent navigations are instant.
// Fetch platform info before the chat-only guard. fetchDeviceType caches,
// so later navigations are instant.
await fetchDeviceType();
const chatOnly = usePlatformStore.getState().isChatOnly();
if (chatOnly && !isChatOnlyAllowed(location.pathname)) {

View file

@ -7,10 +7,10 @@ import { useSettingsDialogStore } from "@/features/settings";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
// /settings is a deep link to the modal. Open it, then redirect home.
// Tab title is driven by useSettingsDialogStore in __root.tsx since the
// redirect means /settings never stays matched; staticData is just a
// safety net if beforeLoad ever stops throwing.
// /settings deep-links the modal: open it, then redirect home. Tab title is
// driven by useSettingsDialogStore in __root.tsx since the redirect means
// /settings never stays matched; staticData is a safety net if beforeLoad
// ever stops throwing.
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/settings",

View file

@ -138,11 +138,8 @@ 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.
// TestTube01Icon's last 2 paths are interior bubbles; slice to the first
// 3 (outline + cap + liquid line) to drop them. Original export untouched.
const TestTubeOutlineIcon = TestTube01Icon.slice(
0,
3,
@ -261,11 +258,11 @@ export function AppSidebar() {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
// Bottom fade hides at the very bottom (and for short, non-scrolling lists)
// so the last row isn't washed out - Gemini-style.
// Bottom fade hides at the very bottom / for short lists so the last row
// isn't washed out (Gemini-style).
const [canScrollDown, setCanScrollDown] = useState(false);
// Driven only from onScroll + a content-change effect below. Deliberately NO
// ResizeObserver: its callback-driven setState created a render loop (React
// Driven only from onScroll + a content-change effect below. No
// ResizeObserver: its callback-driven setState caused a render loop (React
// #185). Both setters bail out when unchanged, so neither path can loop.
const syncScrollState = (el: HTMLDivElement) => {
const nextScrolled = el.scrollTop > 0;
@ -311,10 +308,9 @@ export function AppSidebar() {
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
// Recompute the bottom-fade state on mount and whenever the list height can
// change (items load, sections collapse/expand, route switches the visible
// list) - onScroll never fires for short, non-scrolling lists. Guarded
// setState below means this can't loop even if a dep is a fresh reference.
// Recompute bottom-fade on mount and whenever list height can change
// (items load, sections toggle, route switch) - onScroll never fires for
// short, non-scrolling lists. Guarded setState below can't loop.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
@ -761,9 +757,8 @@ export function AppSidebar() {
label={t("shell.navigation.search")}
active={false}
onClick={() => {
// Search is read-only over chat history and never runs
// inference, so it stays available while training (unlike
// New chat, which is gated on `chatDisabled`).
// Search is read-only and never runs inference, so it stays
// available while training (unlike New chat, gated on chatDisabled).
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
@ -796,8 +791,7 @@ export function AppSidebar() {
closeMobileIfOpen();
}}
/>
{/* Train has its own labelled section when expanded; surface it as
a plain icon here only while the sidebar is collapsed. */}
{/* Train has a labelled section when expanded; plain icon here only when collapsed. */}
<NavItem
icon={TestTubeOutlineIcon}
label={t("shell.navigation.train")}
@ -899,12 +893,10 @@ export function AppSidebar() {
<SidebarGroupContent className="px-2">
<SidebarMenu>
{runItems.map((run) => {
// An explicit sidebar selection wins. Otherwise highlight
// the active job only while the "Current Run" tab is the
// view - that covers a live run (it auto-switches there) and
// a just-finished/errored run you're still viewing, while
// keeping the Configure tab unhighlighted even though
// `activeJobId` stays pinned to the last job.
// Explicit selection wins. Otherwise highlight the active
// job only while the "Current Run" tab is the view, keeping
// the Configure tab unhighlighted even though activeJobId
// stays pinned to the last job.
const isActiveRun =
selectedHistoryRunId != null
? run.id === selectedHistoryRunId
@ -988,10 +980,9 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
{/* Fade above the profile box, shown only while there's more list below
the fold; at the very bottom (or for short lists) it fades out so the
last row shows fully (Gemini-style). `right-2` keeps it clear of the
8px scrollbar gutter so the scrollbar isn't faded out. */}
{/* Fade above the profile box, shown only when there's more list below
the fold; at the bottom (or short lists) it fades so the last row
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
<div
aria-hidden="true"
className={cn(
@ -1082,8 +1073,8 @@ export function AppSidebar() {
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Best-effort server-side revocation; ignore network errors
// so the local clear path still runs and the user lands on /login.
// Best-effort server revocation; ignore network errors so
// the local clear still runs and the user lands on /login.
try {
await logout();
} catch {

View file

@ -164,8 +164,8 @@ const AttachmentUI: FC = () => {
throw new Error(`Unknown attachment type: ${type as string}`);
}
});
// Include filename in accessible name so screen readers distinguish
// same-typed attachments. Sighted users get it via the tooltip.
// Filename in accessible name lets screen readers distinguish same-typed
// attachments. Sighted users get it via the tooltip.
const accessibleName = name
? `${typeLabel} attachment: ${name}`
: `${typeLabel} attachment`;

View file

@ -10,8 +10,8 @@ import {
} from "@streamdown/code";
import type { BundledLanguage } from "shiki";
// Fence tags LLMs/users commonly write that shiki doesn't expose as aliases.
// Keys are lower-cased input; values are canonical shiki language ids.
// Common fence tags shiki doesn't expose as aliases.
// Keys: lower-cased input; values: canonical shiki language ids.
const LANGUAGE_ALIAS_OVERRIDES: Record<string, BundledLanguage> = {
objectivec: "objective-c",
"obj-c": "objective-c",

View file

@ -5,11 +5,10 @@ import oneDarkPro from "@shikijs/themes/one-dark-pro";
import oneLight from "@shikijs/themes/one-light";
import type { ThemeRegistrationAny } from "shiki";
// Canonical Atom One Dark / One Light themes, shipped by `@shikijs/themes`.
// We only override the background so the code block blends into the app's
// `--code-block` surface instead of painting its own. Every token color and
// scope mapping is left intact — that's what gives consistent multi-language
// highlighting (including Objective-C, Go, Rust, etc.) out of the box.
// Canonical Atom One Dark / One Light themes from `@shikijs/themes`. Only the
// background is overridden so the code block blends into the app's `--code-block`
// surface; all token colors/scopes are kept intact for consistent multi-language
// highlighting out of the box.
const withTransparentBg = (theme: ThemeRegistrationAny): ThemeRegistrationAny => ({
...theme,
bg: "transparent",

View file

@ -378,10 +378,7 @@ function ImageContentFilterError({
export type ImageActionsProps = {
part: ImageMessagePart;
/**
* Wire to your own generation call to show a regenerate button. The button
* renders only when this is set and the part carries a `prompt`.
*/
/** Shows a regenerate button (only when set and the part has a `prompt`). */
onRegenerate?: () => void | Promise<void>;
className?: string;
};

View file

@ -157,8 +157,7 @@ const UNSAFE_SVG_RE =
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
// Strip XML declaration (<?xml ...?>) -- not needed for data URI
// rendering and can cause issues with some renderers.
// Strip XML declaration: unneeded for data URIs and breaks some renderers.
return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
}
@ -381,11 +380,10 @@ function StreamdownBlock(props: BlockProps) {
}
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
// Coalesce markdown re-parses to one per animation frame while streaming: the
// runtime notifies on every token (hundreds/sec) and the monitor can't paint
// that fast. When not streaming we return live text rather than the throttled
// state, so the final text never lags and a reused instance (parts are keyed by
// index) shows a completed message's text immediately instead of a stale frame.
// Coalesce markdown re-parses to one per frame while streaming: tokens arrive
// hundreds/sec, faster than the monitor can paint. When not streaming we return
// live text (not the throttled state) so final text never lags and a reused
// instance (parts keyed by index) shows completed text instead of a stale frame.
function useRafCoalescedText(text: string, isStreaming: boolean): string {
const [displayed, setDisplayed] = useState(text);
const pendingRef = useRef(text);
@ -408,9 +406,9 @@ function useRafCoalescedText(text: string, isStreaming: boolean): string {
}
}, [text, isStreaming]);
// Unmount cleanup. Cancel the in-flight rAF and null the handle so a
// StrictMode remount isn't gated out by a stale id. Kept separate from the
// scheduling effect so it doesn't cancel mid-stream and defeat the throttle.
// Unmount cleanup: cancel the in-flight rAF and null the handle so a
// StrictMode remount isn't gated by a stale id. Separate from the scheduling
// effect so it doesn't cancel mid-stream and defeat the throttle.
useEffect(() => {
return () => {
if (rafRef.current !== null) {

View file

@ -52,10 +52,9 @@ export const MessageTiming: FC<{
// Anthropic-only cache-write count.
const cacheWrites = custom?.contextUsage?.cacheWriteTokens ?? 0;
// Guard unphysical tok/s: llama.cpp emits predicted_ms=0 on no-op
// turns, blowing the rate up to Infinity. Require >=1 token AND a
// non-zero decode window AND a finite rate. Fast cached single-token
// responses (sub-10ms) are legitimate and must stay visible.
// Guard unphysical tok/s: llama.cpp emits predicted_ms=0 on no-op turns,
// blowing the rate up to Infinity. Require >=1 token, a non-zero decode
// window, and a finite rate. Fast cached sub-10ms responses are legit.
const hasPredicted =
(st?.predicted_n ?? 0) >= 1 && (st?.predicted_ms ?? 0) > 0;
const predictedRate =

View file

@ -32,12 +32,9 @@ export interface FolderBrowserProps {
function splitBreadcrumb(path: string): { label: string; value: string }[] {
if (!path) return [];
// Distinguish path styles BEFORE normalizing separators. On POSIX
// backslashes are valid filename characters, so we cannot blindly
// rewrite ``\`` -> ``/`` -- doing so would mangle directory names
// like ``my\backup`` into ``my/backup`` and produce breadcrumb
// values that 404 on the server. Only Windows-style absolute paths
// (drive letter, or UNC ``\\server\share``) get the conversion.
// Detect path style BEFORE normalizing: on POSIX, `\` is a valid filename
// char, so blindly rewriting `\` -> `/` mangles names like `my\backup` into
// 404ing breadcrumbs. Only Windows-style paths (drive letter, or UNC) convert.
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
const isUnc = /^\\\\/.test(path);
const isWindows = isWindowsDrive || isUnc;
@ -57,11 +54,9 @@ function splitBreadcrumb(path: string): { label: string; value: string }[] {
return parts;
}
// Windows-ish drive path (C:, D:): first segment is the drive. Use
// ``C:/`` (drive-absolute) as the crumb value so clicking the drive
// root navigates to the root of the drive rather than the
// drive-relative current working directory on that drive (``C:``
// alone resolves to ``CWD-on-C``, not ``C:\``).
// Windows drive path (C:, D:): first segment is the drive. Use `C:/` as the
// crumb value so clicking the drive root navigates to the drive root, not the
// drive-relative CWD (`C:` alone resolves to CWD-on-C, not `C:\`).
if (/^[A-Za-z]:$/.test(segments[0])) {
const driveRoot = `${segments[0]}/`;
let cur = driveRoot;
@ -102,8 +97,8 @@ export function FolderBrowser({
abortRef.current = ctrl;
setLoading(true);
setError(null);
// Forward the signal so cancelled navigation actually cancels the
// backend enumeration instead of just discarding the response.
// Forward the signal so cancelled navigation aborts the backend
// enumeration, not just the response.
browseFolders(target, hidden, ctrl.signal)
.then((res) => {
if (ctrl.signal.aborted) return;
@ -112,17 +107,13 @@ export function FolderBrowser({
})
.catch((err) => {
if (ctrl.signal.aborted) return;
// Surface the error, but if the very first request (typically
// a typo'd or denylisted ``initialPath``) fails AND the
// browser is empty (no ``data`` to render against), fall
// back to the user's HOME so the modal is navigable instead
// of an irrecoverable dead end.
// Surface the error; if the first request (e.g. a bad initialPath)
// fails, fall back to HOME so the modal stays navigable.
const message = err instanceof Error ? err.message : String(err);
setError(message);
if (opts?.fallbackOnError && target !== undefined) {
// Re-issue without a target -> backend defaults to HOME.
// Don't recurse if HOME itself fails (paranoia: shouldn't
// happen since the sandbox allowlist always includes HOME).
// Don't recurse if HOME itself fails (allowlist always has HOME).
queueMicrotask(() => navigate(undefined, hidden));
}
})
@ -133,15 +124,13 @@ export function FolderBrowser({
[],
);
// Fetch when the dialog opens. Only re-run when the dialog transitions
// closed -> open; subsequent navigation is driven by `navigate()` so we
// don't want `path` in the dependency list here.
// Fetch only on closed -> open; later navigation is driven by `navigate()`,
// so `path` is deliberately kept out of the dependency list.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (!open) return;
// ``fallbackOnError``: if the user-supplied ``initialPath`` is bad
// (typo, denylisted, deleted) we recover into HOME instead of
// showing an empty modal with no breadcrumbs/entries.
// fallbackOnError: recover into HOME if initialPath is bad, rather than
// showing an empty modal.
navigate(initialPath, showHidden, { fallbackOnError: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);

View file

@ -62,7 +62,7 @@ function dedupe(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
/** Normalize a string for fuzzy search: lowercase, strip separators. */
/** Lowercase and strip separators for fuzzy search. */
function normalizeForSearch(s: string): string {
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
}
@ -296,14 +296,14 @@ function GgufVariantExpander({
[gpuGb, gpuBudgetGb, totalBudgetGb],
);
// If the backend-recommended variant is OOM, pick the largest fitting
// variant instead; if all are OOM, recommend the smallest one.
// If the recommended variant is OOM, pick the largest fitting one;
// if all are OOM, recommend the smallest.
const effectiveRecommended = useMemo(() => {
if (!variants || !gpuGb || gpuGb <= 0) return defaultVariant;
const defaultV = variants.find((v) => v.quant === defaultVariant);
if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
return defaultVariant;
// Default is OOM -- pick largest non-OOM variant (best quality that fits)
// Largest non-OOM variant (best quality that fits)
const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
if (fitting.length > 0) {
fitting.sort((a, b) => b.size_bytes - a.size_bytes);
@ -460,8 +460,8 @@ function isGgufRepo(id: string, hintedIsGguf?: boolean): boolean {
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | undefined {
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
const name = id.split("/").pop() ?? id;
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : undefined;
}
@ -504,11 +504,10 @@ export function HubModelPicker({
const { results, isLoading, isLoadingMore, fetchMore } =
useHfModelSearch(debouncedQuery);
// Sets of lowercased repo ids that the store or HF search have
// confirmed are GGUF. Absence means "no hint" and lets hasGgufSuffix
// take over as fallback, rather than conflating unknown with known-
// not-GGUF. Keys are lowercased so that store IDs and HF search IDs
// that differ only by casing still match the same hint.
// Lowercased repo ids confirmed GGUF by the store or HF search.
// Absence means "no hint" -> hasGgufSuffix is the fallback (don't
// conflate unknown with known-not-GGUF). Lowercased so store and HF
// IDs differing only by casing match the same hint.
const modelGgufIds = useMemo(() => {
const ids = new Set<string>();
for (const model of models) {
@ -538,8 +537,8 @@ export function HubModelPicker({
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
// Cached (already downloaded) repos -- use module-level cache so
// re-mounting the popover does not flash an empty "Downloaded" section.
// Cached (downloaded) repos -- module-level cache avoids flashing an
// empty "Downloaded" section when the popover re-mounts.
const [cachedGguf, setCachedGguf] =
useState<CachedGgufRepo[]>(_cachedGgufCache);
const [cachedModels, setCachedModels] =
@ -548,8 +547,7 @@ export function HubModelPicker({
_cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
const [cachedReady, setCachedReady] = useState(alreadyCached);
// LM Studio local models -- module-level cache so re-mounting the
// popover does not flash an empty section (same pattern as GGUF/models).
// LM Studio local models -- module-level cache, same pattern as above.
const [lmStudioModels, setLmStudioModels] =
useState<LocalModelInfo[]>(_lmStudioCache);
const [customFolderModels, setCustomFolderModels] =
@ -589,24 +587,20 @@ export function HubModelPicker({
}, []);
const handleAddFolder = useCallback(async (overridePath?: string) => {
// Accept an explicit path so the folder browser can submit the
// chosen path in the same tick it calls `setFolderInput`; reading
// `folderInput` alone would race the state update.
// Explicit path lets the folder browser submit in the same tick it
// calls `setFolderInput`; reading `folderInput` would race the update.
const raw = overridePath !== undefined ? overridePath : folderInput;
const trimmed = raw.trim();
if (!trimmed || folderLoading) return;
setFolderError(null);
setFolderLoading(true);
// True when the request originated from the folder browser's
// ``onSelect`` (one-click "Use this folder"). In that flow the
// typed-input panel is closed, so the inline ``folderError``
// paragraph is invisible. Surface failures via toast instead so
// the action doesn't appear to silently no-op when the backend
// rejects (denylisted path, sandbox 403, etc.).
// From the folder browser's one-click "Use this folder": the typed-
// input panel is closed, so the inline folderError is invisible.
// Surface failures (denylisted path, sandbox 403, etc.) via toast.
const fromBrowser = overridePath !== undefined;
try {
const created = await addScanFolder(trimmed);
// Backend returns existing row for duplicates, so deduplicate
// Backend returns the existing row for duplicates, so dedupe.
const next = _scanFoldersCache.some((f) => f.id === created.id || f.path === created.path)
? _scanFoldersCache
: [..._scanFoldersCache, created];
@ -632,7 +626,7 @@ export function HubModelPicker({
const handleRemoveFolder = useCallback(async (id: number) => {
try {
await removeScanFolder(id);
// Optimistic update so the folder disappears immediately
// Optimistic: drop it immediately.
const next = _scanFoldersCache.filter((f) => f.id !== id);
_scanFoldersCache = next;
setScanFolders(next);
@ -662,18 +656,17 @@ export function HubModelPicker({
}, [refreshLocalModelsList]);
useEffect(() => {
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
// Always refresh LM Studio + custom folder models (not gated by alreadyCached).
refreshLocalModelsList();
refreshScanFolders();
listRecommendedFolders()
.then(setRecommendedFolders)
.catch(() => {});
// Always refetch cached GGUF/model lists. The module-level caches give
// an instant render with stale data (no spinner flash), but newly
// downloaded repos won't appear unless we re-hit the backend on every
// mount. Initial state already has cachedReady=alreadyCached, so the
// background refresh is invisible when we already had data.
// Always refetch cached GGUF/model lists. The module-level caches render
// instantly with stale data (no spinner flash), but newly downloaded
// repos need a fresh backend hit. cachedReady=alreadyCached initially,
// so the background refresh is invisible when we already had data.
let done = 0;
const check = () => {
if (++done >= 2) setCachedReady(true);
@ -694,8 +687,8 @@ export function HubModelPicker({
.finally(check);
}, [refreshLocalModelsList, refreshScanFolders]);
// Deduplicate: don't show downloaded models in the recommended list.
// Compare case-insensitively since HF cache lowercases repo IDs.
// Hide downloaded models from the recommended list. Case-insensitive
// since the HF cache lowercases repo IDs.
const downloadedSet = useMemo(() => {
const s = new Set<string>();
for (const c of cachedGguf) s.add(c.repo_id.toLowerCase());
@ -756,10 +749,9 @@ export function HubModelPicker({
return recommendedIds.filter((id) => normalizeForSearch(id).includes(q));
}, [showHfSection, debouncedQuery, recommendedIds]);
// Fetch VRAM info for visible models, plus any models surfaced by a search
// query so that filtered recommended models also show VRAM badges.
// Skip GGUF repos: they have no safetensors metadata and the render layer
// already shows a static "GGUF" badge instead of VRAM data.
// VRAM info for visible models plus any surfaced by a search query, so
// filtered recommended models also show VRAM badges. Skip GGUF repos:
// no safetensors metadata, and the render layer shows a "GGUF" badge.
const idsForVram = useMemo(() => {
const ids = showHfSection
? [...new Set([...visibleRecommendedIds, ...filteredRecommendedIds])]
@ -851,9 +843,8 @@ export function HubModelPicker({
);
// Sentinel + IntersectionObserver for recommended infinite scroll.
// We disconnect after each fire so the observer doesn't loop while
// React re-renders; the effect re-creates it on the next page.
// Uses a callback ref for the sentinel so we detect mount/unmount reliably.
// Disconnect after each fire so it doesn't loop during re-render; the
// effect re-creates it next page. Callback ref detects mount/unmount.
const [recommendedSentinel, setRecommendedSentinel] =
useState<HTMLDivElement | null>(null);
const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => {
@ -872,7 +863,7 @@ export function HubModelPicker({
},
{ threshold: 0, root },
);
// Small delay so the browser finishes layout after the previous page render
// Small delay so layout settles after the previous page render.
const timer = setTimeout(() => obs.observe(recommendedSentinel), 100);
return () => {
clearTimeout(timer);
@ -1184,9 +1175,8 @@ export function HubModelPicker({
onSelect={(picked) => {
setFolderInput(picked);
setFolderError(null);
// One-click UX: the "Use this folder" button submits
// the scan folder directly. Pass the path explicitly
// because `folderInput` state hasn't flushed yet.
// Pass the path explicitly: `folderInput` state hasn't
// flushed yet when "Use this folder" submits.
void handleAddFolder(picked);
}}
/>

View file

@ -358,18 +358,18 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
}
}, [isReasoningStreaming]);
// Reset dismissed flag when a new stream starts
// Reset dismissed flag on new stream.
useEffect(() => {
if (isReasoningStreaming) {
setDismissedWhileStreaming(false);
}
}, [isReasoningStreaming]);
// Derived: open during streaming (unless dismissed), or if user manually opened after
// Open while streaming (unless dismissed), or once manually opened.
const isOpen = (isReasoningStreaming && !dismissedWhileStreaming) || manualOpen;
const variant = isOpen ? "outline" : "ghost";
// Allow closing during streaming (matches ChatGPT)
// Allow closing during streaming (matches ChatGPT).
const handleOpenChange = useCallback(
(open: boolean) => {
if (isReasoningStreaming) {

View file

@ -128,9 +128,8 @@ function Source({
interface SourceData {
/**
* Stable per-citation key. Two Anthropic document citations into
* different spans of the same source share a ``url``, so React keys
* on ``id`` to keep each footnote distinct.
* Stable per-citation key. Two Anthropic citations into different spans of
* the same source share a `url`, so React keys on `id` to keep them distinct.
*/
id: string;
url: string;
@ -185,7 +184,6 @@ const SourcesGroup: FC = () => {
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false);
// Extract source parts from the message
const sources: SourceData[] = [];
if (message.content) {
for (const part of message.content) {
@ -220,7 +218,6 @@ const SourcesGroup: FC = () => {
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) return;
// Find the top of the first child as baseline
const firstTop = children[0].offsetTop;
let rowCount = 1;
let prevTop = firstTop;
@ -263,17 +260,12 @@ const SourcesGroup: FC = () => {
return (
<div className="relative mt-2 mb-3">
{/* Hidden measurement container. Renders all badges off-screen so we
can read each child's offsetTop and decide how many fit in two
rows. Wrapped in an absolute, h-0, overflow-hidden box so the
measurement pills do NOT contribute to the viewport's scrollable
overflow region. Without this clip, every hidden source row
adds ~30px to scrollHeight, producing a phantom empty scroll
area below the message: visible to users as unbounded blank
space below the assistant action bar. The inner div still
flex-wraps its children for measurement; offsetTop reads
correctly because the wrapper is positioned (absolute) and the
children's offsetTop is measured relative to it. */}
{/* Hidden measurement container: renders all badges off-screen to read
each child's offsetTop and decide how many fit in two rows. The
absolute/h-0/overflow-hidden wrapper clips the pills so they don't add
to scrollHeight (~30px per row) and create a phantom empty scroll area
below the message. offsetTop reads correctly because the wrapper is
positioned (absolute) and the flex-wrapped children measure against it. */}
<div
aria-hidden
className="absolute pointer-events-none overflow-hidden h-0 w-full left-0 top-0"

View file

@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Shared aria-labels for the Think pill (toggle + effort dropdown) so both
// controls use the same pre-load / unsupported-model wording.
// Shared aria-labels for the Think pill toggle + effort dropdown, so both
// use the same pre-load / unsupported-model wording.
export interface ThinkToggleState {
reasoningLockedOn: boolean;
@ -27,8 +27,7 @@ export interface ThinkEffortState {
reasoningEffort: string;
}
// Locked-on isn't special-cased: the effort dropdown stays interactive
// (users can still pick an effort level).
// Locked-on isn't special-cased: the effort dropdown stays interactive.
export function thinkEffortAriaLabel(state: ThinkEffortState): string {
if (!state.modelLoaded) return "Thinking (model not loaded)";
if (state.reasoningDisabled)

View file

@ -124,20 +124,18 @@ import {
useState,
} from "react";
// True while a file is dragged anywhere over the chat page (not just the
// composer), so the composer can show its "Drop files here" affordance.
// True while a file is dragged anywhere over the chat page, so the composer
// can show its "Drop files here" affordance.
const PageDragContext = createContext(false);
// Gap (px) between the last message and the floating composer. The bottom
// spacer tracks composer height plus this gap so the chat can always be
// scrolled fully above the composer.
// Gap (px) between last message and floating composer; bottom spacer tracks
// composer height plus this gap so chat can scroll fully above the composer.
const COMPOSER_SCROLL_GAP_PX = 24;
// The scroll-to-bottom footer sits 10px below the spacer top.
const FOOTER_GAP_BELOW_SPACER_PX = 10;
// Composer shrinks this soon after a run start (send clears the chips)
// apply immediately: the run-start pin owns the bottom, so the clamp is
// the intended glide. Covers instant responses where isRunning is
// already false by the time the dock resize is observed.
// Window after a run start during which composer shrinks apply immediately:
// the run-start pin owns the bottom, so the clamp is the intended glide.
// Covers instant responses where isRunning is already false by resize time.
const RUN_SHRINK_WINDOW_MS = 1000;
export const Thread: FC<{
@ -145,10 +143,9 @@ export const Thread: FC<{
hideWelcome?: boolean;
targetThreadId?: string;
}> = ({ hideComposer, hideWelcome, targetThreadId }) => {
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
// to prevent the streaming-mutation race that makes the viewport snap
// back to the bottom while the user is scrolling up (see the hook for
// the full explanation).
// Intent-aware autoscroll replaces assistant-ui's built-in autoscroll to
// prevent the streaming-mutation race that snaps the viewport back to the
// bottom while the user scrolls up (see the hook for the full explanation).
const { ref: viewportRef, context: autoScrollContext } =
useIntentAwareAutoScroll();
@ -167,10 +164,9 @@ export const Thread: FC<{
? null
: composerHeight + COMPOSER_SCROLL_GAP_PX - FOOTER_GAP_BELOW_SPACER_PX;
// The viewport element is owned by the autoscroll hook; mirror it
// locally for the spacer clamp math below. State, not a ref: the keyed
// provider below remounts the viewport on thread switches, and the
// scroll listener effect must re-attach to the new element.
// Viewport element is owned by the autoscroll hook; mirror it locally for
// the spacer clamp math. State, not a ref: the keyed provider remounts the
// viewport on thread switches and the scroll listener must re-attach.
const [viewportEl, setViewportEl] = useState<HTMLElement | null>(null);
const composedViewportRef = useCallback(
(node: HTMLElement | null) => {
@ -180,13 +176,13 @@ export const Thread: FC<{
[viewportRef],
);
// Bottom spacer sizing. Invariant: the chat never moves on its own when
// the composer resizes.
// - Grow (attachment added, multiline input): grow the spacer at once.
// Growth below the scroll position is invisible and only adds room.
// Bottom spacer sizing. Invariant: chat never moves on its own on composer
// resize.
// - Grow (attachment added, multiline): grow at once; growth below the
// scroll position is invisible and only adds room.
// - Shrink (attachment removed): shrinking scrollHeight near the bottom
// would clamp scrollTop and yank the chat down. Defer the shrink until
// it is invisible (user scrolled up) or a bottom-pinning moment.
// clamps scrollTop and yanks the chat down. Defer until invisible (user
// scrolled up) or a bottom-pinning moment.
// Applied imperatively so a remounted spacer can be sized from refs even
// when composerHeight did not change (e.g. thread switch).
const spacerElRef = useRef<HTMLDivElement | null>(null);
@ -214,8 +210,8 @@ export const Thread: FC<{
const spacerRef = useCallback(
(node: HTMLDivElement | null) => {
spacerElRef.current = node;
// Fresh mounts (thread switch, first message) start at the desired
// size; deferral state from a previous mount is moot.
// Fresh mounts (thread switch, first message) start at desired size;
// deferral state from a previous mount is moot.
const desired = desiredSpacerPxRef.current;
if (node && desired != null) {
applySpacerPx(desired);
@ -249,7 +245,7 @@ export const Thread: FC<{
aui.thread().getState().isRunning ||
performance.now() - runStartAtRef.current < RUN_SHRINK_WINDOW_MS;
// At the bottom the shrink only drops blank spacer, so apply it now
// instead of stranding dead space until the next pin.
// rather than strand dead space until the next pin.
if (
runOwnsBottom ||
distance >= applied - desired ||
@ -260,20 +256,20 @@ export const Thread: FC<{
// else: deferred; released on scroll or a bottom-pinning event.
}
if (prev != null && composerHeight > prev) {
// The chat is now above the new bottom. Detach as if the user had
// scrolled up so no later signal re-pins and shoves the chat up.
// Scrolling back down re-attaches; explicit pins still work.
// Mid-run growth comes from tool-status rows, not the user, and
// detaching then would break streaming autoscroll, so skip it.
// Chat is now above the new bottom. Detach as if the user scrolled up
// so no later signal re-pins and shoves the chat up (scrolling back
// down re-attaches; explicit pins still work). Skip mid-run: that
// growth is tool-status rows, not the user, and detaching would break
// streaming autoscroll.
if (!aui.thread().getState().isRunning) {
autoScrollContext.detachFromBottom();
}
}
}, [composerHeight, hideComposer, autoScrollContext, aui, applySpacerPx, viewportEl]);
// Drop deferred spacer excess as soon as the user has scrolled far
// enough above the bottom that the shrink cannot clamp scrollTop.
// Keyed on viewportEl so the listener follows viewport remounts.
// Drop deferred spacer excess once the user has scrolled far enough above
// the bottom that the shrink cannot clamp scrollTop. Keyed on viewportEl
// so the listener follows viewport remounts.
useEffect(() => {
const el = viewportEl;
if (!el) {
@ -303,10 +299,10 @@ export const Thread: FC<{
useAuiEvent("thread.initialize", releaseSpacerExcess);
useAuiEvent("threadListItem.switchedTo", releaseSpacerExcess);
// Page-wide drag-and-drop: dropping a file anywhere on the chat page (not
// just on the composer) attaches it and shows the composer drop affordance.
// The composer's own dropzone still handles drops on the box itself; its
// handler calls preventDefault, so the page handler skips them (no double-add).
// Page-wide drag-and-drop: dropping a file anywhere on the chat page
// attaches it and shows the composer drop affordance. The composer's own
// dropzone handles drops on the box and calls preventDefault, so the page
// handler skips them (no double-add).
const [pageDragging, setPageDragging] = useState(false);
const dragDepth = useRef(0);
const hasFiles = (e: ReactDragEvent) =>
@ -332,8 +328,8 @@ export const Thread: FC<{
// Compare panes hide this composer and use the shared composer's own
// dropzone, so don't capture drops into a hidden composer here.
if (hideComposer) return;
// Drops on the composer box are handled by its own dropzone, which calls
// preventDefault; skip those here so the file isn't added twice.
// Drops on the composer box are handled by its dropzone (preventDefault);
// skip those here so the file isn't added twice.
if (e.defaultPrevented) return;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
@ -391,10 +387,9 @@ export const Thread: FC<{
}}
/>
{/* Bottom slack so the last message has breathing room above the
sticky scroll-to-bottom button (and the floating composer in
single mode). Without this, content would butt against the
sticky footer and feel cramped. */}
{/* Bottom slack so the last message has room above the sticky
scroll-to-bottom button (and floating composer in single mode),
instead of butting against the footer. */}
<AuiIf condition={({ thread }) => hideWelcome || !thread.isEmpty}>
<div
ref={spacerRef}
@ -562,8 +557,8 @@ const ThreadComposerDock: FC<{
}> = ({ disabled, threadId, onHeightChange }) => {
const { overlay } = useGeneratedImageOverlay();
// Report the dock's rendered height so the viewport can reserve matching
// scroll space when attachments or multiline input grow the composer.
// Report dock height so the viewport reserves matching scroll space when
// attachments or multiline input grow the composer.
const dockRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = dockRef.current;
@ -608,12 +603,12 @@ const ThreadComposerDock: FC<{
};
const ThreadScrollToBottom: FC = () => {
// State and action both come from our IntentAwareScrollProvider (scoped
// per Thread, so compare panes are independent). We deliberately
// avoid `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to
// stay off assistant-ui's internal autoscroll path — see the hook
// for why. The button stays mounted and toggles via CSS; unmounting
// would trip the hook's MutationObserver as a content change.
// State and action both come from our IntentAwareScrollProvider (per-Thread
// scope, so compare panes are independent). We avoid
// `ThreadPrimitive.ScrollToBottom` + `useThreadViewport` to stay off
// assistant-ui's internal autoscroll path (see the hook). The button stays
// mounted and toggles via CSS; unmounting would trip the hook's
// MutationObserver as a content change.
const isAtBottom = useIsThreadAtBottom();
const scrollToBottom = useScrollThreadToBottom();
return (
@ -634,9 +629,9 @@ const ThreadScrollToBottom: FC = () => {
const pickRandom = <T,>(arr: T[]): T =>
arr[Math.floor(Math.random() * arr.length)];
// Each greeting carries the sloth picture that best fits it, so a given line
// always shows the same mascot. Greeting varies by local time; name-bearing
// lines drop the name when none is set.
// Each greeting carries its matching sloth picture so a line always shows the
// same mascot. Greeting varies by local time; name-bearing lines drop the
// name when none is set.
type Welcome = { text: string; sloth: string };
const DEFAULT_WELCOME: Welcome = {
text: "Whats on your mind today?",
@ -645,9 +640,8 @@ const DEFAULT_WELCOME: Welcome = {
function buildWelcome(hour: number, name: string): Welcome {
const g = (text: string, sloth: string): Welcome => ({ text, sloth });
// Use the name on roughly a third of the lines per time of day: only the
// direct salutations where it reads most naturally. Everything else stays
// name-free so the greeting doesn't feel repetitive.
// Use the name on ~a third of lines (only direct salutations where it reads
// naturally); the rest stay name-free so greetings don't feel repetitive.
const base: Welcome[] = [
g(name ? `Good to see you, ${name}.` : "Good to see you.", "large sloth wave.png"),
g("Ready when you are.", "large sloth thumbs.png"),
@ -696,7 +690,7 @@ const ThreadWelcome: FC<{
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-start pt-[28vh]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-9 px-4">
{/* Center the whole greeting (sloth + title) over the composer. */}
{/* Center the greeting (sloth + title) over the composer. */}
<div className="flex flex-row items-center justify-center gap-[15px]">
<img
src={currentEmojiSrc}
@ -784,8 +778,8 @@ const Composer: FC<{
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
// With more than 4 pills showing, collapse them to icons only to cut clutter.
// Search and Code always show; Images, Canvas and MCP are conditional.
// More than 4 pills: collapse to icons only. Search and Code always show;
// Images, Canvas and MCP are conditional.
const pillsCompact =
2 +
(supportsBuiltinImageGeneration ? 1 : 0) +
@ -802,7 +796,7 @@ const Composer: FC<{
// Expand only once the input wraps to a second line, not on first keystroke.
// Latch until cleared so it can't flip-flop at the wrap boundary.
const inputRef = useRef<HTMLTextAreaElement>(null);
// Cache line metrics so getComputedStyle runs once, not on every keystroke.
// Cache line metrics so getComputedStyle runs once, not per keystroke.
const lineMetricsRef = useRef<{ lineHeight: number; padding: number } | null>(
null,
);
@ -844,8 +838,8 @@ const Composer: FC<{
const referenceThreadId = threadId ?? activeThreadId ?? null;
const hasSendableContent =
composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
// Two-row layout shows once the input wraps or a tool is on. Tools pre-select
// before a model loads, so an active toggle expands the composer either way.
// Two-row layout shows once the input wraps or a tool is on. Tools can
// pre-select before a model loads, so an active toggle expands it either way.
const composerExpanded =
isMultiline ||
hasAttachments ||
@ -857,7 +851,7 @@ const Composer: FC<{
mcpEnabledForChat;
// react-textarea-autosize re-measures only on value change or window resize,
// not on the width swap from expanding, so it keeps the taller height and
// leaves a stray blank row. Nudge a resize whenever the input width changes.
// leaves a stray blank row. Nudge a resize whenever input width changes.
useEffect(() => {
const el = inputRef.current;
if (!el || typeof ResizeObserver === "undefined") {
@ -872,8 +866,8 @@ const Composer: FC<{
return;
}
lastWidth = width;
// Re-measure after layout settles. An immediate dispatch races autosize's
// own measurement (stale pre-expand width); 0ms + 64ms wins it, no flash.
// Re-measure after layout settles. An immediate dispatch races
// autosize's own measurement (stale pre-expand width); 0ms + 64ms wins.
while (pending.length) {
clearTimeout(pending.pop());
}
@ -893,8 +887,8 @@ const Composer: FC<{
observer.disconnect();
};
}, []);
// Docked composer opens upward; the centered welcome composer opens downward
// by default and only flips up via collision detection when it would not fit.
// Docked composer opens upward; the welcome composer opens downward by
// default and only flips up via collision detection when it won't fit.
const effectiveMenuSide = menuSide ?? "bottom";
const shouldBlockSend = useCallback(
() =>
@ -1027,17 +1021,17 @@ const Composer: FC<{
onSubmit={handleSubmit}
>
{isTauri ? (
// Phase 1 native model drops own Tauri local-path drops. Restore browser
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
// Phase 1 native model owns Tauri local-path drops. Restore browser
// attachment drops in Tauri once Phase 1d adds token bridging.
<div className="aui-composer-attachment-dropzone unsloth-composer-surface">
{composerContent}
</div>
) : (
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative">
{composerContent}
{/* Gemini-style drop affordance: shown only while a file is dragged
over the composer. Absolutely positioned + pointer-events-none so
the dashed outline adds no layout shift and the drop still lands. */}
{/* Gemini-style drop affordance, shown while a file is dragged over
the composer. Absolute + pointer-events-none so the outline adds
no layout shift and the drop still lands. */}
<div
className={cn(
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[32px] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
@ -1063,13 +1057,12 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Fallback timeout for stuck IME composition. When Chrome on Windows talks
// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after
// the candidate is committed, so `composingRef` stays true and Send stays
// disabled. Every compositionupdate / non-composing input resets the timer;
// only a true gap-after-commit lets it fire. 2500ms is well above a normal
// candidate-window pause but short enough to recover before the user
// notices the Send button is stuck.
// Fallback timeout for stuck IME composition. With Chrome on Windows against
// a WSL-hosted Studio (issue #5546), `compositionend` never fires after the
// candidate commits, so `composingRef` stays true and Send stays disabled.
// Every compositionupdate / non-composing input resets the timer; only a true
// gap-after-commit lets it fire. 2500ms is above a normal candidate-window
// pause but short enough to recover before the user notices Send is stuck.
const IME_STUCK_TIMEOUT_MS = 2500;
function useImeComposerInputHandlers() {
@ -1153,13 +1146,12 @@ function useImeComposerInputHandlers() {
);
// If the watchdog cleared the composing flags during a long candidate-window
// pause, a subsequent IME keypress (browser-side isComposing=true / IME
// keyCode 229) would otherwise reach handleSubmit with composingRef=false
// and submit the preedit text. Re-arm composingRef synchronously from the
// native event so the form-submit gate keeps blocking until compositionend.
// Re-arm the watchdog at the same time — otherwise the WSL+Chrome path
// this PR targets (no compositionend, no follow-up input event) would
// leave composingRef pinned true indefinitely and Send blocked again.
// pause, a later IME keypress (isComposing=true / keyCode 229) would reach
// handleSubmit with composingRef=false and submit the preedit text. Re-arm
// composingRef synchronously from the native event so the submit gate keeps
// blocking until compositionend. Re-arm the watchdog too, or the WSL+Chrome
// path (no compositionend, no follow-up input) would pin composingRef true
// forever and block Send again.
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
@ -1207,8 +1199,8 @@ const ComposerAudioMenuItem: FC = () => {
);
// Build the input on document.body, not in the menu: selecting the item
// closes the dropdown, which would unmount a menu-rendered input before the
// OS picker returns and drop the file.
// closes the dropdown, unmounting a menu-rendered input before the OS picker
// returns and dropping the file.
const pickAudio = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
@ -1573,9 +1565,8 @@ const WebSearchToggle: FC = () => {
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
// External providers (OpenAI today) expose a server-side web_search tool
// even when the local tool runtime is unavailable — gate the Search pill
// on either source so it lights up on external models too. Mirror of
// shared-composer's searchDisabled.
// even without the local tool runtime; gate the pill on either source so it
// lights up on external models too. Mirror of shared-composer's searchDisabled.
const supportsBuiltinWebSearch = useChatRuntimeStore(
(s) => s.supportsBuiltinWebSearch,
);
@ -1594,7 +1585,7 @@ const WebSearchToggle: FC = () => {
: undefined;
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
// Disable only when a loaded model lacks the capability; with no model the
// tool can still be pre-selected and reflected, matching the + menu.
// tool can still be pre-selected, matching the + menu.
const disabled = modelLoaded && !(supportsTools || supportsBuiltinWebSearch);
return (
@ -1605,9 +1596,8 @@ const WebSearchToggle: FC = () => {
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled (see
// https://platform.kimi.ai/docs/guide/use-web-search). Keep
// the two pills mutually exclusive so the visible state always
// matches what the backend ends up sending.
// https://platform.kimi.ai/docs/guide/use-web-search). Keep the two
// pills mutually exclusive so visible state matches what's sent.
if (isKimiExternal) {
setReasoningEnabled(!next, { persist: false });
applyQwenThinkingParams(!next);
@ -1630,18 +1620,17 @@ const CodeToolsToggle: FC = () => {
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
// External providers have no local tool runtime, but Anthropic's
// Claude 4.x dispatches code_execution_20250825 server-side. The
// chat-page resolver stashes that capability in the runtime store
// (next to supportsBuiltinWebSearch). Mirror of shared-composer's
// codeDisabled so this pill lights up in active threads too.
// External providers have no local tool runtime, but Anthropic's Claude 4.x
// dispatches code_execution_20250825 server-side; the chat-page resolver
// stashes that capability in the runtime store (next to
// supportsBuiltinWebSearch). Mirror of shared-composer's codeDisabled.
const supportsBuiltinCodeExecution = useChatRuntimeStore(
(s) => s.supportsBuiltinCodeExecution,
);
const codeToolsEnabled = useChatRuntimeStore((s) => s.codeToolsEnabled);
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
// Disable only when a loaded model lacks the capability; with no model the
// tool can still be pre-selected and reflected, matching the + menu.
// tool can still be pre-selected, matching the + menu.
const disabled = modelLoaded && !(supportsTools || supportsBuiltinCodeExecution);
return (
@ -1672,9 +1661,8 @@ const ImagesToggle: FC = () => {
(s) => !!s.params.checkpoint && !s.modelLoading,
);
// OpenAI cloud Responses-API models advertise image_generation as a
// server-side tool; no local runtime fallback exists. Mirror of
// shared-composer's imageDisabled / showImagePill so the in-thread
// composer surfaces the same control as the empty-state composer.
// server-side tool; no local runtime fallback. Mirror of shared-composer's
// imageDisabled / showImagePill so this composer matches the empty state.
const supportsBuiltinImageGeneration = useChatRuntimeStore(
(s) => s.supportsBuiltinImageGeneration,
);
@ -1755,10 +1743,9 @@ const ToolStatusDisplay: FC = () => {
setElapsed(0);
// Debounce badge visibility by 300ms when the badge is not
// already on screen. Once visible from a prior tool, consecutive
// tools show immediately so the badge does not flicker. Fast
// tool calls that all complete under 300ms never show the badge.
// Debounce visibility by 300ms when the badge isn't already on screen.
// Once visible from a prior tool, later tools show immediately so it
// doesn't flicker; tool calls under 300ms never show the badge.
let showTimer: ReturnType<typeof setTimeout> | undefined;
if (!visibleRef.current) {
showTimer = setTimeout(() => setVisible(true), 300);
@ -1790,8 +1777,8 @@ const ToolStatusDisplay: FC = () => {
</div>
);
};
// Plus menu: attachment and workflow actions. Opens downward in the centered
// welcome composer; the docked composer passes side="top" to open upward.
// Plus menu: attachment and workflow actions. Opens downward in the welcome
// composer; the docked composer passes side="top" to open upward.
const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
side = "bottom",
}) => {
@ -1806,7 +1793,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const setMcpEnabledForChat = useChatRuntimeStore(
(s) => s.setMcpEnabledForChat,
);
// Capability gating, mirroring the visible pills so menu and pills agree on
// Capability gating mirrors the visible pills so menu and pills agree on
// what a loaded model supports (a tool the backend drops must not look on).
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
@ -1889,8 +1876,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
sideOffset={2}
avoidCollisions={true}
className="unsloth-plus-menu w-[212px]"
// Don't refocus the + on close; the restored focus showed a stray
// focus-visible ring.
// Don't refocus the + on close; restored focus showed a stray ring.
onCloseAutoFocus={(event) => event.preventDefault()}
>
<ComposerPrimitive.AddAttachment asChild={true}>

View file

@ -6,11 +6,7 @@
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Spinner shown while a tool call is running: a clean circular arc with a
* rounded cap (lucide Loader2 / LoaderCircle), animated. Inherits the current
* text color so it matches the surrounding font.
*/
/** Spinner shown while a tool call runs. Inherits text color to match its surroundings. */
export function ToolCallSpinner({ className }: { className?: string }) {
return (
<Loader2Icon

View file

@ -217,8 +217,8 @@ const ToolGroupImpl: FC<
),
);
// Single tool calls and artifacts render directly so cards never hide inside
// a collapsed tool group.
// Render single tool calls and artifacts directly so cards never hide in a
// collapsed group.
if (toolCount <= 1 || containsArtifactTool) {
return <>{children}</>;
}

View file

@ -23,12 +23,9 @@ import {
} from "./tool-fallback";
/**
* Renders the synthetic `_toolEvent` chunks emitted by
* `_stream_anthropic` when Anthropic's `code_execution_20250825` tool
* fires. The backend collapses Anthropic's two sub-tools
* (`bash_code_execution`, `text_editor_code_execution`) into a single
* `tool_name: "code_execution"`, with `arguments.kind` ("bash" or
* "text_editor") and a per-kind argument shape:
* Renders synthetic `_toolEvent` chunks from `_stream_anthropic` for the
* `code_execution_20250825` tool. The backend collapses Anthropic's two
* sub-tools into `tool_name: "code_execution"` with `arguments.kind`:
*
* kind=bash: { command: "<shell command>" }
* kind=text_editor: { command: "view"|"create"|"str_replace", path, ... }
@ -141,9 +138,8 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
completedLabel = commandLabel ? `Ran \`${commandLabel}\`` : "Ran command";
}
// Collapse the card once the model has resumed streaming prose after
// the tool call. Mirrors WebSearchToolUI's behavior so the tool-card
// doesn't crowd the final answer once the run is done.
// Collapse the card once the model resumes streaming prose after the tool
// call (mirrors WebSearchToolUI) so it doesn't crowd the final answer.
const hasText = useAuiState(({ message }) =>
message.content.some(
(p) =>

View file

@ -21,26 +21,14 @@ import {
/**
* Renders the synthetic `_toolEvent` chunks emitted by
* `_stream_openai_responses` when OpenAI's Responses-API
* `image_generation` tool fires. The backend stashes the base64
* PNG/WebP/JPEG (the gpt-image backbone output) on an `image_b64`
* field of the tool_end event so the JSON result stays small, and the
* adapter repackages it into a structured `result` shape:
*
* {
* image_b64: string,
* image_mime: string, // e.g. "image/png"
* size?: string, // "1024x1024" etc
* quality?: string,
* background?: string,
* }
*
* The corresponding `tool_start` carries the prompt as
* `args.prompt` (after gpt-image's revision pass) plus `args.kind:
* "image"`. Without this component the generic ToolFallback would
* print the prompt as JSON args text with an empty Result block --
* which is exactly the "no image" symptom users hit before this UI
* landed.
* `_stream_openai_responses` when OpenAI's Responses-API `image_generation`
* tool fires. The backend stashes the base64 image on `image_b64` of the
* tool_end event (keeping the JSON small); the adapter repackages it into a
* structured `result` (image_b64, image_mime e.g. "image/png", size? e.g.
* "1024x1024", quality?, background?).
* The `tool_start` carries the revised prompt as `args.prompt` plus
* `args.kind: "image"`. Without this, ToolFallback would print the prompt as
* JSON with an empty Result block (the "no image" symptom).
*/
interface ImageGenerationArgs {
prompt?: string;

View file

@ -72,7 +72,7 @@ function CopyBtn({ text }: { text: string }) {
);
}
/** Render code with syntax highlighting via Streamdown + shiki. No extra borders — inherits parent container. */
/** Syntax-highlighted code via Streamdown + shiki; inherits parent container. */
function HighlightedCode({ code: source, language }: { code: string; language: string }) {
const markdown = useMemo(
() => `\`\`\`${language}\n${truncate(source)}\n\`\`\``,

View file

@ -17,8 +17,8 @@ import { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo, useEffect } from "react";
// Context7 assistant-ui docs: tool UIs can read streaming args via
// useToolArgsStatus, so render_html does not need to wait for tool completion.
// Per Context7 assistant-ui docs: tool UIs read streaming args via
// useToolArgsStatus, so render_html need not wait for tool completion.
type RenderHtmlArgs = Record<string, unknown> & {
code?: string;
title?: string;
@ -41,9 +41,8 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
const isRunning = status?.type === "running";
const codeIsStreaming = propStatus.code === "streaming";
// Surface the backend error when the tool call completed with invalid
// args. Backend success results start with "Rendered HTML artifact";
// error results start with "Error:".
// Surface the backend error when the tool call completed with invalid args.
// Success results start with "Rendered HTML artifact"; errors with "Error:".
const errorText =
status?.type === "complete" &&
typeof result === "string" &&

View file

@ -25,9 +25,8 @@ const RE_URL = /URL:\s*(.+)/;
const RE_SNIPPET = /Snippet:\s*(.+)/s;
/**
* Reject anything that is not a real http(s) URL. Web-search / web-fetch
* output is provider-controlled, so hostile ``javascript:`` / ``data:``
* lines must not reach the Source badge's <a href>.
* Reject non-http(s) URLs. Web-search/fetch output is provider-controlled,
* so hostile `javascript:` / `data:` lines must not reach a Source <a href>.
*/
function isSafeHttpUrl(raw: string): boolean {
const value = raw.trim();

View file

@ -18,57 +18,42 @@ import {
/**
* Intent-aware autoscroll for a Thread viewport.
*
* Why we don't reuse assistant-ui's built-in autoscroll:
* `useThreadViewportAutoScroll` runs unconditionally whenever
* `ThreadPrimitive.Viewport` is mounted. Even with every
* `scrollToBottomOn*` prop disabled, it still installs observers
* that write `isAtBottom` to the shared viewport store on every
* layout change. On sidebar toggles, browser resizes, or
* mobiledesktop breakpoint crossings, that write races our scroll
* correction whoever writes last wins, and the scroll-to-bottom
* button flickers or sticks depending on observer ordering.
* Why not assistant-ui's built-in: `useThreadViewportAutoScroll` always
* installs observers that write `isAtBottom` to the shared store on every
* layout change, even with `scrollToBottomOn*` disabled. On resizes /
* breakpoint crossings that write races our correction and the
* scroll-to-bottom button flickers depending on observer ordering.
*
* Strategy:
* - Own `isAtBottom` as local state (exposed via
* `useIsThreadAtBottom`). Upstream can still write to its own
* store; nobody reads from it.
* - Drive the viewport with a single rAF loop governed by a follow
* deadline (`followUntilRef`). Any signal that can invalidate
* bottom alignment (resize, mutation, AUI event, programmatic
* scroll) extends the deadline; the loop pins to the bottom and
* reports `isAtBottom=true` until it expires, then settles on
* pure DOM observation.
* - Detect user intent (wheel up, touch swipe up, scroll direction)
* to detach. While detached, resize/mutation signals don't extend
* the deadline, so the button appears and stays. Re-attach when
* the user scrolls *down* within 24px of the bottom.
* - Own `isAtBottom` as local state (via `useIsThreadAtBottom`); nobody
* reads upstream's store.
* - Drive the viewport via a single rAF loop with a follow deadline
* (`followUntilRef`). Any signal that can invalidate bottom alignment
* extends the deadline; the loop pins and reports `isAtBottom=true`
* until it expires, then settles on DOM observation.
* - Detect user intent (wheel up, swipe up, scroll direction) to detach.
* While detached, resize/mutation don't extend the deadline. Re-attach
* when the user scrolls down within 24px of the bottom.
*/
// 2px, not 1: subpixel rounding on HiDPI displays can leave a fractional
// gap at the "true" bottom that a 1px threshold reports as not-at-bottom.
// 2px, not 1: HiDPI subpixel rounding can leave a fractional gap that a
// 1px threshold reports as not-at-bottom.
const AT_BOTTOM_THRESHOLD_PX = 2;
const RE_ATTACH_THRESHOLD_PX = 24;
const TOUCH_MOVE_THRESHOLD_PX = 4;
// Cumulative upward movement (summed across scroll events) that
// counts as a deliberate detach. Summed rather than per-event so that
// slow 1-px-per-event sources — middle-click autoscroll, scrollbar
// drags, some trackpads — accumulate instead of slipping under a
// per-event threshold forever.
// Cumulative upward movement counting as a deliberate detach. Summed (not
// per-event) so slow 1px-per-event sources (middle-click autoscroll,
// scrollbar drags, some trackpads) accumulate instead of slipping under.
const UPWARD_DETACH_THRESHOLD_PX = 2;
// Window during which the viewport pins to the bottom through
// layout/content races. Extends on every resize/mutation, so streaming
// keeps the viewport pinned as long as content keeps arriving; settles
// this long after the last change.
// Window the viewport stays pinned through layout/content races. Extends
// on every resize/mutation, so streaming keeps it pinned; 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.
// Max stabilizer compensation. Absorbs sub-frame transients (~5-15px shiki
// re-renders, ~8px action-bar drift). Larger shrinks are intentional
// content removals (delete, regenerate clear, reasoning collapse); padding
// those over leaves empty space, so above this we release and let
// autoscroll re-pin to the new height.
const STABILIZER_MAX_PX = 64;
export type ScrollToBottom = (behavior?: ScrollBehavior) => void;
@ -78,11 +63,10 @@ type AutoScrollContextValue = {
getIsAtBottom: () => boolean;
subscribe: (listener: () => void) => () => void;
/**
* Mark the user as detached from the bottom, as if they had scrolled
* up. Called when the composer grows and the bottom spacer grows with
* it: the chat is then above the new bottom, and observer-driven pins
* must not shove it up. Scrolling back to the bottom re-attaches;
* explicit pins (run start, scroll-to-bottom button) still work.
* Mark the user as detached, as if they scrolled up. Called when the
* composer (and bottom spacer) grow: the chat is then above the new
* bottom and observer-driven pins must not shove it up. Scrolling back
* re-attaches; explicit pins (run start, button) still work.
*/
detachFromBottom: () => void;
};
@ -191,24 +175,18 @@ export function useIntentAwareAutoScroll(): {
const atBottomStrict = (): boolean =>
distanceFromBottom() <= AT_BOTTOM_THRESHOLD_PX;
// True only when there is room to scroll upward. Guards the
// wheel/touch detach paths: a wheel-up or swipe-down gesture on
// a viewport with nothing above (short thread, or already at
// the top) can't express intent to leave the bottom and must
// not flip userDetachedRef — otherwise later streaming updates
// skip extendFollow and auto-follow stays dead for the session.
// Room to scroll upward. Guards wheel/touch detach: a gesture on a
// viewport with nothing above can't express intent to leave the
// bottom and must not flip userDetachedRef, else later streaming
// skips extendFollow and auto-follow stays dead for the session.
const canScrollUp = (): boolean => el.scrollTop > 0;
// True when a nested scrollable ancestor of the event target
// (e.g. the reasoning panel's own overflow-y-auto region, or
// any tool output with internal scroll) has room above and
// will therefore consume the upward delta before it reaches
// the viewport. Walking stops at the viewport element itself,
// so only intermediate inner scrollers count.
//
// Wheel and touchmove events bubble, so without this check a
// user reading back through a long reasoning pane mid-stream
// would falsely detach the outer viewport.
// True when a nested scrollable ancestor of the target (reasoning
// panel, tool output) has room above and will consume the upward
// delta before it reaches the viewport. Walk stops at the viewport,
// so only intermediate inner scrollers count. Without this, wheel/
// touchmove bubbling would falsely detach while reading a long
// reasoning pane mid-stream.
const innerScrollWillConsumeUpward = (
target: EventTarget | null,
): boolean => {
@ -230,10 +208,8 @@ 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.).
// Stabilizer state (see `stabilize`). In this closure so it resets
// when the viewport remounts (Compare-pane swap, thread switch).
let stabilizerPx = 0;
let maxContentHeight = 0;
@ -255,11 +231,10 @@ 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.
// The stabilizer only matters while pinning. Once the user
// scrolls up, drop residual padding so the bottom stays flush on
// return. Safe mid-content: shrinking scrollHeight can't cap their
// scrollTop.
releaseStabilizer();
maxContentHeight = el.scrollHeight;
};
@ -270,12 +245,10 @@ export function useIntentAwareAutoScroll(): {
}
};
// Single rAF loop. While within the follow window and not
// detached, pin the viewport to the bottom and report
// isAtBottom=true every frame. Otherwise settle on whatever the
// DOM says. Scheduling is edge-triggered: scroll/resize/mutation
// events call requestTick(), and the loop self-perpetuates only
// as long as pinning is still active.
// Single rAF loop. Within the follow window and not detached, pin to
// bottom and report isAtBottom=true each frame; otherwise settle on
// the DOM. Edge-triggered: scroll/resize/mutation call requestTick(),
// and the loop self-perpetuates only while pinning is active.
const tick = (): void => {
rafId = null;
const following =
@ -304,8 +277,8 @@ export function useIntentAwareAutoScroll(): {
requestTick();
};
// Programmatic detach (see detachFromBottom). Same effect as the
// user scrolling up; the tick refresh updates isAtBottom.
// Programmatic detach (see detachFromBottom). Same as scrolling up;
// the tick refresh updates isAtBottom.
detachImplRef.current = () => {
detach();
requestTick();
@ -363,17 +336,12 @@ export function useIntentAwareAutoScroll(): {
extendFollow();
}
} else if (delta < 0 && !userDetachedRef.current) {
// Upward: sum across events. Middle-click autoscroll and
// some trackpads deliver 1px-per-event scrolls that each
// slip under a per-event threshold; summing catches them.
//
// Count distance-from-bottom growth, not raw scrollTop
// delta. When content above collapses (reasoning panels
// auto-closing after streaming, tool outputs auto-hiding),
// browsers scroll-anchor to keep visible content stable:
// scrollTop decreases but scrollHeight decreases by the
// same amount, so distance is unchanged. Those layout-
// induced deltas must not flip user intent.
// Upward: sum across events to catch 1px-per-event sources
// (middle-click autoscroll, some trackpads) that slip under a
// per-event threshold. Count distance-from-bottom growth, not
// raw scrollTop delta: when content above collapses, browsers
// scroll-anchor so scrollTop and scrollHeight drop together and
// distance is unchanged. Those layout deltas must not flip intent.
const distanceDelta = distanceNow - lastDistanceFromBottom;
if (distanceDelta > 0) {
upwardAccumulator += distanceDelta;
@ -393,37 +361,25 @@ export function useIntentAwareAutoScroll(): {
// 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.
// Problem: when a trailing code block finalizes (shiki re-renders the
// <pre> with highlight spans), its height briefly dips then recovers.
// The dip shrinks `scrollHeight`, so the browser synchronously caps
// `scrollTop` to the new max — a one-frame upward jump, then a
// "snap back". Re-scrolling can't help: once scrollHeight drops the
// cap has happened and scrollTop can't exceed 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.
// Fix: keep `scrollHeight` monotonic across the follow window. Track
// max content height (scrollHeight minus our padding) and write any
// shortfall into CSS var `--aui-scroll-stabilizer`, read by the
// viewport's padding-bottom. A 5px shrink grows padding 5px so the
// browser sees no scrollHeight change. Padding shrinks back to zero
// as content grows past its prior high-water mark.
//
// 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.
// Self-contained: lives on the viewport element via a CSS variable,
// touching no 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.
// Returns the post-adjustment scrollHeight so one layout read per
// observer callback feeds both stabilization and pinning.
const stabilize = (): number => {
const sh = el.scrollHeight;
const currentContent = sh - stabilizerPx;
@ -431,9 +387,8 @@ export function useIntentAwareAutoScroll(): {
!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.
// Outside the follow window: stop adjusting but keep
// maxContentHeight current so the next session isn't stale.
maxContentHeight = currentContent;
return sh;
}
@ -441,14 +396,11 @@ export function useIntentAwareAutoScroll(): {
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.
// Large shrinks (over STABILIZER_MAX_PX) are intentional removals
// (delete, regenerate clear, reasoning collapse). Compensating
// would leave an empty gap, so release the stabilizer and rebase
// the high-water mark; the pinIfFollowing call below re-anchors to
// the new bottom.
if (shrink > STABILIZER_MAX_PX) {
maxContentHeight = currentContent;
if (stabilizerPx !== 0) {
@ -468,10 +420,9 @@ export function useIntentAwareAutoScroll(): {
return currentContent + stabilizerPx;
};
// 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.
// Synchronous pin-to-bottom. Observer callbacks run after layout,
// before paint, so this scrollTo composites in the same frame as the
// triggering mutation.
const pinIfFollowing = (scrollHeight: number): void => {
if (userDetachedRef.current) {
return;
@ -485,10 +436,8 @@ export function useIntentAwareAutoScroll(): {
el.scrollTo({ top: scrollHeight, behavior: "instant" });
};
// 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
// All three layout-change signals fan in here. Order matters: extend
// first so the stabilizer sees follow as active; stabilize before
// pinning so we scroll to the post-adjustment scrollHeight.
const onLayoutChange = (): void => {
extendFollow();
@ -501,18 +450,17 @@ export function useIntentAwareAutoScroll(): {
const mutationObserver = new MutationObserver(onLayoutChange);
const onViewportResize = onLayoutChange;
// Fresh attach (a new viewport element) always starts pinned.
// Rebinds to the SAME element must not pin or reset detach state:
// the Viewport composes refs with an identity that changes on
// re-render, so React re-runs the ref (null, then same element)
// on unrelated renders such as composer resizes. Pinning here
// would yank the chat to the bottom on every such render. The
// observers below are re-installed either way.
// Fresh attach always starts pinned. Rebinds to the SAME element must
// not pin or reset detach state: the Viewport's composed ref identity
// changes on re-render, so React re-runs the ref (null, then same
// element) on unrelated renders (composer resizes); pinning would
// yank the chat to bottom each time. Observers are re-installed either
// way.
if (!isRebind) {
userDetachedRef.current = false;
// Pin to bottom when the ref first attaches. Covers the case
// where `thread.initialize` fires before the ref is bound.
// Pin on first attach, covering thread.initialize firing before the
// ref is bound.
extendFollow();
if (el.scrollHeight > el.clientHeight) {
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
@ -521,23 +469,19 @@ export function useIntentAwareAutoScroll(): {
}
requestTick();
// 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.
// Observe the border box, not content box. The stabilizer writes
// padding-bottom (shrinks the content box); observing that would echo
// every adjustment back as a resize into onLayoutChange. Border-box is
// stable through padding changes but still tracks parent resizes.
resizeObserver.observe(el, { box: "border-box" });
mutationObserver.observe(el, {
childList: true,
subtree: true,
characterData: true,
// Layout-affecting attributes only. Excludes `style`, which
// elements may mutate in response to viewport state (feedback
// loop). `class` catches Tailwind show/hide; `hidden` /
// `aria-hidden` / `aria-expanded` / `data-state` catch Radix
// and native collapsible toggles that change scrollHeight
// without triggering the viewport ResizeObserver.
// Layout-affecting attributes only. Excludes `style` (elements may
// mutate it in response to viewport state → feedback loop). `class`
// catches Tailwind show/hide; the rest catch Radix/native
// collapsibles that change scrollHeight without a viewport resize.
attributes: true,
attributeFilter: [
"class",
@ -551,10 +495,9 @@ export function useIntentAwareAutoScroll(): {
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: true });
el.addEventListener("scroll", onScroll, { passive: true });
// ResizeObserver above covers browser-window resizes (they resize
// the viewport element). visualViewport.resize is the only signal
// for iOS software-keyboard changes, where the visual viewport
// shrinks without the viewport element's clientHeight changing.
// ResizeObserver covers window resizes. visualViewport.resize is the
// only signal for iOS software-keyboard changes, where the visual
// viewport shrinks without the element's clientHeight changing.
window.visualViewport?.addEventListener("resize", onViewportResize);
return () => {
@ -580,10 +523,9 @@ export function useIntentAwareAutoScroll(): {
[setIsAtBottom],
);
// Thread lifecycle moments that always pin to the bottom, regardless
// of prior detach state. "auto" respects CSS smooth scrolling for
// runStart so new turns glide in; "instant" snaps for load/switch
// where any animation would just be wasted motion.
// Thread lifecycle moments that always pin, regardless of detach state.
// "auto" respects CSS smooth scroll for runStart (new turns glide in);
// "instant" snaps for load/switch where animation is wasted.
const pinToBottom = useCallback((behavior: ScrollBehavior) => {
userDetachedRef.current = false;
scrollImplRef.current(behavior);

View file

@ -18,10 +18,9 @@ import {
interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after the shutdown API returns success, right before we replace
* document.body with the "Server stopped" page. Callers use this to remove
* their beforeunload listener otherwise the browser would prompt
* "Leave site?" when the user tries to close the final tab. */
/** Called after shutdown succeeds, before we replace document.body. Lets
* callers remove their beforeunload listener so the browser doesn't prompt
* "Leave site?" when closing the final tab. */
onAfterShutdown?: () => void;
}
@ -44,7 +43,7 @@ export function ShutdownDialog({
return;
}
} catch {
// Network error — shutdown request never reached the server
// Network error: request never reached the server
toastError("Could not reach server");
setStopping(false);
return;

View file

@ -137,7 +137,7 @@ export function WindowTitlebar({
const appWindow = await getAppWindow();
setMaximized(await appWindow.isMaximized());
} catch {
// If a window permission is not ready yet, keep the previous visual state.
// Window permission not ready yet: keep previous visual state.
}
}, [enabled]);

View file

@ -11,7 +11,7 @@ export const env = {
BASE_URL: import.meta.env.BASE_URL,
} as const;
// ── Platform / device type ──────────────────────────────────
// Platform / device type
export type DeviceType = "mac" | "windows" | "linux" | string;
@ -22,7 +22,7 @@ interface PlatformState {
isChatOnly: () => boolean;
}
// Client-side platform detection as fallback when backend isn't ready yet.
// Client-side fallback when backend isn't ready yet.
function detectLocalPlatform(): DeviceType {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
@ -55,9 +55,9 @@ export async function fetchDeviceType(): Promise<DeviceType> {
return deviceType;
}
} catch {
// Backend not ready — use client-side detection so chat-only guard
// still works on initial load (important for macOS). Keep fetched=false
// so a later call retries against the backend.
// Backend not ready: use client-side detection so chat-only guard works
// on initial load (important for macOS). Keep fetched=false so a later
// call retries against the backend.
const deviceType = detectLocalPlatform();
const chatOnly = deviceType === "mac";
usePlatformStore.setState({ deviceType, chatOnly, fetched: false });

View file

@ -168,9 +168,8 @@ export async function authFetch(
});
} catch (err) {
if (err instanceof TypeError) {
// fetch TypeError = offline | backend down | CORS/DNS. In Tauri
// it's always backend-down; in the web build distinguish offline
// so the user gets the right recovery path.
// fetch TypeError = offline | backend down | CORS/DNS. Tauri is always
// backend-down; the web build distinguishes offline for the right message.
if (!isTauri && typeof navigator !== "undefined" && navigator.onLine === false) {
throw new Error(
"You appear to be offline. Check your network connection and try again.",
@ -227,10 +226,9 @@ async function postLogout(accessToken: string | null): Promise<Response | null>
}
export async function logout(): Promise<void> {
// Server-side revoke. If the access token is expired the 401 fires
// BEFORE revoke runs; rotate via the refresh token and retry so the
// refresh family is actually revoked. Generation bump in finally
// invalidates any in-flight refresh from before this call.
// Server-side revoke. If the access token is expired, the 401 fires before
// revoke runs; rotate via the refresh token and retry so the refresh family
// is revoked. The finally generation bump invalidates in-flight refreshes.
try {
let response = await postLogout(getAuthToken());
if (response && response.status === 401 && getRefreshToken()) {

View file

@ -12,8 +12,8 @@ import type { ReactElement } from "react";
import type { SyntheticEvent } from "react";
import { refreshSession } from "../api";
// Bootstrap credentials injected into index.html by the backend
// (only present while default admin must_change_password is true)
// Bootstrap credentials injected into index.html by the backend (only present
// while default admin must_change_password is true)
declare global {
interface Window {
__UNSLOTH_BOOTSTRAP__?: { username: string; password: string };
@ -93,9 +93,9 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
let canceled = false;
async function initializeAuthForm(): Promise<void> {
// Always check the server first — localStorage flags can be stale
// (e.g. tokens from a previous install attempt). The server's
// /api/auth/status is the source of truth for requires_password_change.
// Always check the server first; localStorage flags can be stale (e.g.
// tokens from a previous install). /api/auth/status is the source of
// truth for requires_password_change.
try {
const response = await fetch(apiUrl("/api/auth/status"));
if (!response.ok) throw new Error("Failed to load auth status.");
@ -109,7 +109,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
setMustChangePassword(result.requires_password_change);
}
// Redirect between login ↔ change-password based on server state
// Redirect between login / change-password per server state
if (mode === "login" && result.requires_password_change) {
navigate({ to: "/change-password" });
return;
@ -119,8 +119,8 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
return;
}
// On login page, if user already has a valid session and no
// password change is required, skip straight to the app.
// On login, skip to the app if a valid session exists and no
// password change is required.
if (isLoginMode && !result.requires_password_change) {
if (hasRefreshToken()) {
const refreshed = await refreshSession();
@ -293,12 +293,12 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
storeAuthTokens(token.access_token, token.refresh_token);
navigate({ to: getPostAuthRoute() });
} catch (err: unknown) {
// The backend already returns the correct, PATH-based command
// ("unsloth studio reset-password"), which the installer puts on PATH on
// every platform. Do NOT rewrite it to a relative Windows path like
// ".\unsloth_studio\Scripts\unsloth.exe ..." -- that only resolves when the
// terminal happens to be inside the Studio home dir, so it fails with
// CommandNotFoundException everywhere else. Show the backend message as-is.
// The backend returns the correct PATH-based command ("unsloth studio
// reset-password"), which the installer puts on PATH on every platform.
// Do NOT rewrite it to a relative Windows path like
// ".\unsloth_studio\Scripts\unsloth.exe ..." -- that only resolves inside
// the Studio home dir and fails with CommandNotFoundException elsewhere.
// Show the backend message as-is.
const msg = err instanceof Error ? err.message : "Auth failed.";
setError(msg);
} finally {

View file

@ -39,9 +39,9 @@ export function storeAuthTokens(
accessToken: string,
refreshToken: string,
): void {
// Callers set must_change_password via setMustChangePassword(). Routing it
// through here would let CodeQL trace the boolean to localStorage and flag
// the (deliberate) JWT writes as sensitive-info storage.
// must_change_password is set via setMustChangePassword(), not here: routing
// it through would let CodeQL trace the boolean into localStorage and flag the
// deliberate JWT writes as sensitive-info storage.
if (!canUseStorage()) return;
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken);
@ -54,10 +54,9 @@ export function clearAuthTokens(): void {
localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY);
}
// Encode the flag as key presence (literal "1" or absence) so localStorage
// receives a constant, not a derived boolean. Breaks the CodeQL data flow
// from TokenResponse.must_change_password into localStorage.setItem; the
// stored value is a route hint (/change-password vs /chat), not a secret.
// Flag stored as key presence (constant "1" or absence), not a derived boolean,
// so CodeQL doesn't flow must_change_password into localStorage.setItem. The
// value is a route hint (/change-password vs /chat), not a secret.
export function mustChangePassword(): boolean {
if (!canUseStorage()) return false;
return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) !== null;

View file

@ -20,8 +20,8 @@ type TauriAutoAuthOptions = {
force?: boolean;
};
// Concurrency guard: multiple route guards can call tauriAutoAuth simultaneously.
// Without this, the first-launch password-change could race with itself.
// Concurrency guard: multiple route guards can call tauriAutoAuth at once;
// without this the first-launch password-change could race with itself.
let pending: { promise: Promise<boolean>; force: boolean } | null = null;
let lastTauriAuthFailure: string | null = null;
@ -61,7 +61,7 @@ async function doTauriAutoAuth(options: TauriAutoAuthOptions): Promise<boolean>
return true;
}
// Try refreshing existing session
// Try refreshing an existing session.
if (!options.force && hasRefreshToken()) {
const refreshed = await refreshSession();
if (refreshed && hasAuthToken() && !mustChangePassword()) {

View file

@ -7,8 +7,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { isCustomProviderType } from "./external-providers";
/**
* Registry logos live at `public/provider-logos/{provider_type}.{ext}` where `provider_type`
* matches `PROVIDER_REGISTRY` keys exactly (lowercase). Extension varies by asset (svg preferred).
* Registry logos at `public/provider-logos/{provider_type}.{ext}`; key matches
* `PROVIDER_REGISTRY` (lowercase). Extension varies per asset (svg preferred).
*/
const PROVIDER_LOGO_EXT: Record<string, "svg" | "png" | "jpg"> = {
openai: "svg",
@ -41,9 +41,8 @@ interface ApiProviderLogoProps {
}
/**
* Renders the logo for a registry provider type when `provider_type.{ext}` exists under
* `public/provider-logos/`.
* OpenAI's asset is black-on-transparent; it is inverted in dark mode for contrast.
* Renders a registry provider's logo when its asset exists under
* `public/provider-logos/`. OpenAI's is inverted in dark mode for contrast.
*/
export function ApiProviderLogo({ providerType, className, title }: ApiProviderLogoProps) {
const src = apiProviderLogoSrc(providerType);

View file

@ -80,7 +80,7 @@ interface ServerUsage {
completion_tokens: number;
total_tokens: number;
// External prompt-cache fields (see _build_usage_chunk in
// external_provider.py). cache_creation is Anthropic-only.
// external_provider.py); cache_creation is Anthropic-only.
prompt_tokens_details?: {
cached_tokens?: number;
};
@ -107,9 +107,9 @@ type RunMessage = RunMessages[number];
/** Tracks which user messages were sent with an audio file (messageId → filename). */
export const sentAudioNames = new Map<string, string>();
// Synthetic provider-side tool names; backend stamps args._server_tool
// so user functions with the same name aren't dropped. Mirror of
// _SERVER_SIDE_BUILTIN_TOOL_NAMES on the backend.
// Synthetic provider-side tool names; backend stamps args._server_tool so
// user functions with the same name aren't dropped. Mirror of backend
// _SERVER_SIDE_BUILTIN_TOOL_NAMES.
const SERVER_SIDE_BUILTIN_TOOL_NAMES = new Set<string>([
"web_search",
"web_fetch",
@ -118,10 +118,10 @@ const SERVER_SIDE_BUILTIN_TOOL_NAMES = new Set<string>([
]);
/**
* Whether a persisted tool-call part is provider-side synthetic and
* should be stripped from outbound history. Match on the
* args._server_tool marker or a Gemini native_part payload no shape
* heuristic, because user functions can legitimately share a name.
* Whether a persisted tool-call part is provider-side synthetic and should
* be stripped from outbound history. Matches on the args._server_tool marker
* or a Gemini native_part payload (no shape heuristic, since user functions
* can legitimately share a name).
*/
function isServerSideBuiltinToolPart(
toolNameLower: string,
@ -176,23 +176,19 @@ export function useThreadAutosaveHandle(): ThreadAutosaveHandle {
}
/**
* Match error messages that indicate the request filled or would fill
* the KV cache, so the UI can show a dedicated toast pointing at the
* ``Context Length`` setting.
* Match error messages indicating the request filled (or would fill) the KV
* cache, so the UI can toast a pointer at the ``Context Length`` setting.
*
* Two wordings reach the client and both must hit:
*
* 1. The raw llama-server text when ``--no-context-shift`` trips --
* 1. Raw llama-server text when ``--no-context-shift`` trips:
* "the request exceeds the available context size (N tokens)".
* 2. The rewritten friendly text emitted by
* ``backend/routes/inference.py::_friendly_error`` -- "Message too
* long: X tokens exceeds the Y-token context window. Try
* increasing the Context Length ..." This is the one most users
* see on the streaming GGUF path.
* 2. The friendly rewrite from
* ``backend/routes/inference.py::_friendly_error``: "Message too long:
* ... context window. Try increasing the Context Length ..." (the one
* most users see on the streaming GGUF path).
*
* We match on substrings rather than full regexes because both layers
* have drifted across versions (llama.cpp master has tweaked the
* phrasing; ``_friendly_error`` has gone through several copy edits).
* Match substrings, not full regexes: both layers have drifted across
* versions (llama.cpp phrasing and _friendly_error copy edits).
*/
export function isContextLimitError(message: string): boolean {
if (!message) return false;
@ -224,10 +220,10 @@ async function updateStoredChatThreadEventually(
}
/**
* Return ``raw`` when it is a safe-to-navigate http(s) URL, or "" otherwise.
* Return ``raw`` when it is a safe-to-navigate http(s) URL, else "".
* Rejects non-string input, CR/LF (header injection), and non-http(s)
* schemes (``javascript:`` / ``data:`` / ``vbscript:``) so provider /
* tool-controlled strings cannot land in an <a href>.
* schemes (``javascript:`` / ``data:`` / ``vbscript:``) so provider/tool-
* controlled strings cannot land in an <a href>.
*/
function isSafeNavigableSourceUrl(raw: unknown): string {
if (typeof raw !== "string") return "";
@ -264,10 +260,9 @@ function documentCitationToSource(
"";
const docIndex =
typeof cit.document_index === "number" ? cit.document_index : undefined;
// Only treat ``source`` as a navigable URL when it is real http(s);
// Only treat ``source`` as navigable when it is real http(s);
// search_result_location can carry a free-form id (e.g. ``kb-doc-42``)
// or a hostile ``javascript:`` / ``data:`` / ``vbscript:`` string.
// Fall back to a stable doc anchor otherwise.
// or a hostile scheme. Fall back to a stable doc anchor otherwise.
const url =
isSafeNavigableSourceUrl(source) || `#anthropic-doc-${docIndex ?? fallbackIdx}`;
const title = docTitle || source || `Document ${fallbackIdx + 1}`;
@ -277,9 +272,8 @@ function documentCitationToSource(
const description =
cited.length > 240 ? `${cited.slice(0, 240)}...` : cited;
// Anthropic numbers inline [N] per citation, not per source URL.
// Fold citation type + position-bearing fields into the id so two
// distinct citations on the same source (or two search_result_locations
// with different search_result_index) keep separate Sources entries.
// Fold citation type + position-bearing fields into the id so distinct
// citations on the same source keep separate Sources entries.
const citationType =
typeof cit.type === "string" ? String(cit.type) : "";
const positionParts = [
@ -333,8 +327,8 @@ function parseSourcesFromResult(raw: string): {
const snippetMatch = block.match(/Snippet:\s*(.+)/);
if (titleMatch && urlMatch) {
// Drop blocks whose ``URL:`` is not safe http(s); provider/tool
// output is attacker-controllable so a hostile ``javascript:`` /
// ``data:`` line must not reach the Sources panel <a href>.
// output is attacker-controllable, so a hostile scheme must not
// reach the Sources panel <a href>.
const url = isSafeNavigableSourceUrl(urlMatch[1]);
if (!url) continue;
const snippet = snippetMatch?.[1]?.trim();
@ -363,21 +357,18 @@ function estimateTokenCount(text: string): number | undefined {
* Normalize a streamed `delta.content` to a plain text string.
*
* OpenAI Chat Completions originally typed `delta.content` as a string, but
* a number of providers now emit it as an array of structured content parts.
* Concatenating that with `cumulativeText += delta` would stringify each
* part as `[object Object]` this function is the guard against that.
* some providers now emit an array of structured content parts; concatenating
* those directly would stringify each as `[object Object]`. This guards that.
*
* Handled part shapes:
* { type: "text" | "output_text", text | content: "..." } text body
* { type: "thinking" | "reasoning", thinking | text: "..." } wrapped as
* inline `<think>...</think>` so the downstream parser
* (`parseAssistantContent`) lifts it into a reasoning part the same way
* it does for providers that emit thinking inline. Without this wrap,
* Mistral magistral and similar reasoning-part providers would lose
* their thinking panel.
* inline `<think>...</think>` so `parseAssistantContent` lifts it into
* a reasoning part (else Mistral magistral and similar reasoning-part
* providers lose their thinking panel).
*
* Unknown part types are skipped better to drop a stray field than to
* stringify an object and pollute the rendered chat with `[object Object]`.
* Unknown part types are skipped better to drop a stray field than
* stringify an object into the rendered chat.
*/
function extractDeltaText(delta: unknown): string {
const extractReasoningText = (payload: unknown): string => {
@ -564,8 +555,8 @@ function toOpenAIImageEditReferenceMessage(
// Refusal flag stamped on assistant metadata when the backend emits the
// `anthropic_refusal` _toolEvent. We drop the refused pair from the next
// request body (Anthropic guidance: leaving refusals in context keeps
// refusing). Metadata (not text) prevents content from spoofing a reset.
// request body (Anthropic: leaving refusals in context keeps refusing).
// Using metadata, not text, prevents content from spoofing a reset.
function isAnthropicRefusalMessage(message: RunMessage): boolean {
if (message.role !== "assistant") return false;
const metadata = (message as { metadata?: unknown }).metadata as
@ -618,8 +609,8 @@ function collectAssistantToolCalls(
hasNativePart,
);
if (isServerSideBuiltin) {
// Gemini code_execution / image_generation still need to round-
// trip the native_part payload for native replay; drop the rest.
// Gemini code_execution / image_generation must round-trip the
// native_part payload for native replay; drop the rest.
if (!hasNativePart) continue;
}
const argumentsStr =
@ -640,8 +631,8 @@ function collectAssistantToolCalls(
},
};
// Promote args.google to extra_content.google so the backend
// native_part replay branch can find it. The backend only inspects
// extra_content, not function.arguments.
// native_part replay branch finds it (it only inspects
// extra_content, not function.arguments).
if (tc.extra_content !== undefined) {
entry.extra_content = tc.extra_content;
} else if (argsGoogle) {
@ -670,7 +661,7 @@ function collectToolResultMessages(
if (part.type !== "tool-call") continue;
const tc = part as ToolCallMessagePart;
const result = (tc as { result?: unknown }).result;
// Skip provider-side builtins; see isServerSideBuiltinToolPart.
// Skip provider-side builtins; see isServerSideBuiltinToolPart().
const argsObj =
tc.args && typeof tc.args === "object"
? (tc.args as Record<string, unknown>)
@ -702,8 +693,8 @@ function collectToolResultMessages(
let content: string;
if (typeof result === "string") {
// Backend ChatMessage validator rejects role="tool" with empty
// content; serialise a sentinel JSON so legitimately empty tool
// outputs still round-trip the follow-up turn to the provider.
// content; serialise sentinel JSON so legitimately empty tool
// outputs still round-trip to the provider.
content = result.length > 0 ? result : JSON.stringify({ result: "" });
} else {
try {
@ -774,8 +765,8 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
"[audio]",
);
if (isAnthropicRefusalMessage(message)) {
// Prune refused assistant turn from outbound history; the
// rendered transcript still shows the user-visible notice.
// Prune refused assistant turn from outbound history; the rendered
// transcript still shows the user-visible notice.
return [];
}
}
@ -795,9 +786,9 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
};
if (toolCalls.length > 0) {
base.tool_calls = toolCalls;
// OpenAI requires content === null on assistant turns whose
// payload is entirely tool_calls (matches the wire shape Gemini
// expects for the next functionCall replay).
// OpenAI requires content === null on assistant turns that are
// entirely tool_calls (matches the wire shape Gemini expects for
// the next functionCall replay).
if (!textContent && imageParts.length === 0) {
base.content = null;
}
@ -830,7 +821,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
continue;
}
// Image in message.content (e.g. compare view appends content with image parts)
// Image in message.content (e.g. compare view).
for (const part of message.content ?? []) {
if (part.type === "image" && "image" in part) {
const encoded = extractImageBase64(part.image);
@ -838,7 +829,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
}
}
// Image in message.attachments (e.g. chat composer)
// Image in message.attachments (e.g. chat composer).
if ("attachments" in message && (message.attachments?.length ?? 0) > 0) {
for (const attachment of message.attachments ?? []) {
for (const part of attachment.content ?? []) {
@ -858,7 +849,7 @@ function findLatestUserImageBase64(messages: RunMessages): string | undefined {
}
function findLatestUserAudioBase64(messages: RunMessages): string | undefined {
// Check message content parts (from compare view's CompareMessagePart with type: "audio")
// Message content parts (compare view CompareMessagePart type: "audio").
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (!message || message.role !== "user") continue;
@ -877,7 +868,7 @@ function findLatestUserAudioBase64(messages: RunMessages): string | undefined {
}
}
// Check the runtime store (from main composer's audio upload)
// Runtime store (main composer's audio upload).
const pendingAudio = useChatRuntimeStore.getState().pendingAudioBase64;
return pendingAudio ?? undefined;
}
@ -893,8 +884,8 @@ async function resolveUseAdapter(
if (!thread?.pairId) {
return undefined;
}
// model1/model2 threads don't use the adapter toggle — each side
// loads its own model via /api/inference/load before generation.
// model1/model2 threads skip the adapter toggle — each side loads
// its own model via /api/inference/load before generation.
if (thread.modelType === "model1" || thread.modelType === "model2") {
return undefined;
}
@ -962,9 +953,9 @@ function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
}
/**
* Auto-load the smallest downloaded model when the user tries to chat
* without selecting one. Prefers GGUF (picks smallest cached variant),
* falls back to smallest cached safetensors model.
* Auto-load the smallest downloaded model when the user chats without
* selecting one. Prefers GGUF (smallest cached variant), then smallest
* cached safetensors model.
*/
// Cap cascade so broken cached repos can't spam /api/inference/load.
const MAX_AUTO_LOAD_ATTEMPTS = 3;
@ -1009,8 +1000,7 @@ async function autoLoadSmallestModel(): Promise<{
listCachedModels().catch(() => []),
]);
// Try GGUF first: pick the repo with the smallest total size,
// then pick its smallest downloaded variant.
// GGUF first: smallest-total-size repo, then its smallest variant.
if (ggufRepos.length > 0) {
const sorted = [...ggufRepos].sort((a, b) => a.size_bytes - b.size_bytes);
for (const repo of sorted) {
@ -1053,7 +1043,7 @@ async function autoLoadSmallestModel(): Promise<{
...store.params,
maxTokens: loadResp.context_length ?? 131072,
});
// Add model to store so the selector shows the name
// Add to store so the selector shows the name.
const autoModel: ChatModelSummary = {
id: repo.repo_id,
name: loadResp.display_name ?? repo.repo_id,
@ -1101,7 +1091,7 @@ async function autoLoadSmallestModel(): Promise<{
}
}
// Fall back to safetensors models
// Fall back to safetensors models.
if (modelRepos.length > 0) {
const sorted = [...modelRepos].sort(
(a, b) => a.size_bytes - b.size_bytes,
@ -1171,7 +1161,7 @@ async function autoLoadSmallestModel(): Promise<{
}
}
// Cap also gates the default download so the total /api/inference/load
// Cap also gates the default download, so total /api/inference/load
// budget across cached + fallback is MAX_AUTO_LOAD_ATTEMPTS, not +1.
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) {
toast.dismiss(toastId);
@ -1182,7 +1172,7 @@ async function autoLoadSmallestModel(): Promise<{
};
}
// No cached models found — try downloading a small default GGUF
// No cached models — try downloading a small default GGUF.
toast("Downloading a small model…", {
id: toastId,
description:
@ -1275,8 +1265,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
async *run({ messages, abortSignal, unstable_threadId }) {
await useChatRuntimeStore.getState().hydratePersistedSettings();
let runtime = useChatRuntimeStore.getState();
// Capture the thread ID once at the start so it stays stable even if
// the user switches chats while waiting for model load / auto-load.
// Capture the thread ID once so it stays stable even if the user
// switches chats while waiting for model load / auto-load.
const resolvedThreadId =
(unstable_threadId ?? runtime.activeThreadId) || undefined;
const sandboxSessionId = await resolveSandboxSessionId(resolvedThreadId);
@ -1305,7 +1295,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
};
// Wait for in-progress model load to finish before inferring
// Wait for in-progress model load before inferring.
if (runtime.modelLoading) {
toast.info("Waiting for model to finish loading…");
try {
@ -1317,7 +1307,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
if (!useChatRuntimeStore.getState().params.checkpoint) {
// Auto-load the smallest downloaded model
let loaded: boolean;
let blockedByTrustRemoteCode: boolean;
try {
@ -1342,7 +1331,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
}
// Re-read store after potential auto-load / model ready wait
// Re-read store after auto-load / model-ready wait.
runtime = useChatRuntimeStore.getState();
const { params } = runtime;
const {
@ -1405,8 +1394,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw new Error("Missing connection API key.");
}
// Image-generation flag (OpenAI cloud + Responses-capable model).
// Computed first so Gemini image mode can suppress Search/Code.
// Image-generation flag (OpenAI cloud + Responses-capable model);
// computed first so Gemini image mode can suppress Search/Code.
const imageGenerationEnabledForThisTurn = Boolean(
externalProvider &&
externalSelection &&
@ -1444,9 +1433,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
),
);
// Fetch pill is independent of Search (Anthropic bills web_fetch
// separately from web_search). Sourced from `webFetchToolsEnabled`;
// on providers without web_fetch the toggle is forced off in
// chat-page's runtime setState.
// separately). Sourced from `webFetchToolsEnabled`; on providers
// without web_fetch the toggle is forced off in chat-page setState.
const webFetchEnabledForThisTurn = Boolean(
externalProvider &&
webFetchToolsEnabled &&
@ -1482,7 +1470,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// toOpenAIMessages emits assistant tool_calls + role="tool"
// follow-ups; the backend Gemini translator rebuilds the
// functionCall/functionResponse parts (with thoughtSignature).
// functionCall / functionResponse parts (with thoughtSignature).
const outboundMessages = survivingMessages
.flatMap(toOpenAIMessages)
.filter((message): message is NonNullable<typeof message> =>
@ -1508,9 +1496,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
}
// OpenAIChatMessage is a structural superset of SerializedMessage
// for the role/content axis the outbound pipeline consumes; cast
// through unknown since referenceMessage carries no tool_calls
// (the image_edit reference is a plain assistant turn).
// on the role/content axis; cast through unknown since
// referenceMessage carries no tool_calls (plain assistant turn).
outboundMessages.splice(
insertAt,
0,
@ -1545,10 +1532,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const webLabel = providerShipsWebFetch
? "web search or web fetch"
: "web search";
// Treat search and fetch as a single "any web tool" axis so
// the guard only warns when neither pill is on; checking
// webSearchEnabledForThisTurn alone mis-fired when only Fetch
// was on and suppressed live web_fetch calls.
// Treat search and fetch as one "any web tool" axis so the guard
// only warns when neither pill is on; checking webSearch alone
// mis-fired when only Fetch was on and suppressed web_fetch.
const anyWebEnabledForThisTurn =
webSearchEnabledForThisTurn || webFetchEnabledForThisTurn;
if (
@ -1625,15 +1611,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
// Scan post-prune history so a refused user turn's image/audio
// doesn't gate or mis-attribute the next non-refused turn.
// doesn't gate or mis-attribute the next turn.
const imageBase64 = findLatestUserImageBase64(survivingMessages);
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
const hasOutboundImage = Boolean(imageBase64);
// Keep render_html local-only and mirror the backend image-turn gate.
// Artifacts are independent of Search/Code: if a local tool-capable
// model has Artifacts enabled, expose render_html even when no other
// tool pills are active.
// Artifacts are independent of Search/Code: a local tool-capable model
// with Artifacts on exposes render_html even with no other pills active.
const renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest &&
supportsTools &&
@ -1652,10 +1637,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
addSystemInstruction(outboundMessages, artifactInstruction);
// Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps
// the gate simple: once a chat contains an image, a non-vision
// model can't respond — user starts a new chat to switch models.
// Block when ANY image is in the outbound payload (current or prior
// turns) and the loaded model can't process images. Once a chat
// contains an image, a non-vision model can't respond — the user
// starts a new chat to switch models.
if (imageBase64) {
const activeModel = runtime.models.find(
(m) => m.id === params.checkpoint,
@ -1672,10 +1657,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
});
if (imageGateReason) {
toast.error(imageGateReason);
// Flip the per-thread running flag on→off so the compare-mode
// waitForRunEnd resolves instead of hanging. This gate fires
// before the streaming path's setThreadRunning(true), so the
// wait promise would otherwise never settle.
// Flip the per-thread running flag on→off so compare-mode
// waitForRunEnd resolves instead of hanging: this gate fires
// before the streaming path's setThreadRunning(true).
const gatedThreadKey = resolvedThreadId || "__default";
runtime.setThreadRunning(gatedThreadKey, true);
runtime.setThreadRunning(gatedThreadKey, false);
@ -1683,7 +1667,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw new Error(imageGateReason);
}
}
// Clear pending audio from store after extracting (consumed on send)
// Clear pending audio from store after extracting (consumed on send).
if (audioBase64) {
const audioName = runtime.pendingAudioName;
if (audioName) {
@ -1785,8 +1769,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let reasoningStartAt: number | null = null;
let reasoningDuration = 0;
// True while wrapping a `delta.reasoning_content` stream in
// <think>...</think> for parseAssistantContent. Lives outside
// the SSE loop because the close tag fires when content arrives.
// <think>...</think> for parseAssistantContent. Lives outside the
// SSE loop because the close tag fires when content arrives.
let reasoningContentOpen = false;
// Tool call parts, cumulative; result lands on tool_end.
const toolCallParts: ToolCallMessagePart[] = [];
@ -1819,9 +1803,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
return [...otherToolParts, ...textParts, ...imageToolParts];
};
// Anthropic document_citations tool_event payload, converted to
// Sources-panel source parts at end-of-stream so the inline [N]
// markers have matching entries.
// Anthropic document_citations payload, converted to Sources-panel
// parts at end-of-stream so inline [N] markers have matching entries.
const documentCitationParts: Array<{
type: "source";
sourceType: "url";
@ -1830,16 +1813,16 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
title: string;
metadata?: { description: string };
}> = [];
// Latched on the `anthropic_refusal` tool event; stamped onto the
// final assistant metadata as `custom.anthropicRefusal` to drive
// the history-prune above.
// Latched on the `anthropic_refusal` tool event; stamped onto final
// assistant metadata as `custom.anthropicRefusal` to drive the
// history-prune above.
let anthropicRefusalSeen = false;
let serverMetadata: {
usage?: ServerUsage;
timings?: ServerTimings;
} | null = null;
// Per-run cancellation token so a delayed stop POST cannot match
// Per-run cancellation token so a delayed stop POST can't match
// the next run on the same thread.
const cancelId =
typeof crypto !== "undefined" && "randomUUID" in crypto
@ -1854,10 +1837,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Plain fetch, not authFetch: authFetch redirects to login on
// 401, which would kick the user out mid-stop.
const token = getAuthToken();
// Use apiUrl so the cancel POST reaches the right origin in
// Tauri production builds (where the webview origin is not the
// backend at 127.0.0.1:<port>). Browser/dev builds get the empty
// base, so the path is unchanged there.
// Use apiUrl so the cancel POST reaches the right origin in Tauri
// production builds (webview origin != backend at 127.0.0.1:<port>).
// Browser/dev builds get the empty base, so the path is unchanged.
void fetch(apiUrl("/api/inference/cancel"), {
method: "POST",
headers: {
@ -1948,8 +1930,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
anthropicCodeExecContainerId = null;
}
// Pre-send container validation (OpenAI). Stale ids drop
// silently and fall through to lazy-create. On list-call
// failure, skip and rely on the backend's retry path.
// silently and fall through to lazy-create; on list-call
// failure, rely on the backend's retry path.
let activeContainerIds: Set<string> | null = null;
if (externalProvider.providerType === "openai") {
try {
@ -1972,8 +1954,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId = null;
}
}
// Cross-thread inheritance: reuse the most recently used
// container from any other thread; opt-out via the picker.
// Cross-thread inheritance: reuse the most recent container
// from any other thread; opt-out via the picker.
if (
!openaiCodeExecContainerId &&
externalProvider.providerType === "openai"
@ -1985,7 +1967,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
for (const t of others) {
if (t.id === resolvedThreadId) continue;
if (!t.openaiCodeExecContainerId) continue;
// Skip ids not in active set; null on source thread so
// Skip ids not in active set; null the source thread so
// the next pass doesn't re-pick a dead id.
if (
activeContainerIds &&
@ -2006,9 +1988,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
/* fall through to lazy-create below */
}
}
// Pre-create our own container (vs container_auto) so it
// shows up in the picker with a friendly name and the
// configured TTL. Falls back to container_auto on failure.
// Pre-create our own container (vs container_auto) so it shows
// in the picker with a friendly name and the configured TTL.
// Falls back to container_auto on failure.
if (
!openaiCodeExecContainerId &&
externalProvider.providerType === "openai"
@ -2022,10 +2004,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
baseUrl: externalProvider.baseUrl || null,
},
{
// Friendly English-word name so the container
// is human-readable in the picker list (e.g.
// "kestrel-3f9c") instead of a thread-id slug
// or OpenAI's default blank name.
// Friendly English-word name so the container is
// human-readable in the picker (e.g. "kestrel-3f9c")
// instead of a thread-id slug or blank default.
name: pickFriendlyContainerName(),
ttlMinutes: ttlToUse,
},
@ -2035,10 +2016,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId: created.id,
}).catch(() => {});
} catch {
// Fall back to backend's container_auto path on
// failure — keeps the chat moving; the next turn
// can retry. The auto-created container will be
// unnamed, but the chat doesn't break.
// Fall back to the backend's container_auto path on
// failure — keeps the chat moving (the auto-created
// container is unnamed); the next turn can retry.
openaiCodeExecContainerId = null;
}
}
@ -2047,8 +2027,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
model: externalSelection.modelId,
messages: outboundMessages,
stream: true,
// Reasoning-class models (OpenAI gpt-5.x / o3) reject temperature
// and top_p; only forward when the active provider supports them.
// Reasoning-class models (OpenAI gpt-5.x / o3) reject
// temperature and top_p; forward only when supported.
...(externalCapabilities?.temperature !== false
? { temperature: params.temperature }
: {}),
@ -2072,8 +2052,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(externalCapabilities?.presencePenalty
? { presence_penalty: params.presencePenalty }
: {}),
// Compose the enabled_tools list from the active pills;
// backend maps each name to the provider's tool schema.
// enabled_tools from active pills; backend maps each name
// to the provider's tool schema.
...(webSearchEnabledForThisTurn ||
webFetchEnabledForThisTurn ||
codeExecEnabledForThisTurn ||
@ -2128,8 +2108,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
? { prompt_cache_ttl: externalProvider.promptCacheTtl }
: {}),
// Anthropic fast mode (Opus 4.6 / 4.7 only); backend
// silently drops on unsupported models as a second
// line of defence.
// silently drops on unsupported models as a backstop.
...(params.fastMode &&
providerSupportsFastMode(
externalProvider.providerType,
@ -2230,8 +2209,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
// tool_start: add a part (renders "running").
// tool_end: set result on the part (transitions to "complete").
const toolEvent = (
chunk as unknown as { _toolEvent?: Record<string, unknown> }
)._toolEvent;
@ -2253,8 +2232,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
continue;
}
if (toolEvent.type === "document_citations") {
// Convert Anthropic citations_delta footnotes into
// Sources-panel entries matching the inline [N] markers.
// Convert citations_delta footnotes into Sources-panel
// entries matching the inline [N] markers.
const cits = toolEvent.citations;
if (Array.isArray(cits)) {
cits.forEach((entry, idx) => {
@ -2286,8 +2265,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
continue;
}
if (toolEvent.type === "anthropic_refusal") {
// Latch the backend refusal signal so the final
// message metadata can drive the prune.
// Latch the backend refusal signal so final message
// metadata can drive the prune.
anthropicRefusalSeen = true;
continue;
}
@ -2346,7 +2325,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
imageB64
) {
// Backend keeps base64 on separate image_b64 /
// image_mime fields so logs stay small; repackage.
// image_mime fields so logs stay small; repackage here.
parsedResult = {
image_b64: imageB64,
image_mime:
@ -2359,8 +2338,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
};
} else if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
// Fall back to "_default" to match the backend sandbox
// dir used when no session_id (see tools.py _get_workdir).
const sessionId = sandboxSessionId || "_default";
try {
const images = JSON.parse(
@ -2386,7 +2365,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Merge tool_end native_part into args.google so the
// outbound translator replays both start (executableCode)
// and end (result / inlineData) on the same turn.
// Concatenate parts so each keeps its own thoughtSignature.
// Concatenate so each part keeps its own thoughtSignature.
const endGoogle = (
toolEvent as { google?: { native_part?: unknown } }
).google;
@ -2410,9 +2389,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
string,
unknown
>;
// Extract part entries from either parts:[...] or
// legacy single-object native_part. Legacy
// thoughtSignature always belongs on executableCode.
// Extract part entries from parts:[...] or legacy
// single-object native_part. Legacy thoughtSignature
// always belongs on executableCode.
const collectParts = (
native: Record<string, unknown>,
): Record<string, unknown>[] => {
@ -2466,8 +2445,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
};
}
}
// Cumulative yield. orderAssistantContent puts search/
// code before text and generated images after.
// Cumulative yield; orderAssistantContent puts search/code
// before text and generated images after.
const textParts = pinTextThoughtSignature(
parseAssistantContent(cumulativeText),
);
@ -2485,7 +2464,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
continue;
}
// OpenAI-standard usage chunk: choices=[], usage populated
// OpenAI-standard usage chunk: choices=[], usage populated.
if (chunk.choices?.length === 0 && chunk.usage) {
serverMetadata = {
usage: chunk.usage,
@ -2497,8 +2476,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
totalChunks += 1;
// Latch the chunk's `model` field so the openrouter/free
// chip can show the chosen underlying model.
// Latch the chunk's `model` field so the openrouter/free chip
// shows the chosen underlying model.
if (
isExternalRequest &&
externalProvider?.providerType === "openrouter" &&
@ -2517,7 +2496,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
}
const rawDelta = chunk.choices?.[0]?.delta?.content;
// Normalize structured delta.content (mistral magistral) to text.
// Normalize structured delta.content (mistral magistral).
const delta = extractDeltaText(rawDelta);
// Latest Gemini text-part thoughtSignature for next-turn replay.
const deltaExtraContent = (
@ -2539,15 +2518,15 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
}
}
// Kimi / DeepSeek stream thinking via delta.reasoning_content.
// Wrap inline as <think>...</think> for parseAssistantContent.
// Kimi / DeepSeek stream thinking via delta.reasoning_content;
// wrap inline as <think>...</think> for parseAssistantContent.
const rawReasoning = (
chunk.choices?.[0]?.delta as
| { reasoning_content?: unknown }
| undefined
)?.reasoning_content;
// OpenRouter ships reasoning as delta.reasoning_details[]
// regardless of underlying provider; merge into the same wrap path.
// regardless of provider; merge into the same wrap path.
const rawReasoningDetails = (
chunk.choices?.[0]?.delta as
| { reasoning_details?: unknown }
@ -2567,7 +2546,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
reasoningFromDetails;
// OpenAI delta.tool_calls: streams fragments by index;
// accumulate into one part. extra_content carries Gemini 3
// thoughtSignature for next-turn replay.
// thoughtSignature for replay.
const rawDeltaToolCalls = (
chunk.choices?.[0]?.delta as
| { tool_calls?: unknown }
@ -2588,9 +2567,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const idx =
typeof call.index === "number" ? call.index : undefined;
const stableId = call.id;
// Match an existing fragment by id first (canonical),
// then by index slot. Fall back to a freshly-minted
// tool_call_<n> id for streams that send neither.
// Match an existing fragment by id first (canonical), then
// by index slot; fall back to a minted tool_call_<n> id
// for streams that send neither.
let existing = stableId
? toolCallParts.find((p) => p.toolCallId === stableId)
: undefined;
@ -2777,9 +2756,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw streamError;
}
}
// If the stream ended while we were still inside a
// delta.reasoning_content block (Kimi / DeepSeek path), close
// the open <think> tag so the reasoning panel parses cleanly.
// If the stream ended inside a delta.reasoning_content block
// (Kimi / DeepSeek), close the open <think> tag so the reasoning
// panel parses cleanly.
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
@ -2787,9 +2766,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
settleFirstTokenOk();
// Extract source parts from completed web_search and web_fetch
// tool calls. Both emit the same `Title:` / `URL:` / `Snippet:`
// block shape from the Anthropic backend, so the parser does
// not need to branch on tool name.
// calls. Both emit the same `Title:` / `URL:` / `Snippet:` block
// shape, so the parser need not branch on tool name.
const sourceParts = toolCallParts.flatMap((tc) => {
if (
(tc.toolName !== "web_search" && tc.toolName !== "web_fetch") ||
@ -2818,8 +2796,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const cacheWriteTokens = meta?.usage?.cache_creation_input_tokens ?? 0;
// Gate on the captured checkpoint still being active so a late
// completion from provider A doesn't populate the bar after the
// user switched to provider B mid-stream.
// completion from provider A doesn't populate the bar after a
// mid-stream switch to provider B.
if (
meta?.usage &&
typeof meta.usage.prompt_tokens === "number" &&
@ -2882,10 +2860,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (!abortSignal.aborted) {
const msg = err instanceof Error ? err.message : String(err);
if (isContextLimitError(msg)) {
// llama-server was launched with --no-context-shift, so it
// returns a hard error instead of silently dropping old
// turns from the KV cache. Point the user at the exact
// control that raises the ceiling.
// llama-server runs with --no-context-shift, returning a hard
// error instead of silently dropping old KV-cache turns. Point
// the user at the control that raises the ceiling.
toast.error("Context limit reached", {
description:
"The conversation has filled the model's context window. " +

View file

@ -141,9 +141,8 @@ export interface DownloadProgressResponse {
expected_bytes: number;
progress: number;
/**
* Resolved on-disk path of the snapshot dir (or cache repo root if no
* snapshot exists yet). Null when nothing has been written to the
* cache for this repo.
* On-disk path of the snapshot dir (or cache repo root if no snapshot yet).
* Null when nothing has been written to the cache for this repo.
*/
cache_path: string | null;
}
@ -168,9 +167,8 @@ export type ModelLoadPhase = "mmap" | "ready" | null;
export interface LoadProgressResponse {
/**
* Load phase: ``"mmap"`` while the llama-server subprocess is paging
* weight shards into RAM, ``"ready"`` once it has reported healthy,
* or ``null`` when no load is in flight.
* Load phase: "mmap" while llama-server pages weight shards into RAM,
* "ready" once healthy, or null when no load is in flight.
*/
phase: ModelLoadPhase;
bytes_loaded: number;
@ -179,10 +177,9 @@ export interface LoadProgressResponse {
}
/**
* Fetch the active GGUF load's mmap/upload progress. Complements
* ``getDownloadProgress`` / ``getGgufDownloadProgress`` for the window
* between "download complete" and "chat ready", which for large MoE
* models can be several minutes of otherwise-opaque spinning.
* Fetch the active GGUF load's mmap/upload progress. Complements the download
* progress endpoints for the "download complete" -> "chat ready" window, which
* for large MoE models can be several minutes of otherwise-opaque spinning.
*/
export async function getLoadProgress(): Promise<LoadProgressResponse> {
const response = await authFetch(`/api/inference/load-progress`);
@ -551,16 +548,14 @@ export async function buildBackendChatExport(): Promise<{
return parseJsonOrThrow(response);
}
// Legacy-Dexie import ledger. The server-side source of truth that
// replaces the boolean localStorage sentinel
// (`unsloth_chat_legacy_imported_to_studio_db`) so a studio.db wipe
// makes the import recoverable.
// Legacy-Dexie import ledger: server-side source of truth replacing the
// boolean localStorage sentinel, so a studio.db wipe keeps the import
// recoverable.
export async function listChatImportLedger(): Promise<Set<string>> {
const response = await authFetch("/api/chat/import-ledger");
// Backend deployments that don't have this endpoint yet behave the
// same as an empty ledger -- caller treats every legacy thread as
// un-imported and tries to import. The UPSERT semantics in
// syncChatMessages prevent duplicates, so this fallback is safe.
// Backends without this endpoint behave like an empty ledger -- caller
// re-imports every legacy thread. syncChatMessages UPSERTs prevent
// duplicates, so this fallback is safe.
if (response.status === 404 || response.status === 405) return new Set();
const data = await parseJsonOrThrow<{ threadIds: string[] }>(response);
return new Set(data.threadIds);
@ -569,9 +564,9 @@ export async function listChatImportLedger(): Promise<Set<string>> {
export interface RecordChatImportLedgerResult {
accepted: number;
inserted: number;
// false when the backend predates /api/chat/import-ledger (404/405/501)
// so the caller can avoid poisoning the localStorage perf hint -- the
// next launch will retry the (idempotent) import.
// false when the backend predates /api/chat/import-ledger (404/405/501) so
// the caller avoids poisoning the localStorage perf hint; next launch
// retries the (idempotent) import.
supported: boolean;
}
@ -633,11 +628,10 @@ export async function browseFolders(
if (path !== undefined && path !== null) params.set("path", path);
if (showHidden) params.set("show_hidden", "true");
const qs = params.toString();
// Forward the AbortSignal through authFetch -> fetch so that a
// navigation cancelled in the FolderBrowser (rapid breadcrumb / row /
// hidden-toggle clicks) actually cancels the in-flight HTTP request
// server-side, instead of merely dropping the response client-side
// while the backend keeps walking large directory trees.
// Forward the AbortSignal through authFetch -> fetch so a cancelled
// FolderBrowser navigation actually cancels the in-flight request
// server-side, instead of just dropping the response while the backend
// keeps walking large directory trees.
const response = await authFetch(
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
signal ? { signal } : undefined,

View file

@ -2,11 +2,10 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Wrappers for the three OpenAI shell-tool container management
* endpoints exposed by the backend (studio/backend/routes/inference.py).
* Each one proxies to OpenAI's /v1/containers REST surface using the
* user's encrypted API key. Backend rejects any base URL that isn't
* api.openai.com the shell tool only exists on the managed cloud.
* Wrappers for the backend's three OpenAI shell-tool container endpoints
* (studio/backend/routes/inference.py). Each proxies to OpenAI's
* /v1/containers using the user's encrypted API key. Backend rejects any
* base URL but api.openai.com (the shell tool is managed-cloud only).
*/
import { authFetch } from "@/features/auth";
@ -110,9 +109,8 @@ export async function deleteOpenAIContainer(
}),
},
);
// 404 = container already gone (deleted elsewhere, or expired-then-purged).
// Treat as idempotent success so a stale list entry doesn't surface as a
// confusing error — the caller will refresh and the entry will disappear.
// 404 = container already gone; treat as idempotent success so a stale
// list entry doesn't surface as a confusing error.
if (!response.ok && response.status !== 204 && response.status !== 404) {
throw new Error(await parseError(response));
}

View file

@ -140,10 +140,9 @@ export async function deleteProviderConfig(providerId: string): Promise<void> {
const response = await authFetch(`/api/providers/${providerId}`, {
method: "DELETE",
});
// Treat 404 as success: another browser (or tab) already deleted this
// provider on the backend, so locally pruning the stale cache is the
// correct follow-up. Without this, the caller would throw and the user
// would be stuck with an entry they cannot remove from the UI.
// Treat 404 as success: another tab already deleted this provider, so pruning
// the stale cache is correct. Otherwise the caller throws and the user is stuck
// with an entry they cannot remove from the UI.
if (response.status === 404) {
return;
}

View file

@ -46,10 +46,9 @@ function buildHtmlFence(source: string): string {
const fence = "`".repeat(longestBacktickRun + 1);
return `${fence}html\n${source}\n${fence}`;
}
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
// trap. Granting same-origin sandbox privileges would weaken isolation, so
// keyboard users can reach Studio controls here while fully interactive artifact
// content remains a known sandbox limitation.
// Sandboxed artifact iframes are deliberately outside the overlay focus trap:
// granting same-origin sandbox privileges would weaken isolation, so reaching
// interactive artifact content via keyboard is a known sandbox limitation.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

View file

@ -60,9 +60,8 @@ export function ArtifactHtmlFrame({
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
}, [allowNetworkAccess, code]);
const postArtifactHtml = useCallback(() => {
// The sandboxed frame intentionally has an opaque origin ("null").
// A wildcard target is required here;
// the payload is sent only to this iframe's contentWindow.
// Sandboxed frame has an opaque origin ("null"), so a wildcard target is
// required; the payload only reaches this iframe's contentWindow.
iframeRef.current?.contentWindow?.postMessage(
{ type: "unsloth:artifact-html", html: artifactHtml },
"*",

View file

@ -44,8 +44,8 @@ export function hashArtifactCode(code: string): string {
export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const messageSegment = input.sourceMessageId || "transient";
// Backend tool call IDs (call_0, call_1, …) reset per request, so
// the message ID is needed to scope them to a specific turn.
// Backend tool call IDs (call_0, call_1, …) reset per request, so the
// message ID is needed to scope them to a specific turn.
const parts = [input.source, threadSegment, messageSegment];
if (input.source === "tool" && input.sourceToolCallId) {

View file

@ -84,10 +84,9 @@ function isValidAddress(value: string): boolean {
return false;
}
}
// Anything else is treated as a local command (stdio); the backend gates
// whether stdio servers are allowed on this host. Reject other URL schemes
// only when the command itself is a URL; "://" is fine inside an argument
// (e.g. a database connection string passed to the server).
// Otherwise it's a local command (stdio); the backend gates whether those
// are allowed. Reject only when the command itself is a URL; "://" is fine
// inside an argument (e.g. a DB connection string passed to the server).
return !trimmed.split(/\s+/)[0].includes("://");
}

View file

@ -367,9 +367,8 @@ function modelMatchesDeleted(
}
/**
* Detect if this is a LoRA base-vs-fine-tuned compare.
* Returns true when the loaded checkpoint is a LoRA in that case
* we use the fast simultaneous base/lora adapter-toggle path.
* True when the loaded checkpoint is a LoRA, meaning a base-vs-fine-tuned
* compare that uses the fast simultaneous adapter-toggle path.
*/
function useIsLoraCompare(): boolean {
return useChatRuntimeStore((s) => {
@ -424,15 +423,12 @@ const CompareContent = memo(function CompareContent({
});
/**
* A single column in the compare layout. Hosts one ChatRuntimeProvider
* and one Thread rendered with hideComposer the composer is shared
* across both panes and rendered outside the pane flex.
* A single column in the compare layout: one ChatRuntimeProvider and one
* Thread with hideComposer (the composer is shared across panes).
*
* Each pane is a flex item with `flex-1 basis-0 min-h-0 min-w-0` so on
* mobile (flex-col) they share height equally, and on desktop (flex-row)
* they share width equally. The `min-*` constraints are required for
* the inner viewport to scroll internally instead of spilling into the
* page.
* Each pane is `flex-1 basis-0 min-h-0 min-w-0` so panes share height
* (mobile flex-col) or width (desktop flex-row) equally. The `min-*`
* constraints let the inner viewport scroll instead of spilling.
*/
function ComparePane({
modelType,
@ -476,16 +472,13 @@ function ComparePane({
}
/**
* Shared shell for both compare variants. A vertical flex column with
* the two panes as siblings and the shared composer docked at the
* bottom. On mobile the panes stack (flex-col); on desktop they sit
* side by side (md:flex-row).
* Shared shell for both compare variants: a flex column with the two panes
* as siblings and the shared composer docked at the bottom. Panes stack on
* mobile (flex-col), sit side by side on desktop (md:flex-row).
*
* Flex is used rather than CSS grid for the pane container so that
* viewport sizing stays stable across viewport-size transitions. Grid
* rows with 1fr were triggering resize thrash in assistant-ui's
* autoscroll hook on breakpoint crossings, leaving it stuck in a
* scroll-to-bottom loop.
* Flex, not grid, for the pane container: grid rows with 1fr triggered
* resize thrash in assistant-ui's autoscroll on breakpoint crossings,
* leaving it stuck in a scroll-to-bottom loop.
*/
function CompareShell({
handlesRef,
@ -594,10 +587,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
});
/**
* Per-pane header rendered inside GeneralCompareContent. Contains the
* model selector aligned with the global topbar height. The left pane
* reserves room for the mobile sidebar trigger; the right pane reserves
* room for the global settings button.
* Per-pane header (inside GeneralCompareContent) with the model selector,
* aligned to the global topbar height. Left pane reserves room for the
* mobile sidebar trigger; right pane for the global settings button.
*/
function GeneralCompareHeader({
models,
@ -1087,9 +1079,8 @@ export function ChatPage(): ReactElement {
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
// Tracks the latest non-compare view so exiting compare can restore it even
// when compare was opened from a path that does not set viewBeforeCompareRef
// (e.g. the composer + menu).
// Latest non-compare view, so exiting compare can restore it even when
// compare was opened from a path that doesn't set viewBeforeCompareRef.
const lastNonCompareViewRef = useRef<ChatSearch | null>(null);
useEffect(() => {
if (!search.compare) {
@ -1249,15 +1240,11 @@ export function ChatPage(): ReactElement {
preferredEffort,
effortLevels,
);
// Per-provider default effort. Anthropic gets the highest available
// level (xhigh on 4.6/4.7, high on 4.5) since Claude's adaptive
// thinking adjusts cost per turn — sitting at the top of the dial
// gives users the strongest answers and the model can still skip
// thinking when the turn is trivial. OpenAI gets "high" by default
// — the gpt-5.x reasoning models accept high across the board and
// it's the right cost/quality sweet spot for Responses-API tools
// (web search included). Everyone else gets "medium" as a balanced
// default. Users can pick another level via the Think dropdown.
// Per-provider default effort. Anthropic gets the highest level since
// Claude's adaptive thinking adjusts cost per turn (top of dial =
// strongest answers, still skips thinking when trivial). OpenAI gets
// "high" (gpt-5.x accept it across the board; good cost/quality for
// Responses-API tools). Everyone else "medium". Overridable via Think.
const isAnthropic = provider?.providerType === "anthropic";
const isOpenAI = provider?.providerType === "openai";
const anthropicTopEffort = effortLevels.includes("xhigh")
@ -1298,19 +1285,15 @@ export function ChatPage(): ReactElement {
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
provider?.providerType,
);
// Kimi's k2.6/k2.5 default to thinking enabled on the server side
// (per https://platform.kimi.ai/docs/models). Mirror that default
// in the UI so the Think pill comes up clicked when the user picks
// a Kimi model. The Search pill stays off by default; the mutual-
// exclusion handlers in the composer flip the two when needed.
// Kimi's k2.6/k2.5 default to thinking enabled server-side (per
// https://platform.kimi.ai/docs/models). Mirror that so the Think pill
// comes up clicked for Kimi models. Search stays off; the composer's
// mutual-exclusion handlers flip the two when needed.
const isKimi = provider?.providerType === "kimi";
// Web search is on by default for the two providers we trust most
// for it: Anthropic (web_search_20250305 server tool, structured
// citations) and OpenAI (/v1/responses web_search, structured
// citations). Other providers stay off-by-default — OpenRouter's
// plugins shape and Kimi's $web_search builtin still work when the
// user opts in via the pill, but they're a notch less reliable so
// we don't pre-enable them.
// Web search on by default only for the two providers we trust most:
// Anthropic and OpenAI (both with structured citations). Others stay
// off-by-default; OpenRouter and Kimi work on opt-in but are less
// reliable, so we don't pre-enable them.
const searchOnByDefault =
supportsBuiltinWebSearch &&
(provider?.providerType === "anthropic" ||
@ -1345,15 +1328,11 @@ export function ChatPage(): ReactElement {
: true
: state.reasoningEnabled,
supportsPreserveThinking: false,
// External models never give us a local tool runtime (no
// python sandbox), so `supportsTools` must be false. The three
// `supportsBuiltin*` flags pick up the slack for providers that
// run the tool server-side: `supportsBuiltinWebSearch` lights
// up the Search pill (OpenAI / Anthropic / OpenRouter / Kimi),
// `supportsBuiltinCodeExecution` lights up the Code pill
// (Anthropic Claude 4.x and OpenAI gpt-5.5), and
// `supportsBuiltinImageGeneration` lights up the Images pill
// (OpenAI cloud Responses-API models only).
// External models have no local tool runtime, so `supportsTools` is
// false. The `supportsBuiltin*` flags cover providers that run tools
// server-side: WebSearch lights the Search pill (OpenAI/Anthropic/
// OpenRouter/Kimi), CodeExecution the Code pill (Claude 4.x, gpt-5.5),
// ImageGeneration the Images pill (OpenAI cloud Responses-API only).
supportsTools: false,
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
@ -1485,9 +1464,9 @@ export function ChatPage(): ReactElement {
useEffect(() => {
if (view.mode !== "single") return;
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
// view intentionally excludes __LOCALID_ threads (they fall through to
// { mode: "single" } with no threadId/nonce). Don't close an artifact
// whose thread is the currently active local thread.
// view excludes __LOCALID_ threads (they fall through to mode:"single"
// with no threadId/nonce). Don't close an artifact whose thread is the
// active local thread.
if (
selectedArtifact.threadId &&
selectedArtifact.threadId === activeThreadId
@ -1593,9 +1572,8 @@ export function ChatPage(): ReactElement {
preferredEffort,
effortLevels,
);
// Same per-provider default policy as the useEffect path above:
// Anthropic picks the highest available level, OpenAI picks
// "high", everyone else picks "medium".
// Same per-provider default policy as the useEffect above:
// Anthropic highest level, OpenAI "high", everyone else "medium".
const isAnthropic = selectedProvider?.providerType === "anthropic";
const isOpenAI = selectedProvider?.providerType === "openai";
const anthropicTopEffort = effortLevels.includes("xhigh")
@ -1617,9 +1595,8 @@ export function ChatPage(): ReactElement {
? "medium"
: clampedEffort
: store.reasoningEffort;
// Clear any cached router-picked openrouter/free model unless the
// user is staying on openrouter/free — otherwise the chip would
// keep showing a stale ":<chosen>" suffix from a previous model.
// Clear any cached router-picked openrouter/free model unless staying
// on openrouter/free, else the chip keeps a stale ":<chosen>" suffix.
const stillOnOpenRouterFree =
selectedProvider?.providerType === "openrouter" &&
selectedExternal?.modelId === "openrouter/free";
@ -1644,13 +1621,12 @@ export function ChatPage(): ReactElement {
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
selectedProvider?.providerType,
);
// See sibling useEffect above: Kimi's k2.x default to thinking
// enabled, so the Think pill comes up clicked. Search pill stays
// off by default; mutual exclusion flips them via the composer.
// See sibling useEffect: Kimi's k2.x default to thinking enabled
// (Think pill clicked). Search stays off; the composer's mutual
// exclusion flips them.
const isKimi = selectedProvider?.providerType === "kimi";
// Mirror of sibling useEffect: Anthropic and OpenAI get Search
// on-by-default since their server tools emit structured
// citations end-to-end. OpenRouter and Kimi stay off-by-default.
// Mirror of sibling useEffect: Anthropic/OpenAI get Search on by
// default (structured citations end-to-end); others stay off.
const searchOnByDefault =
supportsBuiltinWebSearch &&
(selectedProvider?.providerType === "anthropic" ||
@ -1676,9 +1652,8 @@ export function ChatPage(): ReactElement {
ggufMaxContextLength: null,
ggufNativeContextLength: null,
activeNativePathToken: null,
// Clear previous-model counters; the relaxed external-provider
// render gate would otherwise show stale stats until the next
// completion overwrites them.
// Clear previous-model counters, else the relaxed external-provider
// render gate shows stale stats until the next completion.
contextUsage: null,
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
@ -1694,14 +1669,10 @@ export function ChatPage(): ReactElement {
: true
: store.reasoningEnabled,
supportsPreserveThinking: false,
// External models have no local tool runtime → supportsTools
// stays false. The three supportsBuiltin* flags carry the
// server-side capability info for each pill:
// - Search → providerSupportsBuiltinWebSearch
// - Code → providerSupportsBuiltinCodeExecution
// (Anthropic Claude 4.x + OpenAI gpt-5.5)
// - Images → providerSupportsBuiltinImageGeneration
// (OpenAI cloud Responses-API models)
// External models have no local tool runtime → supportsTools false.
// The supportsBuiltin* flags carry server-side capability per pill:
// Search, Code (Claude 4.x + gpt-5.5), Images (OpenAI cloud
// Responses-API).
supportsTools: false,
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
@ -1813,8 +1784,8 @@ export function ChatPage(): ReactElement {
}, [currentProjectId, navigate, search]);
const exitCompare = useCallback(() => {
// Prefer the explicit save; fall back to the last non-compare view so the
// composer + menu path also returns to where the user started.
// Prefer the explicit save; fall back to the last non-compare view so
// the composer + menu path also returns where the user started.
const saved = viewBeforeCompareRef.current ?? lastNonCompareViewRef.current;
// No saved view (compare opened by direct URL); fall back to a fresh chat.
if (!saved) {
@ -1823,9 +1794,8 @@ export function ChatPage(): ReactElement {
}
viewBeforeCompareRef.current = null;
navigate({ to: "/chat", search: saved });
// Restore usage from the last assistant message, but only if it
// matches the currently active checkpoint. Without this guard the
// relaxed render gate would show stale stats from another model.
// Restore usage from the last assistant message, only if it matches the
// active checkpoint, else the relaxed render gate shows stale stats.
const threadId =
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
if (threadId) {
@ -1845,7 +1815,7 @@ export function ChatPage(): ReactElement {
const usageModelId =
(usage as { modelId?: unknown }).modelId;
// Scope by modelId when present; reject if no active checkpoint
// (model-scoped usage cannot be attributed to "nothing").
// (model-scoped usage can't be attributed to "nothing").
if (typeof usageModelId === "string" && usageModelId) {
if (!activeCheckpoint || usageModelId !== activeCheckpoint) {
return;
@ -1895,15 +1865,13 @@ export function ChatPage(): ReactElement {
.flatMap((provider) =>
provider.models.map((model) => {
// For OpenRouter's free router we know which underlying free
// model the gateway actually picked once a stream completes
// (chat-adapter latches `chunk.model` into the runtime store).
// Render the chip as `openrouter:<short-chosen>` — drop the
// redundant `/free` from the router id and the org prefix
// from the chosen id (e.g.
// openrouter/free + inclusionai/ring-2.6-1t-20260508:free
// -> openrouter:ring-2.6-1t-20260508:free
// ). The `:free` suffix on the chosen id already conveys
// 'free model', so the leading `/free` is noise.
// model the gateway picked once a stream completes (chat-adapter
// latches `chunk.model`). Render the chip as
// `openrouter:<short-chosen>`, dropping the redundant `/free`
// and the chosen id's org prefix (e.g. openrouter/free +
// inclusionai/ring-2.6-1t-20260508:free ->
// openrouter:ring-2.6-1t-20260508:free). The `:free` suffix
// already conveys "free model".
let displayName = model;
if (
provider.providerType === "openrouter" &&
@ -2117,8 +2085,8 @@ export function ChatPage(): ReactElement {
<GuidedTour {...tour.tourProps} />
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<NativeModelDropOverlay state={nativeModelDropState} />
{/* Bottom fade under the top bar so messages dissolve as they scroll
beneath it (Gemini / unsloth-sidebar style), instead of a hard cut. */}
{/* Fade under the top bar so messages dissolve as they scroll
beneath it, instead of a hard cut. */}
{view.mode !== "compare" && (
<div
aria-hidden

View file

@ -326,9 +326,8 @@ export function ChatProvidersSettings({
return;
}
// Seed default_models only for curated providers (catalog too large to
// enumerate — defaults are the suggestion shortlist). Remote-mode cloud
// providers and local OpenAI-compat presets stay empty until the user
// clicks "Load available models".
// enumerate). Remote cloud providers and local OpenAI-compat presets stay
// empty until the user clicks "Load available models".
const seedDefaults = entry.model_list_mode === "curated";
setAvailableModels(seedDefaults ? [...entry.default_models] : []);
setSelectedModelIds([]);
@ -425,10 +424,9 @@ export function ChatProvidersSettings({
updatedAt,
};
});
// Trust the backend response when it succeeds. An empty array means
// every connection was removed (often from another browser/tab) and
// the local cache should mirror that, otherwise the stale entries
// become un-removable in this browser until localStorage is cleared.
// Trust the backend response. An empty array means every connection was
// removed (often from another tab); mirror that locally, else stale
// entries become un-removable here until localStorage is cleared.
onProvidersChange(syncedProviders);
} catch (error) {
// Only surface a toast for real failures, not for the silent

View file

@ -127,19 +127,11 @@ export function InfoHint({ children }: { children: ReactNode }) {
}
/**
* Editable numeric value display.
*
* Renders as a single <input> that *looks* like text by default
* transparent background, no border, no ring and only shows a faint
* surface tint on hover/focus to signal editability. When unfocused,
* the input shows the formatted display string (`displayValue ?? value`,
* so labels like "Off" / "Max" still render); on focus, it switches to
* the raw numeric value, selects it, and accepts free text input.
* Commit happens on blur or Enter; Escape reverts. The clamp-to-range
* happens on commit so users can type intermediate values without the
* input fighting them mid-keystroke. Single component shared by every
* slider value and the Context Length input so the click-to-edit
* affordance is consistent across the panel.
* Editable numeric value display, shared by every slider value and the Context
* Length input. An <input> that looks like text (shows `displayValue ?? value`,
* so "Off"/"Max" labels render) until focus, when it swaps to the raw number,
* selects it, and accepts free text. Commits on blur/Enter, reverts on Escape.
* Clamping happens on commit so typing intermediate values isn't fought.
*/
function snapToStep(
value: number,
@ -205,7 +197,7 @@ function NumericValueInput({
cancelBlurCommitRef.current = false;
setDraft(String(value));
setFocused(true);
// Defer the select() so it runs after the value swap above.
// Defer select() so it runs after the value swap above.
const target = e.currentTarget;
requestAnimationFrame(() => target.select());
}}
@ -331,10 +323,9 @@ function CollapsibleSection({
}: {
label: string;
/**
* When set, the label text becomes an external link (e.g. to the feature's
* GitHub PR) instead of part of the collapse toggle. The chevron still
* toggles open/close, so we render the two as siblings rather than nesting
* an <a> inside the <button> (invalid HTML).
* When set, the label becomes an external link (e.g. the feature's GitHub PR)
* instead of part of the toggle. The chevron still toggles, so link and button
* are siblings rather than an <a> nested in a <button> (invalid HTML).
*/
labelHref?: string;
children?: ReactNode;
@ -412,17 +403,16 @@ interface ChatSettingsPanelProps {
onParamsChange: (params: InferenceParams) => void;
isExternalModel?: boolean;
/**
* Sampling-param capability set for the active external provider, or `null`
* for local models (in which case every knob is rendered). Drives the
* per-param visibility in the sampling section.
* Sampling-param capabilities for the active external provider, or `null` for
* local models (every knob rendered). Drives per-param sampling visibility.
*/
providerCapabilities?: ProviderCapabilities | null;
activeExternalProvider?: ExternalProviderConfig | null;
onExternalProviderChange?: (provider: ExternalProviderConfig) => void;
/**
* Backend provider type for the active external model (e.g. "kimi",
* "anthropic", "openai"), or `null` for local models. Drives the
* per-provider Max Tokens floor in the slider.
* "anthropic", "openai"), or `null` for local models. Drives the per-provider
* Max Tokens floor in the slider.
*/
externalProviderType?: string | null;
onReloadModel?: () => void;
@ -440,9 +430,8 @@ export function ChatSettingsPanel({
externalProviderType = null,
onReloadModel,
}: ChatSettingsPanelProps) {
// For non-external (local) models we show every knob — providerCapabilities
// is only consulted when `isExternalModel` is true. An external model with an
// unknown provider falls back to the OpenAI-compat shape via
// Local models show every knob; providerCapabilities is only consulted when
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
// getProviderCapabilities, so these flags never undercount support.
const showTemperature =
!isExternalModel || Boolean(providerCapabilities?.temperature);
@ -724,8 +713,7 @@ export function ChatSettingsPanel({
const settingsContent = (
<>
<div className="flex h-full min-h-0 flex-col">
{/* Header sits outside the scroll area so the scrollbar never shifts the
close button. */}
{/* Header is outside the scroll area so the scrollbar never shifts the close button. */}
<div className="flex h-[48px] shrink-0 items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
{isMobile ? (
<span className="flex h-[34px] flex-1 items-center text-[16px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">

View file

@ -28,7 +28,7 @@ function getSeverityColor(percent: number): {
export const ContextUsageBar: FC<{
used: number;
// null on external providers (no known window); bar then hides the ratio.
// null on external providers (unknown window); bar hides the ratio.
total?: number | null;
cached?: number;
// Anthropic-only (billed at the write premium).

View file

@ -17,15 +17,10 @@ function clampProgress(value: number): number {
}
/**
* Split a composed progress label like
* "22.8 of 122.3 GB • 330.1 MB/s • 5m 9s left"
* into a primary chunk ("22.8 of 122.3 GB") that can sit next to the
* percent, and a secondary chunk ("330.1 MB/s • 5m 9s left") that can
* live on its own row. This keeps either line from overflowing into a
* ragged wrap when the rate/ETA part shows up mid-download.
*
* Labels without " • " (e.g. "22.8 GB downloaded") are returned
* primary-only, so the secondary row simply doesn't render.
* Split a composed progress label like "22.8 of 122.3 GB 330.1 MB/s 5m 9s
* left" into a primary chunk (next to the percent) and a secondary chunk (its
* own row), so neither line wraps raggedly once rate/ETA appears mid-download.
* Labels without " • " return primary-only, so the secondary row doesn't render.
*/
function splitProgressLabel(
label: string | null | undefined,
@ -46,8 +41,7 @@ export function ModelLoadDescription({
progressLabel,
}: ModelLoadDescriptionProps) {
const hasProgress = typeof progressPercent === "number";
// Split once at the top of the render so the JSX below stays flat --
// no IIFE required. splitProgressLabel is a trivial string op.
// Split once at the top so the JSX below stays flat (no IIFE).
const { primary: labelPrimary, secondary: labelSecondary } =
splitProgressLabel(progressLabel);
@ -116,9 +110,8 @@ export function ModelLoadInlineStatus({
className="flex shrink-0 items-center gap-1 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80"
title={progressLabel ?? undefined}
>
{/* Inline layout is horizontal and tight -- show only the
primary (bytes) chunk; the full label (with rate/ETA)
stays available via the tooltip. */}
{/* Tight inline layout: show only the primary (bytes) chunk;
full label (rate/ETA) stays in the tooltip. */}
<span>{splitProgressLabel(progressLabel).primary}</span>
<span className="tabular-nums">
{Math.round(clampProgress(progressPercent))}%

View file

@ -2,29 +2,21 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Settings-sheet section for OpenAI shell-tool container management.
* Renders only when:
* - active provider is OpenAI cloud (api.openai.com base URL), AND
* - the active model is gpt-5.5 or gpt-5.5-pro (the only families
* where the shell tool is wired through today).
* Settings-sheet section for OpenAI shell-tool container management. Renders
* only when the active provider is OpenAI cloud (api.openai.com) and the model
* is gpt-5.5 / gpt-5.5-pro (the only families wired to the shell tool today).
*
* Surfaces three controls:
* 1. Default container idle-timeout (minutes). Persists on the
* provider record; pre-fills the create dialog and is used by
* the chat-adapter's lazy-create path on the first turn of a
* thread.
* 2. Container picker for the *active thread* pick any of the
* user's existing OpenAI containers, or "Auto-create per thread"
* (default; lets the auto-create path manage it).
* 3. Create-new-container inline form. Refresh + delete actions
* per row.
* Controls:
* 1. Default container idle-timeout (minutes). Persisted on the provider
* record; pre-fills the create dialog and feeds the chat-adapter's
* lazy-create path on a thread's first turn.
* 2. Container picker for the active thread (an existing OpenAI container or
* auto-create per thread).
* 3. Create-new-container inline form, with per-row refresh + delete.
*
* State persistence:
* - TTL ExternalProviderConfig.openaiContainerTtlMinutes
* - Active container for this thread ThreadRecord.openaiCodeExecContainerId
*
* No new global stores list is fetched on open / refresh and held
* in component state.
* Persistence: TTL -> ExternalProviderConfig.openaiContainerTtlMinutes;
* active container -> ThreadRecord.openaiCodeExecContainerId. No global stores;
* the list is fetched on open / refresh and held in component state.
*/
"use client";
@ -63,10 +55,9 @@ import {
const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1;
const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
// Cadence for re-fetching the container list while the section is
// mounted. OpenAI's container TTL flips at minute granularity, so 30s
// is fast enough that an expired container loses its ACTIVE pill within
// half a minute without hammering /v1/containers.
// Re-fetch cadence while the section is mounted. OpenAI's TTL flips at minute
// granularity, so 30s drops an expired container's ACTIVE pill within half a
// minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
function ageLabel(epochSeconds: number | null | undefined): string {
@ -89,10 +80,9 @@ function shortContainerId(id: string): string {
}
function isContainerRunning(c: OpenAIContainerSummary): boolean {
// OpenAI's containers API reports `status: "running"` while idle TTL is
// valid and `status: "expired"` once the idle window has passed. Treat
// a missing status as running so we don't false-positive on any older
// payloads that didn't include the field.
// OpenAI reports `status: "running"` while idle TTL is valid and "expired"
// afterward. Treat a missing status as running so older payloads without the
// field don't false-positive.
return c.status == null || c.status === "running";
}
@ -114,32 +104,27 @@ export function OpenAICodeExecSection({
const [creating, setCreating] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
// Ids that have been deleted in this session. Once tombstoned, an id
// stays hidden from the picker for the lifetime of the page — OpenAI's
// /containers list can keep returning a freshly-deleted id for an
// undocumented and variable amount of time, and an automatic re-show
// creates more confusion than it solves. Refreshing the page resets
// the tombstone naturally.
// Ids deleted this session. A tombstoned id stays hidden for the page's
// lifetime: OpenAI's /containers list can keep returning a freshly-deleted
// id for an undocumented, variable time, and auto-reshowing it confuses more
// than it helps. A page refresh resets the tombstone.
const [tombstones, setTombstones] = useState<Set<string>>(() => new Set());
// Ids optimistically inserted after a successful create but not yet
// confirmed by a /v1/containers list response. OpenAI's list endpoint
// is eventually consistent — a freshly-created container can be absent
// for several seconds. We render the row immediately with a "Creating"
// pill, then drop it from this set once a refresh sees the id.
// Ids optimistically inserted after a create but not yet confirmed by a
// /v1/containers list. That endpoint is eventually consistent (a new
// container can be absent for several seconds), so we render the row at once
// with a "Creating" pill and drop it once a refresh sees the id.
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
// Ref mirror so `refresh()` can read the current pending set without
// re-binding when it changes (the callback is in a useEffect dep).
// Ref mirror so `refresh()` reads the current pending set without re-binding
// when it changes (the callback is a useEffect dep).
const pendingIdsRef = useRef<Set<string>>(pendingIds);
useEffect(() => {
pendingIdsRef.current = pendingIds;
}, [pendingIds]);
// One-shot follow-up refresh scheduled after a create, to catch the
// common case where the server list lags the create response by a few
// seconds. Tracked so we can clear it on unmount.
// One-shot follow-up refresh after a create, for when the server list lags
// the create response by a few seconds. Tracked so we clear it on unmount.
const pendingRetryRef = useRef<number | null>(null);
// Target row for the destructive confirmation dialog. Held in state
// (rather than blocking with window.confirm) so the dialog sits inside
// the settings sheet instead of a native browser alert.
// Target row for the delete-confirmation dialog. Held in state (not
// window.confirm) so the dialog sits inside the settings sheet.
const [pendingDelete, setPendingDelete] =
useState<OpenAIContainerSummary | null>(null);
const [deleting, setDeleting] = useState(false);
@ -174,15 +159,15 @@ export function OpenAICodeExecSection({
}, [activeThreadId]);
// Hide just-deleted containers even if OpenAI's list still returns them.
// This is the single chokepoint — every downstream view (sorted picker,
// auto-bind candidate, all-containers list) derives from visibleContainers.
// Single chokepoint: every downstream view (sorted picker, auto-bind
// candidate, all-containers list) derives from visibleContainers.
const visibleContainers = useMemo(() => {
if (tombstones.size === 0) return containers;
return containers.filter((c) => !tombstones.has(c.id));
}, [containers, tombstones]);
// Containers sorted newest-first by lastActiveAt so the dropdown's
// default (auto-bind target) shows up first.
// Newest-first by lastActiveAt so the dropdown default (auto-bind target)
// shows up first.
const sortedContainers = useMemo(
() =>
[...visibleContainers].sort(
@ -191,19 +176,18 @@ export function OpenAICodeExecSection({
[visibleContainers],
);
// First running container by lastActiveAt — the auto-bind target and
// also what we surface visually before Dexie catches up.
// First running container by lastActiveAt: the auto-bind target and what we
// surface visually before Dexie catches up.
const firstRunningContainer = useMemo(
() => sortedContainers.find(isContainerRunning) ?? null,
[sortedContainers],
);
// What the picker should treat as "active" right now. We decouple
// this from `activeContainerId` (Dexie state) so the user immediately
// sees the most-recent running container while the auto-bind effect's
// async write propagates. If the Dexie-bound container has since
// expired, fall back to the first running candidate — the stale-bind
// sweeper below will clear Dexie shortly after.
// What the picker treats as "active" now. Decoupled from `activeContainerId`
// (Dexie state) so the user sees the most-recent running container while the
// auto-bind effect's async write propagates. If the Dexie-bound container
// expired, fall back to the first running candidate; the stale-bind sweeper
// clears Dexie shortly after.
const boundContainer = useMemo(
() => sortedContainers.find((c) => c.id === activeContainerId) ?? null,
[sortedContainers, activeContainerId],
@ -223,8 +207,8 @@ export function OpenAICodeExecSection({
});
const serverIds = new Set(list.map((c) => c.id));
setContainers((prev) => {
// Preserve optimistic inserts the server hasn't acknowledged
// yet so they don't disappear on the reconciling refresh.
// Preserve optimistic inserts the server hasn't acknowledged yet so
// they don't disappear on the reconciling refresh.
const orphans = prev.filter(
(c) => !serverIds.has(c.id) && pendingIdsRef.current.has(c.id),
);
@ -248,11 +232,9 @@ export function OpenAICodeExecSection({
}
}, [apiKey, provider.baseUrl]);
// Fetch once when the section mounts (or provider changes), then
// poll on a low cadence so an expired container's ACTIVE pill clears
// without the user clicking the refresh button. Also re-fetch when
// the tab regains visibility — covers the common case of leaving the
// sheet open across a long idle period.
// Fetch on mount (or provider change), then poll on a low cadence so an
// expired container's ACTIVE pill clears without a manual refresh. Also
// re-fetch when the tab regains visibility (sheet left open while idle).
useEffect(() => {
void refresh();
const interval = window.setInterval(() => {
@ -276,21 +258,17 @@ export function OpenAICodeExecSection({
};
}, [refresh]);
// Auto-bind the active thread to the most-recently-active container
// whenever the thread has none set and at least one container exists
// on the user's OpenAI account. Sorting by `lastActiveAt` matches
// what feels "most recent" from the user's perspective.
// Auto-bind the active thread to the most-recently-active container when the
// thread has none and at least one container exists on the account. Sorting
// by `lastActiveAt` matches what feels "most recent" to the user.
//
// We eagerly materialize the thread row via `ensureThreadRecord` so
// the bind lands before the user has sent a first
// message. This does NOT create anything at OpenAI — only a local
// ThreadRecord — so it does not bypass the user's expectation that
// a fresh OpenAI container is not created until first send.
// `ensureThreadRecord` eagerly materializes the thread row so the bind lands
// before the first message. This creates nothing at OpenAI (only a local
// ThreadRecord), so a fresh OpenAI container is still not created until first
// send.
//
// If `containers` is empty (no OpenAI containers exist yet), this
// effect short-circuits: the picker renders an empty-state hint and
// the chat-adapter's lazy-create path will mint the first container
// on first send.
// If no containers exist yet, this short-circuits: the picker shows an
// empty-state hint and the chat-adapter mints the first container on send.
useEffect(() => {
if (
!activeThreadId ||
@ -330,14 +308,12 @@ export function OpenAICodeExecSection({
const onPick = async (value: string) => {
if (!activeThreadId || !value) return;
// value is always a container id now — the "Auto-create per thread"
// option has been removed in favour of always defaulting to the
// most-recently-active container. The chat-adapter still handles
// the no-containers-exist case (lazy-create on first send).
// value is always a container id now; "Auto-create per thread" was removed
// in favour of defaulting to the most-recently-active container. The
// chat-adapter still handles the no-containers case (lazy-create on send).
//
// ensureThreadRecord materializes the thread row eagerly (modelType
// "base" — settings sheet is single-thread-mode only) so the update
// actually lands when the user hasn't sent a message yet.
// ensureThreadRecord eagerly materializes the thread row (modelType "base":
// settings sheet is single-thread only) so the update lands before send.
try {
await ensureThreadRecord({ threadId: activeThreadId, modelType: "base" });
const updated = await updateStoredChatThread(activeThreadId, {
@ -360,9 +336,9 @@ export function OpenAICodeExecSection({
toast.error("Container name is required");
return;
}
// TTL inherits from the section-level "Idle timeout" control
// there is no per-container override on the form. Read it at
// submit time so a last-second change to the TTL row applies.
// TTL inherits from the section-level "Idle timeout" control (no
// per-container override). Read at submit time so a last-second TTL change
// applies.
const ttlMinutes =
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
setCreating(true);
@ -374,10 +350,9 @@ export function OpenAICodeExecSection({
toast.success(`Created container ${name}`);
setCreateName("");
setCreateOpen(false);
// Optimistic insert + "Creating" pill. OpenAI's /v1/containers
// list endpoint is eventually consistent and can omit the new
// container for several seconds — without this, the row only
// shows up on the next 30s poll or a manual refresh.
// Optimistic insert + "Creating" pill. The /v1/containers list is
// eventually consistent and can omit the new container for seconds;
// without this the row only appears on the next poll or manual refresh.
setContainers((prev) =>
prev.some((c) => c.id === created.id) ? prev : [created, ...prev],
);
@ -387,9 +362,8 @@ export function OpenAICodeExecSection({
next.add(created.id);
return next;
});
// Follow-up refresh ~5s later to reconcile the optimistic row
// with the server's view once /v1/containers catches up. One
// shot; the regular poll covers any longer tail.
// Follow-up refresh ~5s later to reconcile the optimistic row once
// /v1/containers catches up. One shot; the poll covers a longer tail.
if (pendingRetryRef.current != null) {
window.clearTimeout(pendingRetryRef.current);
}
@ -397,9 +371,8 @@ export function OpenAICodeExecSection({
pendingRetryRef.current = null;
void refresh();
}, 5000);
// Auto-bind the just-created container to the active thread.
// ensureThreadRecord first so the bind lands even when the user
// creates a container before sending the first message.
// Auto-bind the new container to the active thread. ensureThreadRecord
// first so the bind lands even if no message has been sent yet.
if (activeThreadId) {
try {
await ensureThreadRecord({
@ -420,8 +393,7 @@ export function OpenAICodeExecSection({
} finally {
setCreating(false);
// Refresh even on failure: the request may have partially succeeded
// server-side (created container, lost response), and a re-fetch
// keeps the picker in sync with OpenAI's actual state.
// (container created, response lost); a re-fetch keeps the picker in sync.
await refresh();
}
};
@ -435,8 +407,8 @@ export function OpenAICodeExecSection({
{ apiKey, baseUrl: provider.baseUrl || null },
id,
);
// Tombstone the id so the picker hides it immediately even if
// OpenAI's list keeps returning it for a while.
// Tombstone the id so the picker hides it at once even if OpenAI's list
// keeps returning it for a while.
setTombstones((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
@ -460,9 +432,8 @@ export function OpenAICodeExecSection({
} finally {
setDeleting(false);
setPendingDelete(null);
// Always refresh so a stale list entry (e.g. container deleted
// elsewhere, or already expired) is purged from the UI even when
// the delete call itself errored.
// Always refresh so a stale list entry (deleted elsewhere or expired) is
// purged even when the delete call errored.
await refresh();
}
};
@ -496,9 +467,8 @@ export function OpenAICodeExecSection({
/>
</div>
{/* Single container list. Clicking a row binds it to the active
thread and the ACTIVE pill marks which one no separate
picker needed. */}
{/* Container list. Clicking a row binds it to the active thread; the
ACTIVE pill marks which one (no separate picker). */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
@ -518,10 +488,9 @@ export function OpenAICodeExecSection({
</Button>
</div>
{sortedContainers.length === 0 ? (
// Quiet placeholder with the same muted border as row cards
// so an empty section doesn't masquerade as an active control.
// The first container is minted by the chat-adapter on first
// send (lazy-create) and appears here after the next refresh.
// Quiet placeholder with the same muted border as row cards so an
// empty section doesn't look like an active control. The first
// container is lazy-created on first send and appears after refresh.
<div className="flex h-9 w-full items-center rounded-md border border-dashed border-border/60 bg-muted/20 px-2 text-xs text-muted-foreground">
None yet - one will be created on first send.
</div>
@ -566,9 +535,8 @@ export function OpenAICodeExecSection({
: undefined
}
>
{/* min-w-0 + truncate keeps long OpenAI container ids
from spilling under the trash button on narrow
settings sheets. */}
{/* min-w-0 + truncate keeps long container ids from
spilling under the trash button on narrow sheets. */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-medium">
@ -619,10 +587,9 @@ export function OpenAICodeExecSection({
)}
</div>
{/* Create new inline single-row edit that visually echoes a
container card. TTL is inherited from the section's top
"Idle timeout" control (no per-container override), which
keeps the form light and avoids a duplicated input. */}
{/* Create new: inline single-row edit echoing a container card. TTL is
inherited from the top "Idle timeout" control (no per-container
override), keeping the form light. */}
{createOpen ? (
<div className="flex items-center gap-1 rounded-md border border-border/60 bg-muted/20 px-1.5 py-1">
<Input

View file

@ -45,9 +45,8 @@ export { db };
/**
* Wraps Dexie liveQuery for React state updates.
*
* Important: include every semantic query input in `deps` (filters, sort keys,
* IDs, etc). `querier` identity is intentionally ignored to avoid re-subscribing
* on every render when callers pass inline functions.
* Include every semantic query input in `deps` (filters, sort keys, IDs). `querier`
* identity is ignored to avoid re-subscribing every render on inline functions.
*/
export function useLiveQuery<T>(
querier: () => Promise<T>,

View file

@ -17,33 +17,29 @@ export interface ExternalProviderConfig {
/** Whether to ask supported hosted providers to use prompt caching. */
enablePromptCaching?: boolean;
/**
* Anthropic prompt-cache TTL bucket. Only meaningful when
* `enablePromptCaching` is true and the provider supports the choice
* (Anthropic today). Maps to `prompt_cache_ttl` on the backend, which
* attaches `cache_control.ttl` to the cache marker. Omitted = inherit
* Anthropic's default 5-minute pool, same as before this knob existed.
* Anthropic prompt-cache TTL bucket. Only meaningful when `enablePromptCaching`
* is true and the provider supports the choice (Anthropic today). Maps to
* backend `prompt_cache_ttl`, which sets `cache_control.ttl` on the cache
* marker. Omitted = inherit Anthropic's default 5-minute pool.
*/
promptCacheTtl?: "5m" | "1h";
/** User-pinned: the loaded vLLM model supports `enable_thinking`. */
isReasoningModel?: boolean;
/**
* Default idle-timeout (in minutes) for newly created OpenAI shell
* containers. Pre-fills the "Create container" dialog and is the
* TTL the auto-create-per-thread path POSTs to /v1/containers with.
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
* Default idle-timeout (minutes) for new OpenAI shell containers. Pre-fills the
* "Create container" dialog and is the TTL the auto-create-per-thread path POSTs
* to /v1/containers. OpenAI's hard default is 20. Only for OpenAI cloud.
*/
openaiContainerTtlMinutes?: number;
createdAt: number;
updatedAt: number;
}
// Gemini supports prompt caching, but the wire flow requires a
// separate POST to /v1beta/cachedContents to create the cache before
// the generateContent call can reference it; the boolean Studio
// currently emits on enable_prompt_caching is not enough on its own.
// Until that two-step orchestration ships we keep the picker off so
// the toggle does not silently no-op for Gemini users. See
// https://ai.google.dev/gemini-api/docs/caching.
// Gemini supports prompt caching, but the wire flow needs a separate POST to
// /v1beta/cachedContents before generateContent can reference the cache; the
// enable_prompt_caching boolean alone isn't enough. Until that two-step flow
// ships, keep the picker off so the toggle doesn't silently no-op for Gemini.
// See https://ai.google.dev/gemini-api/docs/caching.
const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]);
export function supportsProviderPromptCaching(
@ -53,10 +49,9 @@ export function supportsProviderPromptCaching(
}
/**
* Whether the provider lets the user choose between a short and a long
* prompt-cache pool. Anthropic exposes both a 5m and a 1h ephemeral
* pool via `cache_control.ttl`; OpenAI's automatic prompt cache has no
* equivalent user-selectable knob, so it stays off the picker.
* Whether the provider lets the user choose between a short and long prompt-cache
* pool. Anthropic exposes 5m and 1h ephemeral pools via `cache_control.ttl`;
* OpenAI's automatic cache has no equivalent knob, so it stays off the picker.
*/
const PROMPT_CACHE_TTL_PROVIDER_TYPES = new Set(["anthropic"]);
@ -74,8 +69,8 @@ export function isPromptCacheTtl(value: unknown): value is "5m" | "1h" {
return typeof value === "string" && PROMPT_CACHE_TTL_VALUES.has(value as "5m" | "1h");
}
// Provider types that expose the connection-level "reasoning model"
// toggle. vLLM's OpenAI-compat endpoint doesn't advertise this per model.
// Provider types exposing the connection-level "reasoning model" toggle.
// vLLM's OpenAI-compat endpoint doesn't advertise this per model.
const REASONING_TOGGLE_PROVIDER_TYPES = new Set(["vllm"]);
export function supportsProviderReasoningToggle(
@ -245,10 +240,9 @@ export function toExternalBackendProviderType(
providerType: string | null | undefined,
): string | undefined {
if (!providerType) return undefined;
// vLLM's /v1/responses applies the loaded model's chat template, which
// 400s on strict-alternation templates (e.g. Gemma 3). Pass the actual
// type through so the backend routes vLLM to /v1/chat/completions instead
// of the OpenAI Responses path used for gpt-5.x.
// vLLM's /v1/responses applies the loaded model's chat template, which 400s on
// strict-alternation templates (e.g. Gemma 3). Pass the type through so the
// backend routes vLLM to /v1/chat/completions instead of the Responses path.
if (providerType === "vllm") return "vllm";
if (providerType === "ollama") return "ollama";
if (providerType === "llama_cpp") return "llama_cpp";
@ -419,10 +413,8 @@ export function loadExternalProviders(): ExternalProviderConfig[] {
}
}
/**
* Load the raw (encrypted or legacy plaintext) key map from localStorage.
* Values are opaque strings either AES-GCM ciphertext or legacy plaintext.
*/
/** Load the raw key map from localStorage. Values are opaque strings: either
* AES-GCM ciphertext or legacy plaintext. */
function loadRawKeyMap(): Record<string, string> {
if (!canUseStorage()) return {};
try {
@ -457,7 +449,7 @@ export function saveExternalProviders(
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
// Prune keys for removed providers — works on raw ciphertext, no decryption needed
// Prune keys for removed providers (works on raw ciphertext, no decryption)
const allowedIds = new Set(providers.map((provider) => provider.id));
const keys = loadRawKeyMap();
const pruned: Record<string, string> = {};
@ -472,10 +464,7 @@ export function saveExternalProviders(
}
}
/**
* Retrieve a provider API key from localStorage.
* Returns "" if no key is stored.
*/
/** Retrieve a provider API key from localStorage; "" if none stored. */
export function getExternalProviderApiKey(
providerId: string,
): string {
@ -483,9 +472,7 @@ export function getExternalProviderApiKey(
return keys[providerId] ?? "";
}
/**
* Store a provider API key in localStorage.
*/
/** Store a provider API key in localStorage. */
export function setExternalProviderApiKey(
providerId: string,
apiKey: string,

View file

@ -167,11 +167,9 @@ function getTrustRemoteCodeRequiredMessage(modelName: string): string {
return `${modelName} needs custom code enabled to load. Turn on "Enable custom code" in Chat Settings, then try again.`;
}
// Canonicalises any value the backend reports (or persisted state holds)
// onto the five UI-facing modes the Speculative Decoding dropdown
// understands: "auto" / "mtp" / "ngram" / "mtp+ngram" / "off" / null.
// Mirrors backend _canonicalize_spec_mode so old persisted "default" /
// "draft-mtp" / "ngram-mod" / chain values round-trip cleanly.
// Canonicalises any backend/persisted value onto the Speculative Decoding
// dropdown's modes ("auto"/"mtp"/"ngram"/"mtp+ngram"/"off"/null). Mirrors
// backend _canonicalize_spec_mode so legacy persisted values round-trip.
function normalizeSpeculativeType(v: string | null | undefined): string | null {
if (v == null) return null;
const s = String(v).trim().toLowerCase();
@ -276,9 +274,8 @@ export function useChatModelRuntime() {
listLoras(),
]);
// Cancellation can land while the requests above are in flight (e.g. the
// user cancels a load during this refresh). Bail before writing any
// backend state back into the store -- cancelLoading already cleared it.
// Cancellation can land while the requests above are in flight. Bail
// before writing backend state back -- cancelLoading already cleared it.
if (signal?.aborted) return;
setModels(listRes.models.map(toChatModelSummary));
@ -331,12 +328,10 @@ export function useChatModelRuntime() {
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.
// Refresh runs on F5 (needs hydration) and right after a load (store
// already set). For user-configurable params, only hydrate when the
// shadow `loaded*` field is null ("not yet hydrated"); otherwise we'd
// clobber what the load path just applied and revert the user.
const prevState = useChatRuntimeStore.getState();
const clampedReasoningEffort = clampLocalReasoningEffort(
prevState.reasoningEffort,
@ -355,15 +350,12 @@ export function useChatModelRuntime() {
supportsPreserveThinking,
supportsTools,
// Reset per-turn reasoning flag so:
// 1. models that do not support reasoning do not inherit a stale
// off state from a prior model, and
// 2. local reasoning-effort models (where the composer hides
// the Off option via supportsReasoningOff=false) cannot end
// up with reasoningEnabled=false carried over from an
// external model where Off was selected — the composer would
// keep showing "Think: <level>" via effectiveReasoningEnabled,
// but the chat-adapter would omit the kwarg and the Harmony
// template would fall back to its own default effort.
// 1. non-reasoning models don't inherit a stale off state, and
// 2. local reasoning-effort models (Off hidden via
// supportsReasoningOff=false) don't carry reasoningEnabled=false
// from an external model where Off was selected -- the composer
// would still show "Think: <level>" but the adapter would omit
// the kwarg, so Harmony falls back to its default effort.
reasoningEnabled: supportsReasoning
? reasoningStyle === "reasoning_effort"
? true
@ -600,15 +592,12 @@ export function useChatModelRuntime() {
}
if (abortCtrl.signal.aborted) throw new Error("Cancelled");
// Reset Speculative Decoding to Auto whenever the user
// switches to a different model. Spec strategy is a
// per-model decision: a sub-3B non-MTP GGUF that ran with
// "Off" should not carry that choice into a 27B MTP GGUF
// where Auto would auto-promote to draft-mtp. The user can
// still pick a forced mode on the new model; this just
// clears the stale prior-model choice so the backend's
// platform-aware path runs by default. Same applies to
// spec_draft_n_max which is MTP-only.
// Reset Speculative Decoding to Auto on model switch: spec
// strategy is per-model, so a sub-3B non-MTP GGUF's "Off" must
// not carry into a 27B MTP GGUF where Auto auto-promotes to
// draft-mtp. Clears the stale prior choice so the backend's
// platform-aware path runs by default; same for spec_draft_n_max
// (MTP-only). The user can still force a mode on the new model.
if (currentCheckpoint && currentCheckpoint !== modelId) {
useChatRuntimeStore.setState({
speculativeType: null,
@ -852,17 +841,15 @@ export function useChatModelRuntime() {
);
loadToastIdRef.current = toastId;
// Poll download progress for non-cached models (GGUF and non-GGUF).
// Then, once the download wraps (or for already-cached models),
// poll the llama-server mmap phase so "Starting model..." no
// longer looks frozen for several minutes on large MoE models.
// Poll download progress for non-cached models, then (after download
// or for cached models) poll the llama-server mmap phase so "Starting
// model..." doesn't look frozen for minutes on large MoE models.
let progressInterval: ReturnType<typeof setInterval> | null = null;
const expectedBytes =
typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0;
// Rolling window of byte samples for rate / ETA estimation.
// Shared across download + mmap phases so the estimator doesn't
// reset when the phase flips.
// Rolling window of byte samples for rate/ETA estimation, shared
// across download + mmap phases so it survives phase flips.
type Sample = { t: number; b: number };
const MIN_SAMPLES = 3;
const MIN_WINDOW = 3_000; // ms

View file

@ -95,8 +95,8 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
);
// Legacy-only chats can exist before server-side history import finishes.
// Fill just the missing ids from the legacy-aware path instead of issuing
// one request per thread up front.
// Fill only the missing ids via the legacy path instead of one request per
// thread up front.
const missingThreadIds = allThreadIds.filter(
(threadId) => !messagesByThread.has(threadId),
);
@ -163,8 +163,7 @@ export function useChatSearchIndex(enabled: boolean): {
setLoading(true);
buildIndex()
.then((result) => {
// Drop out-of-order responses so a slower rebuild can't clobber
// a fresher one.
// Drop out-of-order responses so a slower rebuild can't clobber a fresher one.
if (cancelled || seq !== requestSeqRef.current) return;
setItems(result);
})

View file

@ -61,9 +61,8 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
return items.sort((a, b) => b.createdAt - a.createdAt);
}
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so
// each quiet window produces at most one O(N) fetch; requestSeq
// discards stale responses.
// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so each quiet
// window produces at most one O(N) fetch; requestSeq discards stale responses.
const SIDEBAR_REFRESH_DEBOUNCE_MS = 300;
export function useChatSidebarItems(options?: {

View file

@ -3,9 +3,8 @@
import { useEffect, useState } from "react";
// Tracks which of the given keys are active and the order in which each became
// active, so opt-in composer pills (Canvas, MCP) render in the order they were
// toggled on rather than a fixed order.
// Tracks which keys are active and their activation order, so opt-in
// composer pills (Canvas, MCP) render in toggle-on order, not a fixed one.
export function usePillActivationOrder(states: Record<string, boolean>): string[] {
const [order, setOrder] = useState<string[]>(() =>
Object.keys(states).filter((key) => states[key]),

View file

@ -2,19 +2,17 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Compute rate (bytes/sec) and ETA (seconds) from a time-series of
* cumulative ``bytes`` values, using a rolling window of recent samples.
* Compute rate (bytes/sec) and ETA (seconds) from a time-series of cumulative
* ``bytes`` values, using a rolling window of recent samples.
*
* Shared between the chat-flow download toast, the training-start
* overlay, and the model-load phase UI. All three have the same shape:
* a counter that rises monotonically from 0 toward ``totalBytes``, polled
* on an interval. The derived stats are identical regardless of whether
* the bytes came from an HTTP download or an mmap page-in.
* Shared by the chat-flow download toast, training-start overlay, and
* model-load phase UI: all three are a counter rising monotonically from 0
* toward ``totalBytes``, polled on an interval, regardless of HTTP download or
* mmap page-in.
*
* Stability rule: ``stable`` stays ``false`` until we've observed at
* least 3 samples spanning 3 seconds. That keeps the UI from flashing
* wildly varying rates during the first tick or two when the denominator
* is effectively zero.
* Stability: ``stable`` stays ``false`` until at least 3 samples spanning >=3s,
* so the UI doesn't flash wild rates during the first tick or two when the
* denominator is ~0.
*/
import { useEffect, useRef, useState } from "react";

View file

@ -2,25 +2,17 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Friendly default names for auto-created OpenAI shell containers.
* Used by the chat-adapter when the lazy-create path fires (Code pill
* on, no thread container yet, user has set a non-default TTL). The
* goal is a human-memorable label like "otter" or "harbor" instead of
* "chat-abc12345" the user can still rename via the Studio-side
* alias map.
* Friendly default names for auto-created OpenAI shell containers, used by the
* chat-adapter's lazy-create path (Code pill on, no thread container, non-default
* TTL). Goal: a memorable label like "otter" instead of "chat-abc12345"; users
* can still rename via the Studio alias map.
*
* The list is curated to:
* - Be unambiguous, non-offensive nouns from natural categories
* (animals, plants, geography, materials, weather).
* - Avoid technical / political / brand words that might read as
* odd in a chat UI.
* - Stay reasonably small so the bundle cost is negligible (~200
* entries × ~7 bytes 1.5 KB).
* The list is curated to be unambiguous, non-offensive nouns from natural
* categories (animals, plants, geography, materials, weather), avoid
* technical/political/brand words, and stay small (~1.5 KB).
*
* Collisions are tolerated the container's real unique key is its
* ``cntr_*`` id, not its name. A short random hex suffix is appended
* to make accidental same-name collisions visually distinct in the
* picker list.
* Collisions are tolerated: the real unique key is the ``cntr_*`` id, not the
* name. A short random hex suffix keeps same-name picks visually distinct.
*/
const WORDS = [
@ -223,20 +215,15 @@ function randomHexSuffix(): string {
) {
return crypto.randomUUID().replace(/-/g, "").slice(0, 4);
}
// Older browser fallback. Math.random is fine here — this is a
// display suffix, not a security token.
// Older-browser fallback. Math.random is fine: display suffix, not a token.
return Math.floor(Math.random() * 0xffff)
.toString(16)
.padStart(4, "0");
}
/**
* Returns a single English-word name with a short random hex suffix.
*
* Example output: "kestrel-3f9c", "harbor-a012".
*
* The suffix keeps containers visually distinguishable in the picker
* when the same word recurs across creations.
* Returns an English-word name with a short random hex suffix (e.g.
* "kestrel-3f9c"), so repeated words stay distinguishable in the picker.
*/
export function pickFriendlyContainerName(): string {
const word = WORDS[Math.floor(Math.random() * WORDS.length)] ?? "container";

View file

@ -80,8 +80,8 @@ const MCP_PRESETS: readonly McpPreset[] = [
},
] as const;
// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset
// toggle reuses its row instead of creating duplicates.
// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset toggle
// reuses its row instead of duplicating.
function normalizeMcpUrl(url: string): string {
return (url || "").trim().toLowerCase().replace(/\/+$/, "");
}
@ -110,8 +110,8 @@ export function McpComposerButton({
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
const [hintKey, setHintKey] = useState<string | null>(null);
// Grey out only when a loaded model lacks tool support; with no model yet MCP
// can still be pre-selected, matching the other composer tools.
// Grey out only when a loaded model lacks tool support; with no model yet,
// MCP can still be pre-selected, like the other composer tools.
const usable = !modelLoaded || supportsTools;
const refresh = useCallback(async () => {
@ -178,10 +178,9 @@ export function McpComposerButton({
}
}
// One dropdown row. Enabled rows get a green underlay and a tick that
// becomes an X on hover so a click removes them. A hint shows as a tooltip
// driven by row hover; the tooltip anchor is pointer-events-none so the whole
// row stays clickable (a Radix TooltipTrigger would swallow the select).
// One dropdown row. Enabled rows get a green underlay and a tick that becomes
// an X on hover (click removes). The hint tooltip anchor is pointer-events-none
// so the row stays clickable (a Radix TooltipTrigger would swallow the select).
const renderRow = (opts: {
key: string;
label: string;

View file

@ -4,13 +4,10 @@
/**
* Per-provider sampling parameter capability matrix.
*
* Values are derived from each provider's published chat-completion docs as of
* 2026-05. They describe which of our UI knobs map cleanly onto the provider's
* request body; the panel hides params a provider does not accept so users
* cannot dial a value that gets silently dropped or rejected.
*
* "Local" models (anything that is not an external provider) are represented by
* a null capability every knob renders for them.
* Derived from each provider's published chat-completion docs (2026-05). The
* panel hides params a provider does not accept so users cannot dial a value
* that gets silently dropped or rejected. "Local" models (non-external) use a
* null capability every knob renders for them.
*/
export interface ProviderCapabilities {
@ -48,9 +45,8 @@ export type ExternalReasoningCapabilities = {
};
/**
* Prefer a stored reasoning effort level that exists in ``effortLevels``,
* mapping legacy "xhigh" to "max" when the model only exposes the latter
* (Claude 4.6 adaptive thinking).
* Pick a stored reasoning effort level that exists in `effortLevels`, mapping
* legacy "xhigh" to "max" when only the latter is exposed (Claude 4.6).
*/
export function clampReasoningEffortToLevels(
preferred: ExternalReasoningCapabilities["reasoningEffortLevels"][number],
@ -122,8 +118,8 @@ const EXTERNAL_MAX_OUTPUT_TOKENS_BY_MODEL: Array<{
/**
* Documented per-model output cap; unknown ids fall back to
* `EXTERNAL_MAX_OUTPUT_TOKENS` (32k). OpenRouter ids are
* `provider/model`; the prefix is stripped before matching.
* `EXTERNAL_MAX_OUTPUT_TOKENS` (32k). OpenRouter `provider/model` ids have the
* prefix stripped before matching.
*/
export function getExternalMaxOutputTokens(
providerType: string | null | undefined,
@ -192,16 +188,14 @@ export function providerSupportsBuiltinWebSearch(
modelId?: string | null | undefined,
baseUrl?: string | null | undefined,
): boolean {
// Gemini ships grounded search via `tools: [{googleSearch: {}}]` on
// every chat-capable model. Most image-tier ids (`-image`,
// `nano-banana`) reject text-tool wiring because the
// responseModalities path is mutually exclusive with text tools, but
// Google explicitly documents Search grounding on the Gemini 3 image
// family (gemini-3-pro-image-preview, gemini-3.1-flash-image-preview,
// nano-banana-pro). Allow Search on those; hide on older image ids.
// Custom Gemini OpenAI-compat proxies (non-Google bases) skip the
// native translator on the backend, so native tool envelopes never
// reach them -- hide the pill there.
// Gemini ships grounded search via `tools: [{googleSearch: {}}]` on every
// chat-capable model. Most image-tier ids reject text-tool wiring (the
// responseModalities path excludes text tools), but Google documents Search
// grounding on the Gemini 3 image family (gemini-3-pro-image-preview,
// gemini-3.1-flash-image-preview, nano-banana-pro), so allow it there and
// hide on older
// image ids. Custom Gemini OpenAI-compat proxies skip the backend's native
// translator, so native tool envelopes never reach them -- hide the pill.
if (providerType === "gemini") {
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
const normalized = modelId?.trim().toLowerCase() ?? "";
@ -247,8 +241,8 @@ export function providerSupportsFastMode(
): boolean {
if (providerType !== "anthropic") return false;
if (!modelId) return false;
// Family boundary ("" or "-") required so IDs like "claude-opus-4-70"
// / "claude-opus-4-7b" do not match.
// Family boundary ("" or "-") required so IDs like "claude-opus-4-70" or
// "claude-opus-4-7b" do not match.
return ANTHROPIC_FAST_MODE_MODEL_PREFIXES.some(
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`),
);
@ -269,15 +263,13 @@ export function providerSupportsFastMode(
* thread's stored `openaiCodeExecContainerId`. Documented at
* https://developers.openai.com/api/docs/guides/tools-shell
*
* Returns false for every other provider. The backend additionally
* gates the OpenAI shell tool on `is_openai_cloud` so custom
* OpenAI-compat servers (ollama / llama.cpp / vLLM) that also report
* `provider_type="openai"` never receive the tool but in practice
* none of those catalogs surface the `gpt-5.5` ids anyway, so the
* frontend prefix match is enough.
* Returns false for every other provider. The backend also gates the OpenAI
* shell tool on `is_openai_cloud` so custom OpenAI-compat servers reporting
* `provider_type="openai"` never receive it; in practice those catalogs don't
* surface the `gpt-5.5` ids, so the frontend prefix match suffices.
*
* v1 wires the tools themselves; file uploads (Anthropic
* `container_upload` / OpenAI `input_file`) are a deliberate follow-up.
* v1 wires the tools only; file uploads (Anthropic `container_upload` / OpenAI
* `input_file`) are a deliberate follow-up.
*/
const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
"claude-opus-4-7",
@ -286,29 +278,26 @@ const ANTHROPIC_CODE_EXECUTION_MODEL_PREFIXES = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
// Deprecated upstream but the registry still exposes the ids, so the
// pill should remain functional for users on those snapshots.
// Deprecated upstream but the registry still exposes these ids; keep the
// pill working for users on those snapshots.
"claude-opus-4-1",
"claude-opus-4",
"claude-sonnet-4",
] as const;
// OpenAI cloud shell-tool gating. Docs only explicitly demonstrate
// gpt-5.5; gpt-5.5-pro is included because the family share the same
// /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
// match doesn't collide with a hypothetical `gpt-5.5-turbo` etc.
// OpenAI cloud shell-tool gating. Docs only show gpt-5.5; gpt-5.5-pro shares
// the same /v1/responses contract. `gpt-5.5-pro` is checked first so the prefix
// match doesn't collide with e.g. a hypothetical `gpt-5.5-turbo`.
const OPENAI_CODE_EXECUTION_MODEL_PREFIXES = [
"gpt-5.5-pro",
"gpt-5.5",
] as const;
/**
* Strict check that a provider configuration points at OpenAI's
* managed cloud (api.openai.com) or Azure OpenAI Foundry
* (*.openai.azure.com), as opposed to a custom OpenAI-compat backend
* (ollama / llama.cpp / vLLM / generic "custom" preset). The shell and
* image-generation tools only exist on cloud backends; sending them to
* anything else 400s the request. Mirror of the backend's
* Strict check that a provider config points at OpenAI managed cloud
* (api.openai.com) or Azure OpenAI Foundry (*.openai.azure.com), not a custom
* OpenAI-compat backend. The shell and image-generation tools exist only on
* cloud backends; sending them elsewhere 400s. Mirrors the backend's
* `_is_openai_family_cloud` host check.
*/
function isOpenAICloudBaseUrl(baseUrl: string | null | undefined): boolean {
@ -357,16 +346,13 @@ export function providerSupportsBuiltinCodeExecution(
}
/**
* Whether the selected external provider/model exposes OpenAI's
* Responses-API server-side image_generation tool. Lit on for OpenAI
* cloud (`api.openai.com`) when the picked model is a Responses-API
* family id (gpt-5.x today). The backend additionally gates on
* `is_openai_cloud`; mirror that here so the pill is hidden on custom
* OpenAI-compat backends (ollama / llama.cpp / vLLM) that report
* `provider_type="openai"` but would 400 on a `{type:"image_generation"}`
* tool. See backend/core/inference/external_provider.py near line 2770
* for the dispatch and backend/tests/test_openai_image_generation.py
* for the round-trip coverage.
* Whether the selected provider/model exposes OpenAI's Responses-API
* image_generation tool. On for OpenAI cloud (`api.openai.com`) with a
* Responses-API family id (gpt-5.x). Mirrors the backend's `is_openai_cloud`
* gate so the pill hides on custom OpenAI-compat backends reporting
* `provider_type="openai"` that would 400 on a `{type:"image_generation"}` tool.
* See backend/core/inference/external_provider.py (~line 2770) for dispatch and
* backend/tests/test_openai_image_generation.py for round-trip coverage.
*/
const OPENAI_IMAGE_GENERATION_MODEL_PREFIXES = [
"gpt-5.5-pro",
@ -394,14 +380,11 @@ export function providerSupportsBuiltinImageGeneration(
);
}
if (providerType === "gemini") {
// Gemini's Nano Banana image-output ids carry either `-image` (e.g.
// `gemini-2.5-flash-image`, `gemini-3.1-flash-image-preview`) or the
// `nano-banana` alias (`nano-banana-pro-preview`). The backend flips
// generationConfig.responseModalities to ["TEXT", "IMAGE"] when one
// is picked, and translates inlineData parts into the same image_b64
// tool_end envelope the OpenAI path emits so the chat UI renders the
// picture inline. Custom Gemini OpenAI-compat proxies skip the
// native translator on the backend, so hide the image pill there.
// Gemini Nano Banana image-output ids carry `-image` or the `nano-banana`
// alias. The backend flips responseModalities to ["TEXT", "IMAGE"] and maps
// inlineData parts into the same image_b64 tool_end envelope as the OpenAI
// path so the UI renders inline. Custom Gemini OpenAI-compat proxies skip
// the native translator, so hide the image pill there.
// See https://ai.google.dev/gemini-api/docs/image-generation.
if (isGeminiCustomOpenAICompatBase(baseUrl)) return false;
return normalized.includes("-image") || normalized.includes("nano-banana");
@ -420,13 +403,11 @@ function isGeminiImageModel(modelId: string): boolean {
}
/**
* Whether the saved Gemini connection points at a custom
* OpenAI-compatible gateway (any non-Google host). The backend
* `_is_openai_compatible` mirrors this to route those connections
* through `/chat/completions` instead of the native translator, so
* native Gemini tool envelopes (googleSearch, codeExecution,
* responseModalities) never reach them. Hide the corresponding
* Studio pills here so the request, builder, and UI agree.
* Whether the saved Gemini connection points at a custom OpenAI-compat gateway
* (any non-Google host). The backend `_is_openai_compatible` routes these
* through `/chat/completions` instead of the native translator, so native Gemini
* tool envelopes never reach them. Hide the matching Studio pills here so the
* request, builder, and UI agree.
*/
export function isGeminiCustomOpenAICompatBase(
baseUrl: string | null | undefined,
@ -458,17 +439,13 @@ function geminiImageModelAllowsGoogleSearch(modelId: string): boolean {
}
/**
* Per-provider minimum on the outbound max_tokens. Kimi's docs require
* `max_tokens >= 16000` whenever a thinking model is in use so the
* reasoning_content and final answer both fit in the budget anything
* lower truncates the response mid-stream. Other providers don't have a
* documented floor, so they fall through to the generic min of 64 in
* the slider.
* Per-provider minimum on the outbound max_tokens. Kimi requires
* `max_tokens >= 16000` for thinking models so reasoning_content and the answer
* both fit; lower truncates mid-stream. Others fall through to the generic 64.
*
* The chat-adapter resolves the effective floor on send and bumps the
* outbound max_tokens up to this value if the user's stored maxTokens
* sits below it. The settings panel reflects the same floor as the
* slider min so the displayed value never drifts from what's sent.
* The chat-adapter resolves the floor on send and bumps maxTokens up to it if
* the stored value is below; the settings slider min reflects the same floor so
* the displayed value never drifts from what's sent.
*/
const EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER: Record<string, number> = {
kimi: 16000,
@ -528,11 +505,11 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
presencePenalty: false,
},
mistral: OPENAI_COMPAT_BASE,
// Gemini's native generationConfig accepts temperature, topP, topK and
// presencePenalty (plus a separate frequencyPenalty we do not surface
// today). minP and repetitionPenalty are not part of the contract --
// see https://ai.google.dev/api/rest/v1beta/GenerationConfig. Backend
// request shaping lives in _stream_gemini in
// Gemini's generationConfig accepts temperature, topP, topK and
// presencePenalty (plus a frequencyPenalty we don't surface). minP and
// repetitionPenalty aren't part of the contract --
// see https://ai.google.dev/api/rest/v1beta/GenerationConfig. Backend shaping
// lives in _stream_gemini in
// studio/backend/core/inference/external_provider.py.
gemini: {
temperature: true,
@ -542,11 +519,9 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
repetitionPenalty: false,
presencePenalty: true,
},
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and
// top_p to fixed defaults and 400s on any other value:
// "invalid temperature: only 1 is allowed for this model".
// Hide both sliders so the user is not offered knobs the model
// silently overrides. Backend additionally strips these fields via
// Kimi k2.5/k2.6 are reasoning-class — the API locks temperature and top_p to
// fixed defaults and 400s on any other value ("invalid temperature: only 1 is
// allowed for this model"). Hide both sliders. Backend also strips these via
// PROVIDER_REGISTRY['kimi']['body_omit'].
kimi: {
temperature: false,
@ -570,9 +545,9 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
// OpenRouter silently drops params the target model does not support, so we
// surface every knob and let the gateway handle the per-model fan-out.
openrouter: ALL_SUPPORTED,
// Local OpenAI-compatible connections are proxied through the OpenAI backend
// path, but vLLM/Ollama/llama.cpp users often want top_k / min_p /
// repetition controls, so be permissive.
// Local OpenAI-compat connections go through the OpenAI backend path, but
// vLLM/Ollama/llama.cpp users often want top_k/min_p/repetition controls, so
// be permissive.
custom: ALL_SUPPORTED,
vllm: ALL_SUPPORTED,
ollama: ALL_SUPPORTED,
@ -733,11 +708,11 @@ function withReasoningEffortStyle(caps: ReasoningCaps): ExternalReasoningCapabil
}
function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCapabilities {
// Kimi exposes a boolean thinking toggle rather than an effort scale.
// - kimi-k2.6: thinking enabled by default, toggleable
// via extra_body: {thinking: {type: enabled|disabled}}
// - kimi-k2-thinking: thinking always on, no off switch
// - kimi-k2.5 (and anything else): no thinking
// Kimi exposes a boolean thinking toggle, not an effort scale.
// - kimi-k2.6: on by default, toggleable via
// extra_body: {thinking: {type: enabled|disabled}}
// - kimi-k2-thinking: always on, no off switch
// - kimi-k2.5 (and others): no thinking
if (modelId === "kimi-k2-thinking") {
return withEnableThinkingStyle({
supportsReasoning: true,
@ -754,16 +729,13 @@ function resolveKimiReasoningCapabilities(modelId: string): ExternalReasoningCap
}
// Gemini's thinking ladder.
// - Gemini 3.x (3 / 3.1 / 3.5, Pro + Flash + Flash-Lite) and the
// gemini-pro-latest / gemini-flash-latest aliases use the new
// `thinkingConfig.thinkingLevel` string field (LOW/MEDIUM/HIGH/
// MINIMAL). Pro tier rejects MINIMAL.
// - Gemini 2.5 Flash + 2.5 Pro stay on the integer
// `thinkingConfig.thinkingBudget` (0=off on Flash, -1=dynamic,
// N>0=cap; Pro rejects 0).
// - 2.5 Flash-Lite: no native thinking surfaced; leave it off.
// - Image-tier ids (`*-image*`, `nano-banana-pro-preview`): image
// generation path -- no reasoning controls.
// - Gemini 3.x (Pro + Flash + Flash-Lite) and the gemini-pro-latest /
// gemini-flash-latest aliases use the string `thinkingConfig.thinkingLevel`
// (LOW/MEDIUM/HIGH/MINIMAL); Pro tier rejects MINIMAL.
// - Gemini 2.5 Flash + 2.5 Pro use the integer `thinkingConfig.thinkingBudget`
// (0=off on Flash, -1=dynamic, N>0=cap; Pro rejects 0).
// - 2.5 Flash-Lite: no native thinking surfaced; leave off.
// - Image-tier ids: image generation path -- no reasoning controls.
const GEMINI3_PRO_PREFIXES = [
"gemini-3.5-pro",
"gemini-3.1-pro",
@ -795,9 +767,8 @@ function resolveGeminiReasoningCapabilities(
// Image generation; no thinking knob.
return withEnableThinkingStyle();
}
// Gemini 2.5 Flash-Lite supports `thinkingBudget` with `0` = off and
// a positive range starting at 512 (the backend maps "minimal" to
// that floor at external_provider._stream_gemini). Check this branch
// Gemini 2.5 Flash-Lite: `thinkingBudget` 0 = off, positive range from 512
// (backend maps "minimal" to that floor in _stream_gemini). Check this branch
// BEFORE the broader `gemini-2.5-flash` prefix.
// https://ai.google.dev/gemini-api/docs/thinking
if (m.startsWith("gemini-2.5-flash-lite")) {
@ -815,10 +786,10 @@ function resolveGeminiReasoningCapabilities(
});
}
if (GEMINI3_PRO_PREFIXES.some((p) => m.startsWith(p))) {
// Gemini 3.x Pro: thinkingLevel supports low/medium/high per
// Gemini 3.x Pro: thinkingLevel low/medium/high; cannot fully disable, and
// "minimal" is rejected on Pro. Refs:
// https://ai.google.dev/gemini-api/docs/thinking and
// https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro.
// Cannot fully disable thinking; "minimal" is rejected on Pro.
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: false,
@ -826,8 +797,8 @@ function resolveGeminiReasoningCapabilities(
});
}
if (GEMINI3_FLASH_PREFIXES.some((p) => m.startsWith(p))) {
// Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal
// is the closest to "off" Google offers on Gemini 3.
// Gemini 3 Flash: thinkingLevel minimal/low/medium/high. Minimal is the
// closest to "off" Google offers on Gemini 3.
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: false,
@ -840,9 +811,9 @@ function resolveGeminiReasoningCapabilities(
});
}
if (GEMINI25_PRO_PREFIXES.some((p) => m.startsWith(p))) {
// Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects with
// "only works in thinking mode"); backend coerces to a small
// positive budget. The picker still hides the off switch.
// Gemini 2.5 Pro: thinkingBudget cannot be 0 (API rejects "only works in
// thinking mode"); backend coerces to a small positive budget. Hide the off
// switch in the picker.
return withReasoningEffortStyle({
supportsReasoning: true,
supportsReasoningOff: false,
@ -907,9 +878,8 @@ function resolveConnectionLevelReasoning(
}
/**
* resolve external-model thinking capabilities.
* provider-specific matching lives in the OpenAI/Anthropic resolvers.
* other providers default to no reasoning controls.
* Resolve external-model thinking capabilities. Provider-specific matching lives
* in the per-provider resolvers; others default to no reasoning controls.
*/
export function getExternalReasoningCapabilities(
providerType: string | null | undefined,
@ -951,10 +921,9 @@ export function getExternalReasoningCapabilities(
const isMistralProvider = normalizedProvider === "mistral";
const isOpenRouterProvider = normalizedProvider === "openrouter";
if (isOpenRouterProvider) {
// OpenRouter's unified `reasoning` parameter is accepted on every
// chat-completion request; the gateway silently no-ops for models
// that don't reason. Mandatory-reasoning ids are handled by the
// early guard above; everything else exposes a toggleable control.
// OpenRouter's unified `reasoning` param is accepted on every request; the
// gateway no-ops for non-reasoning models. Mandatory-reasoning ids are
// handled by the early guard; everything else gets a toggleable control.
return {
supportsReasoning: true,
reasoningStyle: "enable_thinking",
@ -966,10 +935,9 @@ export function getExternalReasoningCapabilities(
if (isKimiProvider) return resolveKimiReasoningCapabilities(modelForMatching);
if (isMistralProvider) return resolveMistralReasoningCapabilities(modelForMatching);
if (normalizedProvider === "gemini") {
// Custom Gemini OAI-compat gateways (LiteLLM, proxies) route
// through /chat/completions which drops the Gemini-native
// thinkingConfig payload. Hide the native thinking ladder so the
// UI does not advertise a control the backend cannot honor.
// Custom Gemini OAI-compat gateways route through /chat/completions, which
// drops the native thinkingConfig payload. Hide the native thinking ladder
// so the UI doesn't advertise a control the backend can't honor.
if (isGeminiCustomOpenAICompatBase(options?.baseUrl)) {
return withEnableThinkingStyle();
}

View file

@ -194,9 +194,8 @@ class PDFAttachmentAdapter implements AttachmentAdapter {
class TextAttachmentAdapter implements AttachmentAdapter {
// MIME is unreliable for source files, so also match by extension
// (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers
// svg, code, config and other plain-text formats; html keeps its own
// adapter below.
// (assistant-ui's fileMatchesAccept supports ".ext" entries). Covers svg, code,
// config and other plain-text formats; html keeps its own adapter below.
accept = [
"text/plain,text/markdown,text/csv,text/xml,text/json,text/css",
"application/json,application/xml,image/svg+xml",
@ -258,9 +257,7 @@ class HtmlAttachmentAdapter implements AttachmentAdapter {
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
const html = await attachment.file.text();
// Strip HTML tags to extract readable text
const doc = new DOMParser().parseFromString(html, "text/html");
// Remove script and style elements
for (const el of doc.querySelectorAll("script, style")) el.remove();
const text = (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
return {
@ -584,9 +581,9 @@ export async function ensureThreadRecord({
try {
await saveStoredChatThread(record);
} catch (error) {
// assistant-ui can issue overlapping first-message persistence calls.
// If another call created the same thread while this one was waiting,
// treat initialization as successful and let the message write continue.
// assistant-ui can issue overlapping first-message persistence calls. If
// another call created the same thread while this one waited, treat init as
// successful and let the message write continue.
const existingAfterRace = await listStoredChatThreads({
includeArchived: true,
}).catch(() => []);
@ -866,7 +863,7 @@ function useStudioRuntimeAdapters(
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
// Restore context usage from last assistant message if model matches
// Restore context usage from last assistant message if model matches.
const lastAssistant = [...msgs]
.reverse()
.find((m) => m.role === "assistant");
@ -882,14 +879,14 @@ function useStudioRuntimeAdapters(
}
| undefined;
const store = useChatRuntimeStore.getState();
// Window check applies only when a local GGUF window is known;
// external providers have ggufContextLength === null.
// Window check applies only when a local GGUF window is known; external
// providers have ggufContextLength === null.
const withinLocalLimit =
!store.ggufContextLength ||
(savedUsage?.totalTokens ?? 0) <= store.ggufContextLength;
// Legacy unscoped usage (no modelId) is only trusted when a
// known local window bounds the totals, so we can't misattribute
// an old local turn to a newly-selected external provider.
// Legacy unscoped usage (no modelId) is trusted only when a known local
// window bounds the totals, so an old local turn can't be misattributed
// to a newly-selected external provider.
const modelMatches = savedUsage?.modelId
? savedUsage.modelId === store.params.checkpoint
: typeof store.ggufContextLength === "number" &&
@ -898,12 +895,10 @@ function useStudioRuntimeAdapters(
store.setContextUsage(savedUsage);
}
// If any message has a stored parentId, reconstruct the tree
// so retries/regenerations load as branches instead of being
// unrolled into a flat list. For mixed legacy/new threads
// (old messages without parentId + new messages with), infer
// sequential parents for old messages to preserve the chain.
// Fall back to fromArray for fully legacy threads.
// If any message has a stored parentId, reconstruct the tree so
// retries/regenerations load as branches rather than a flat list. For
// mixed legacy/new threads, infer sequential parents for old messages to
// preserve the chain. Fall back to fromArray for fully legacy threads.
const hasParentIds = msgs.some((m) => m.parentId != null);
if (hasParentIds) {
let previousId: string | null = null;
@ -931,7 +926,7 @@ function useStudioRuntimeAdapters(
return;
}
// Keep single-chat runtime state in sync once a new chat is first
// persisted. Compare panes intentionally do not write global activeThreadId.
// persisted. Compare panes intentionally don't write global activeThreadId.
if (modelType === "base" && !pairId) {
const store = useChatRuntimeStore.getState();
if (store.activeThreadId !== remoteId) {
@ -1071,8 +1066,8 @@ function ThreadNewChatSwitch({
if (isLoading) {
return;
}
// Switch to a fresh local thread without persisting it yet.
// Persistence still happens on first message append.
// Switch to a fresh local thread without persisting it yet; persistence
// still happens on first message append.
void aui.threads().switchToNewThread();
useChatRuntimeStore.getState().setActiveThreadId(null);
}, [aui, isLoading, nonce]);
@ -1099,8 +1094,8 @@ function ActiveThreadSync({
}
// Exposes the current thread's cancelRun() via the shared store so external
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream
// before deleting the thread — mirroring the Stop → Trash sequence.
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream before
// deleting the thread, mirroring the Stop -> Trash sequence.
function CancelRegistrar(): ReactElement | null {
const aui = useAui();
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);

View file

@ -102,7 +102,7 @@ export interface CompareHandle {
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
// Inlined to avoid a new icon dependency. Kept in sync with the main composer.
// Inlined to avoid a new icon dep. Kept in sync with the main composer.
const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
@ -149,9 +149,9 @@ function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
// Mirrors the threshold in thread.tsx — see the comment there. Chrome on
// Windows-over-WSL (issue #5546) never fires `compositionend` after the
// IME commit, so the compose flag would otherwise stay true forever.
// Mirrors the threshold in thread.tsx. Chrome on Windows-over-WSL (#5546)
// never fires `compositionend` after IME commit, so the compose flag would
// otherwise stay true forever.
const IME_STUCK_TIMEOUT_MS = 2500;
function fileToBase64DataURL(file: File): Promise<string> {
@ -187,8 +187,8 @@ function formatReasoningDisabledLabel(
modelId?: string,
): string {
const normalized = modelId?.trim().toLowerCase() ?? "";
// Magistral keeps the "none" wire value, but UX should present this floor
// as "Medium" rather than a disabled state label.
// Magistral keeps the "none" wire value, but UX presents this floor as
// "Medium" rather than a disabled-state label.
if (normalized.includes("magistral-medium-latest")) return "Medium";
return supportsReasoningOff && isExternalOpenAIReasoning ? "None" : "Off";
}
@ -374,7 +374,7 @@ type CompareModelSelection = {
ggufVariant?: string;
};
// Tool icon plus an X overlay the CSS reveals on hover when the pill is active.
// Tool icon plus an X overlay CSS reveals on hover when the pill is active.
function PillGlyph({ children }: { children: ReactNode }) {
return (
<span className="composer-pill-glyph">
@ -396,8 +396,7 @@ export function SharedComposer({
onExitCompare?: () => void;
}): ReactElement {
const navigate = useNavigate();
// Exit compare. Uses the parent's restore handler, or a fresh chat when
// compare was opened by direct URL.
// Exit compare: parent's restore handler, or fresh chat if opened by URL.
const handleExitCompare = useCallback(() => {
if (onExitCompare) {
onExitCompare();
@ -472,7 +471,7 @@ export function SharedComposer({
const setMcpEnabledForChat = useChatRuntimeStore(
(s) => s.setMcpEnabledForChat,
);
// Three most recently updated projects for the quick-access submenu.
// Three most recently updated projects for the quick-access submenu
const { projects } = useChatProjects();
const recentProjects = [...projects]
.sort((a, b) => b.updatedAt - a.updatedAt)
@ -507,11 +506,10 @@ export function SharedComposer({
modelLoaded,
});
const isCompareMode = Boolean(model1?.id || model2?.id);
// Attach-time gate. Compare mode defers to send: the catalog can lag
// behind a model's real capabilities (e.g., a GGUF whose mmproj
// arrives after the catalog snapshot), and we only sync the models[]
// entry after ensureModelLoaded runs at send time. Single mode uses
// the loaded model's runtime capability.
// Attach-time gate. Compare mode defers to send: the catalog can lag a
// model's real capabilities (e.g. a GGUF whose mmproj arrives after the
// snapshot), and models[] only syncs after ensureModelLoaded at send time.
// Single mode uses the loaded model's runtime capability.
const attachUnavailableReason = isCompareMode ? null : imageUnavailableReason;
const effectiveExternalModelId =
selectedExternalProvider?.providerType === "openrouter" &&
@ -547,11 +545,10 @@ export function SharedComposer({
const reasoningLockedOn =
effectiveSupportsReasoning &&
(effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff);
// Kimi's $web_search builtin mandates thinking=disabled per the docs at
// https://platform.kimi.ai/docs/guide/use-web-search. Both pills stay
// clickable for Kimi, but turning one on flips the other off — the
// click handlers below enforce this mutual exclusion so the visible
// state always matches what the backend actually sends.
// Kimi's $web_search builtin mandates thinking=disabled
// (https://platform.kimi.ai/docs/guide/use-web-search). Both pills stay
// clickable, but turning one on flips the other off; the click handlers
// below enforce this so the visible state matches what the backend sends.
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled;
const effectiveReasoningVisualEnabled =
@ -563,15 +560,13 @@ export function SharedComposer({
const thinkingActiveLook = isEffort
? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled)
: reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled);
// Two-pill gating: Search pill lights up when the runtime has either
// a local tool runtime (supportsTools, gives us our Code/python + local
// web_search) OR a server-side web_search the provider runs for us
// (supportsBuiltinWebSearch, currently OpenAI / Anthropic / OpenRouter
// / Kimi). Code pill lights up on the local runtime OR when Anthropic
// is selected with a model that accepts the server-side
// code_execution_20250825 tool — see
// providerSupportsBuiltinCodeExecution. Anthropic is the only external
// provider that ships a code-execution tool today.
// Two-pill gating: Search lights up on a local tool runtime (supportsTools:
// Code/python + local web_search) OR a provider-run server-side web_search
// (supportsBuiltinWebSearch: OpenAI/Anthropic/OpenRouter/Kimi). Code lights
// up on the local runtime OR Anthropic with a model accepting the
// server-side code_execution_20250825 tool (see
// providerSupportsBuiltinCodeExecution). Anthropic is the only external
// provider shipping a code-execution tool today.
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedExternalProvider?.providerType,
effectiveExternalModelId,
@ -586,24 +581,22 @@ export function SharedComposer({
selectedExternalProvider?.providerType,
);
// Gemini rejects codeExecution alongside image modalities. Search is
// blocked on older Gemini image ids but allowed on Gemini 3 image
// models -- supportsBuiltinWebSearch already encodes the per-model
// allowance, so we only disable Code unconditionally in Gemini
// image mode.
// blocked on older Gemini image ids but allowed on Gemini 3 image models
// (supportsBuiltinWebSearch encodes the per-model allowance), so we only
// disable Code unconditionally in Gemini image mode.
const isExternalGemini = selectedExternalProvider?.providerType === "gemini";
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
const imageModeDisablesCode =
isExternalGemini && imageToolsEnabled && !imageDisabled;
// Image-tier Gemini models always reject codeExecution and reject
// web_search on older ids (Gemini 3.x Pro/Flash allow it -- encoded
// in supportsBuiltinWebSearch). Don't let the local `supportsTools`
// runtime flag re-enable a pill the Gemini backend will silently
// drop. Detect "external provider is Gemini AND model is image-tier"
// and gate strictly on the provider builtin support.
// web_search on older ids (Gemini 3.x Pro/Flash allow it, encoded in
// supportsBuiltinWebSearch). Don't let local `supportsTools` re-enable a
// pill the Gemini backend silently drops: detect image-tier Gemini and
// gate strictly on provider builtin support.
const isGeminiImageTier =
isExternalGemini && supportsBuiltinImageGeneration;
// Disable only when a loaded model lacks the capability; with no model the
// tool can still be pre-selected and reflected, matching the + menu.
// tool can still be pre-selected, matching the + menu.
const searchDisabled =
modelLoaded &&
(isGeminiImageTier
@ -615,14 +608,14 @@ export function SharedComposer({
? true
: !(supportsTools || supportsBuiltinCodeExecution))) ||
imageModeDisablesCode;
// Images pill is only ever lit on OpenAI cloud's Responses-API models
// and Gemini Nano Banana family. No local tool runtime fallback.
// Images pill lights only on OpenAI cloud Responses-API models and the
// Gemini Nano Banana family. No local tool runtime fallback.
const showImagePill = supportsBuiltinImageGeneration;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
// With more than 4 pills showing, collapse them to icons only to cut clutter.
// Compare, Search and Code always show; the rest are conditional.
// Above 4 pills, collapse to icons only to cut clutter. Compare, Search and
// Code always show; the rest are conditional.
const pillsCompact =
3 +
(showImagePill ? 1 : 0) +
@ -630,8 +623,8 @@ export function SharedComposer({
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) >
4;
// Backwards-compatible alias for any other call site that may still
// reference `toolsDisabled` (rare; both pills used it before).
// Backwards-compatible alias for call sites still referencing
// `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore(
@ -753,11 +746,11 @@ export function SharedComposer({
const isGeneralizedCompare =
hasCompareHandles && Boolean(model1?.id && model2?.id);
// Generalized compare requires both panes to have a model. A
// half-selected send either races to an empty bubble with bogus
// tok/s (#5569) or leaves the empty pane with a dangling prompt.
// hasCompareHandles is true only in GeneralCompareContent, so
// LoraCompare and single-pane chats are unaffected.
// Generalized compare requires both panes to have a model. A half-
// selected send either races to an empty bubble with bogus tok/s (#5569)
// or leaves the empty pane with a dangling prompt. hasCompareHandles is
// true only in GeneralCompareContent, so LoraCompare and single-pane
// chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description:
@ -771,10 +764,10 @@ export function SharedComposer({
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
// pre-stream gate runs per-side against that fresh state.
// Single mode: the loaded model's runtime capability is known here.
// Compare mode defers: each ensureModelLoaded sets loadedIsMultimodal
// for its side, and the chat-adapter's pre-stream gate runs per-side
// against that fresh state.
toast.error(imageUnavailableReason);
return;
}
@ -869,10 +862,9 @@ export function SharedComposer({
supportsTools: resp.supports_tools ?? false,
loadedIsMultimodal: isMultimodalResponse(resp),
});
// Sync the models[] entry with the load response so the
// attach/send gates read fresh capabilities. /api/models/list
// can lag behind a model's actual state (e.g., a GGUF whose
// mmproj was downloaded after the catalog snapshot).
// Sync the models[] entry with the load response so attach/send gates
// read fresh capabilities. /api/models/list can lag a model's actual
// state (e.g. a GGUF whose mmproj arrived after the snapshot).
const currentModels = useChatRuntimeStore.getState().models;
const idx = currentModels.findIndex((m) => m.id === sel.id);
const synced = {
@ -982,13 +974,12 @@ export function SharedComposer({
const busy = running || comparing;
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck
// watchdog (#5546) cleared it during a long candidate-window pause; this
// keeps a follow-up click-Send from submitting preedit text. Re-arm the
// watchdog on the same path — without it the WSL+Chrome no-compositionend
// case would leave composingRef pinned forever after an IME keypress and
// re-lock Send.
// IME composition (JP/CN/KR): Enter commits the candidate, don't hijack it
// (#5318). Re-pin composingRef in case the stuck watchdog (#5546) cleared
// it during a long candidate-window pause, so a follow-up click-Send won't
// submit preedit text. Re-arm the watchdog on the same path; without it the
// WSL+Chrome no-compositionend case pins composingRef forever after an IME
// keypress and re-locks Send.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckImeTimer();
@ -1019,8 +1010,8 @@ export function SharedComposer({
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
// Phase 1 native model drops own Tauri local-path drops. Restore browser
// attachment drops in Tauri when Phase 1d adds attachment-token bridging.
// Phase 1 native model drops own Tauri local-path drops. Restore
// browser attachment drops in Tauri once Phase 1d adds token bridging.
if (isTauri) return;
e.preventDefault();
setDragging(false);
@ -1071,11 +1062,11 @@ export function SharedComposer({
value={text}
onChange={(e) => {
// ALWAYS mirror the DOM value into React state, even during IME
// composition. The controlled `value` prop must match the DOM at
// all times, otherwise any unrelated parent re-render reconciles
// the textarea back to the stored value mid-composition — wiping
// the IME preedit AND prior committed text (e.g. Tab cycling
// candidates erases earlier words). Issue #5318.
// composition: the controlled `value` must match the DOM at all
// times, else an unrelated parent re-render reconciles the textarea
// back to the stored value mid-composition, wiping the IME preedit
// AND prior committed text (e.g. Tab-cycling candidates erases
// earlier words). #5318.
setCompositionState(isNativeComposing(e.nativeEvent));
setText(e.target.value);
}}
@ -1093,8 +1084,8 @@ export function SharedComposer({
placeholder="Send to both models..."
className="composer-input"
rows={1}
// dir="auto" auto-detects RTL (Arabic / Hebrew / Persian / Urdu)
// from the first strong character; no effect on LTR scripts.
// dir="auto" detects RTL (Arabic/Hebrew/Persian/Urdu) from the first
// strong character; no effect on LTR scripts.
dir="auto"
/>
<div className="composer-action-wrapper">
@ -1127,8 +1118,8 @@ export function SharedComposer({
open={newProjectOpen}
onOpenChange={setNewProjectOpen}
/>
{/* Same + side menu as the single-chat composer (ComposerToolsMenu),
wired to the compare composer's own file/audio inputs and tools. */}
{/* Same + menu as single-chat (ComposerToolsMenu), wired to the
compare composer's own file/audio inputs and tools. */}
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
@ -1241,8 +1232,8 @@ export function SharedComposer({
{mcpEnabledForChat ? <CheckIcon className="ml-auto" /> : null}
</DropdownMenuItem>
{/* RAG hidden temporarily */}
{/* Always active: this menu only renders in compare mode.
Ticked like Web search/Code; click toggles it off. */}
{/* Always active: this menu only renders in compare mode. Ticked
like Web search/Code; click toggles it off. */}
<DropdownMenuItem
className="text-primary font-medium"
onSelect={handleExitCompare}
@ -1303,7 +1294,7 @@ export function SharedComposer({
const next = !toolsEnabled;
setToolsEnabled(next);
// Kimi's $web_search builtin requires thinking=disabled
// (https://platform.kimi.ai/docs/guide/use-web-search). Toggle
// (https://platform.kimi.ai/docs/guide/use-web-search): toggle
// the Think pill off when Search is on, mirroring the backend.
if (isKimiExternal) {
setReasoningEnabled(!next, { persist: false });
@ -1564,9 +1555,8 @@ export function SharedComposer({
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
// Mutual exclusion: Kimi's $web_search builtin requires
// thinking off, so turning thinking on flips Search off.
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}

View file

@ -37,12 +37,11 @@ export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
// External provider selection is encoded into `params.checkpoint` as
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
// Omits `checkpoint` because the local-model side is mirrored by the
// backend's `/api/inference/status.active_model` response. External
// selections have no such backend mirror, so without explicit
// localStorage persistence here the user's external pick is silently
// reset to the default on every page refresh.
// `external::<providerId>::<modelId>`. PersistedChatSettings omits `checkpoint`
// because the local-model side is mirrored by the backend's
// /api/inference/status.active_model. External selections have no such mirror,
// so without explicit localStorage persistence here the user's external pick
// is reset to the default on every refresh.
const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint";
function loadLastExternalCheckpoint(): string | null {
@ -61,13 +60,13 @@ function saveLastExternalCheckpoint(value: string | null): void {
if (value && isExternalModelId(value)) {
window.localStorage.setItem(LAST_EXTERNAL_CHECKPOINT_KEY, value);
} else {
// Clearing on a switch to a local / empty checkpoint means the
// next refresh won't override the now-active local selection.
// Clear on switch to a local/empty checkpoint so the next refresh
// won't override the now-active local selection.
window.localStorage.removeItem(LAST_EXTERNAL_CHECKPOINT_KEY);
}
} catch {
// Storage quota / private-mode failures are non-fatal -- the
// selection just won't survive the refresh.
// Storage quota / private-mode failures are non-fatal; selection just
// won't survive the refresh.
}
}
@ -103,10 +102,9 @@ function warnSettingsPersistenceFailure(): void {
});
}
// Coalesce setting writes into one pendingPatch (deep merge for nested
// keys), flush on a trailing-edge debounce, flush on beforeunload so a
// pending patch survives tab close. Slider drag ticks now produce one
// HTTP write per quiet window instead of one per tick.
// Coalesce setting writes into one pendingPatch (deep merge for nested keys),
// flush on a trailing-edge debounce and on beforeunload so a pending patch
// survives tab close. Slider drags produce one HTTP write per quiet window.
type SettingsPatch = Parameters<typeof savePersistedChatSettingsPatch>[0];
const SETTINGS_DEBOUNCE_MS = 400;
@ -156,9 +154,9 @@ function saveSettingsPatch(patch: SettingsPatch): void {
}, SETTINGS_DEBOUNCE_MS);
}
// Best-effort flush of any pending patch when the tab closes. keepalive
// lets the PUT outlive the unload; without it the browser cancels the
// fetch and the user's last slider drag is dropped.
// Best-effort flush of any pending patch on tab close. keepalive lets the PUT
// outlive the unload; without it the browser cancels the fetch and the user's
// last slider drag is dropped.
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
if (pendingTimer !== null) clearTimeout(pendingTimer);
@ -191,9 +189,9 @@ export function loadOptionalBool(key: string): boolean | null {
/**
* Resolve the web-search / code-execution pill state to apply when a model
* loads. Honors the user's persisted preference so loading a tool-capable
* model never silently re-enables a pill the user turned off; falls back to
* the model's capability only when no preference has been expressed.
* loads. Honors the user's persisted preference so a tool-capable model never
* re-enables a pill the user turned off; falls back to the model's capability
* only when no preference has been expressed.
*/
export function resolveToolsEnabledOnLoad(supportsTools: boolean): {
toolsEnabled: boolean;
@ -255,12 +253,10 @@ type ChatRuntimeStore = {
reasoningAlwaysOn: boolean;
reasoningEnabled: boolean;
/**
* The model id the OpenRouter router actually picked for the most recent
* stream when the active checkpoint is the openrouter/free meta-model.
* Updated each time a chunk arrives carrying a non-empty `model` field
* that differs from the requested id. Cleared when a non-OpenRouter
* model is selected. Used purely for UI display appended after
* `openrouter/free:` in the active model chip.
* The model id the OpenRouter router picked for the most recent stream when
* the active checkpoint is the openrouter/free meta-model. Updated when a
* chunk's `model` field differs from the requested id; cleared on a
* non-OpenRouter model. UI display only (appended after `openrouter/free:`).
*/
lastOpenRouterChosenModel: string | null;
reasoningStyle: ReasoningStyle;
@ -271,35 +267,29 @@ type ChatRuntimeStore = {
preserveThinking: boolean;
supportsTools: boolean;
/**
* Whether the active external provider exposes a server-side
* web_search tool (OpenAI's /v1/responses today). Distinct from
* `supportsTools` that flag governs the local tool runtime (Code,
* python sandbox, our DuckDuckGo web_search). This one only enables
* the chat composer's Search pill for external models. Local models
* keep `supportsTools` only.
* Whether the active external provider exposes a server-side web_search tool
* (OpenAI's /v1/responses today). Distinct from `supportsTools` (the local
* tool runtime): this only enables the composer's Search pill for external
* models. Local models keep `supportsTools` only.
*/
supportsBuiltinWebSearch: boolean;
/**
* Whether the active external provider exposes a server-side
* code-execution tool (Anthropic's `code_execution_20250825` on the
* Claude 4.x family). Distinct from `supportsTools` for the same
* reason as `supportsBuiltinWebSearch`: external providers don't
* give us a local tool runtime, but Anthropic dispatches code
* execution server-side. Read by both composers' Code pill gate.
* Whether the active external provider exposes a server-side code-execution
* tool (Anthropic's `code_execution_20250825` on Claude 4.x). Distinct from
* `supportsTools` like supportsBuiltinWebSearch: Anthropic dispatches it
* server-side. Read by both composers' Code pill gate.
*/
supportsBuiltinCodeExecution: boolean;
/**
* Whether the active external provider exposes a server-side
* image-generation tool (OpenAI's Responses-API `image_generation`
* today). Gates the chat composer's Images pill. Local models never
* receive the tool because their runtime cannot dispatch it.
* Whether the active external provider exposes a server-side image-generation
* tool (OpenAI's Responses-API `image_generation`). Gates the composer's
* Images pill. Local models never receive it (their runtime can't dispatch it).
*/
supportsBuiltinImageGeneration: boolean;
/**
* Whether the active external provider exposes a server-side
* web_fetch tool (Anthropic's `web_fetch_20250910` /
* `web_fetch_20260209`). Gates the composer's Fetch pill,
* independent of Search.
* Whether the active external provider exposes a server-side web_fetch tool
* (Anthropic's `web_fetch_20250910` / `web_fetch_20260209`). Gates the
* composer's Fetch pill, independent of Search.
*/
supportsBuiltinWebFetch: boolean;
toolsEnabled: boolean;
@ -596,10 +586,9 @@ function setScalarSettingVersion<K extends ScalarSettingKey>(
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
settingsHydrated: false,
// Hydrate the last external checkpoint into params.checkpoint so the
// external picker selection survives a page refresh. Local model
// checkpoints are re-derived from the backend in useChatModelRuntime
// and intentionally NOT persisted here.
// Hydrate the last external checkpoint so the external picker survives a
// refresh. Local checkpoints are re-derived from the backend in
// useChatModelRuntime and intentionally NOT persisted here.
params: (() => {
const persistedExternal = loadLastExternalCheckpoint();
return persistedExternal
@ -699,9 +688,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
return nextState;
});
} catch {
// Hydrate failed: treat as hydrated-with-defaults so future
// setParams calls reach saveSettingsPatch (which surfaces its
// own toast on real network failure).
// Hydrate failed: treat as hydrated-with-defaults so future setParams
// calls reach saveSettingsPatch (which toasts on real network failure).
warnSettingsPersistenceFailure();
set({ settingsHydrated: true });
} finally {
@ -715,16 +703,15 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
set({ modelRequiresTrustRemoteCode }),
setParams: (params) =>
set((state) => {
// Bump version unconditionally so a late hydration response
// won't clobber a pre-hydrate user edit; only the HTTP write
// is gated on settingsHydrated.
// Bump version unconditionally so a late hydration response won't clobber
// a pre-hydrate user edit; only the HTTP write is gated on settingsHydrated.
const changedParams = getChangedInferenceParams(params, state.params);
if (state.settingsHydrated && hasKeys(changedParams)) {
saveSettingsPatch({ inferenceParams: changedParams });
}
// Mirror setCheckpoint: the local model load path can mutate
// params.checkpoint via setParams() before setCheckpoint runs,
// leaving stale per-turn counters under the new checkpoint.
// Mirror setCheckpoint: the local load path can mutate params.checkpoint
// via setParams() before setCheckpoint runs, leaving stale per-turn
// counters under the new checkpoint.
const checkpointChanged = state.params.checkpoint !== params.checkpoint;
return {
params,
@ -787,19 +774,17 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setModelsError: (modelsError) => set({ modelsError }),
setCheckpoint: (modelId, ggufVariant) =>
set((state) => {
// Persist external selections so they survive a page refresh.
// Local model ids are NOT persisted here -- they get re-derived
// from the backend's `/api/inference/status.active_model` on
// mount, and a stale persisted local id would race against the
// freshly-loaded model. See LAST_EXTERNAL_CHECKPOINT_KEY notes.
// Persist external selections so they survive a refresh. Local ids are
// NOT persisted -- they're re-derived from the backend on mount, and a
// stale persisted local id would race the freshly-loaded model. See
// LAST_EXTERNAL_CHECKPOINT_KEY notes.
saveLastExternalCheckpoint(isExternalModelId(modelId) ? modelId : null);
// Clear stale per-turn usage when the model changes; the relaxed
// external-provider render gate would otherwise show old counters
// until the next completion overwrites them.
// Clear stale per-turn usage on model change; the relaxed external-provider
// render gate would otherwise show old counters until the next completion.
const checkpointChanged = state.params.checkpoint !== modelId;
// Clamp maxTokens to the new model's cap on switch into an
// external model so a value carried over from a prior local
// session does not render above the slider's max.
// Clamp maxTokens to the new model's cap when switching into an external
// model so a value carried over from a local session doesn't exceed the
// slider's max.
let nextMaxTokens = state.params.maxTokens;
if (checkpointChanged && isExternalModelId(modelId)) {
const parsed = parseExternalModelId(modelId);
@ -831,10 +816,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () => {
// Mirror setCheckpoint's persistence behavior: dropping the
// checkpoint must also clear any stored external selection so
// the next refresh doesn't snap back to a model the user
// intentionally cleared.
// Mirror setCheckpoint's persistence: dropping the checkpoint must also
// clear any stored external selection so the next refresh doesn't snap
// back to a model the user intentionally cleared.
saveLastExternalCheckpoint(null);
return set((state) => ({
params: {

View file

@ -62,8 +62,8 @@ export function buildChatTourSteps({
];
if (canCompare) {
// Compare now lives in the + menu, so there is no sidebar button to anchor
// to; the view step enters compare on its own and explains it.
// Compare lives in the + menu (no sidebar button to anchor to); this step
// enters compare on its own and explains it.
steps.push({
id: "compare-view",
target: "chat-compare-view",

View file

@ -37,29 +37,24 @@ export interface ThreadRecord {
archived: boolean;
createdAt: number;
/**
* OpenAI shell tool container id captured from a prior response on
* this thread. When set, the next turn reuses it via
* `environment.type="container_reference"` so the model can read
* files it wrote earlier in the conversation. When null/undefined,
* the next turn auto-creates a fresh container.
* OpenAI shell tool container id from a prior response. When set, the
* next turn reuses it via `environment.type="container_reference"` so
* the model can read files it wrote earlier; else auto-creates one.
*
* OpenAI containers expire after ~20 min of inactivity by default;
* if a stale id is sent, the backend surfaces an
* `_toolEvent.type="container_invalidated"` and the chat-adapter
* clears this field so the following turn falls back to auto-create.
* Containers expire after ~20 min idle; on a stale id the backend
* emits `_toolEvent.type="container_invalidated"` and the chat-adapter
* clears this field so the next turn falls back to auto-create.
*/
openaiCodeExecContainerId?: string | null;
/**
* Anthropic code_execution container id captured from a prior
* response on this thread. When set, the next turn sends a
* top-level `container` field on /v1/messages so filesystem state
* (files, packages, variables) persists across turns. When
* null/undefined, Anthropic auto-creates a fresh container.
* Anthropic code_execution container id from a prior response. When
* set, the next turn sends a top-level `container` on /v1/messages so
* filesystem state (files, packages, variables) persists; else auto-
* creates one.
*
* Anthropic containers expire after ~1 hour by default; on a stale
* id the backend surfaces `_toolEvent.type="container_invalidated"`
* and the chat-adapter clears this field so the following turn
* falls back to auto-create.
* Containers expire after ~1 hour; on a stale id the backend emits
* `_toolEvent.type="container_invalidated"` and the chat-adapter
* clears this field so the next turn falls back to auto-create.
*/
anthropicCodeExecContainerId?: string | null;
}

View file

@ -44,18 +44,16 @@ export interface LoadModelRequest {
chat_template_override?: string | null;
cache_type_kv?: string | null;
/**
* Speculative decoding mode for GGUF models. Canonical values:
* "auto" (platform-aware: MTP on MTP GGUFs, ngram-mod fallback for
* sub-3B), "mtp" (force draft-mtp only on both GPU and CPU), "ngram"
* (force ngram-mod only), "mtp+ngram" (force ngram-mod + draft-mtp
* chain on both platforms), or "off". Legacy values "default" /
* "draft-mtp" / "ngram-mod" / "ngram-simple" are still accepted by
* the backend.
* Speculative decoding mode for GGUF models. Canonical values: "auto"
* (platform-aware: MTP on MTP GGUFs, ngram-mod fallback for sub-3B), "mtp"
* (force draft-mtp), "ngram" (force ngram-mod), "mtp+ngram" (ngram-mod +
* draft-mtp chain), "off". Legacy "default"/"draft-mtp"/"ngram-mod"/
* "ngram-simple" are still accepted by the backend.
*/
speculative_type?: string | null;
/**
* Override --spec-draft-n-max for MTP speculative decoding. Only
* applied when speculative_type resolves to "mtp" or "mtp+ngram".
* Override --spec-draft-n-max for MTP speculative decoding. Applied only
* when speculative_type resolves to "mtp" or "mtp+ngram".
*/
spec_draft_n_max?: number | null;
}
@ -221,13 +219,11 @@ export type OpenAIMessageContentPart =
export type OpenAIMessageContent = string | OpenAIMessageContentPart[];
/**
* OpenAI Chat Completions tool_call shape. Assistant turns echo back
* function/tool calls as `tool_calls`; the matching tool result rides
* on a separate `role="tool"` message keyed by `tool_call_id`.
* `extra_content.google.thought_signature` is the Gemini-specific
* round-trip field the backend translator both emits (on `delta.
* tool_calls`) and consumes (when rebuilding the native functionCall
* part on the next turn).
* OpenAI Chat Completions tool_call shape. Assistant turns echo function calls
* as `tool_calls`; the matching result rides on a separate `role="tool"`
* message keyed by `tool_call_id`. `extra_content.google.thought_signature` is
* the Gemini round-trip field the backend translator emits (on `delta.
* tool_calls`) and consumes (when rebuilding the functionCall part next turn).
*/
export interface OpenAIToolCallPart {
id?: string;
@ -289,36 +285,28 @@ export interface OpenAIChatCompletionsRequest {
encrypted_api_key?: string;
provider_base_url?: string | null;
/**
* Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For
* Gemini the backend also accepts the cached-content resource name
* (`cachedContents/...`) as a string, which is forwarded as
* `generationConfig.cachedContent` on the native streamGenerateContent
* request.
* Boolean toggle for OpenAI/Anthropic ephemeral cache_control. For Gemini the
* backend also accepts a cached-content resource name (`cachedContents/...`)
* string, forwarded as `generationConfig.cachedContent`.
*/
enable_prompt_caching?: boolean | string | null;
/**
* OpenAI shell-tool container id captured from the prior response in
* this chat thread. When set and the Code pill is on, the backend
* routes the next /v1/responses call with
* `environment.type="container_reference"` so filesystem state
* persists across turns. Unset backend uses
* `environment.type="container_auto"` and OpenAI creates a fresh
* container. Only meaningful for OpenAI cloud + gpt-5.5 family.
* OpenAI shell-tool container id from the prior response in this thread. When
* set and the Code pill is on, the backend routes the next /v1/responses with
* `environment.type="container_reference"` so filesystem state persists; unset
* `container_auto` (fresh container). OpenAI cloud + gpt-5.5 family only.
*/
openai_code_exec_container_id?: string | null;
/**
* Anthropic code_execution container id captured from the prior
* response in this chat thread. When set and the Code pill is on,
* the backend forwards a top-level `container` field on
* /v1/messages so filesystem state persists across turns. Unset
* Anthropic auto-creates a fresh container. Only meaningful for
* the Anthropic provider with `code_execution` in `enabled_tools`.
* Anthropic code_execution container id from the prior response in this
* thread. When set and the Code pill is on, the backend forwards a top-level
* `container` on /v1/messages so filesystem state persists; unset
* auto-created. Anthropic provider with `code_execution` in `enabled_tools`.
*/
anthropic_code_exec_container_id?: string | null;
/**
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; backend drops
* silently on every other model + provider. See
* https://platform.claude.com/docs/en/build-with-claude/fast-mode
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; dropped silently elsewhere.
* See https://platform.claude.com/docs/en/build-with-claude/fast-mode
*/
fast_mode?: boolean | null;
}
@ -327,16 +315,15 @@ export interface OpenAIChatDelta {
role?: string;
content?: string | null;
/**
* Streamed assistant tool calls. The Gemini and OpenAI Responses
* translators emit incremental `tool_calls` deltas (function name +
* arguments fragments) so the chat-adapter can render tool cards as
* they arrive.
* Streamed assistant tool calls. The Gemini and OpenAI Responses translators
* emit incremental deltas (function name + arguments fragments) so the
* chat-adapter can render tool cards as they arrive.
*/
tool_calls?: OpenAIToolCallPart[];
/**
* Provider-specific passthrough. Gemini ships `thoughtSignature`,
* citations, `native_part`, etc., here so the round-trip can replay
* them on follow-up turns without bleeding into other providers.
* Provider-specific passthrough. Gemini ships `thoughtSignature`, citations,
* `native_part`, etc., here so the round-trip can replay them on follow-up
* turns without bleeding into other providers.
*/
extra_content?: Record<string, unknown>;
}

View file

@ -15,8 +15,8 @@ export interface InferenceParams {
/** Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust. */
trustRemoteCode?: boolean;
/**
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; higher OTPS at
* 6x standard Opus pricing. Default false.
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; higher OTPS at 6x Opus
* pricing. Default false.
* https://platform.claude.com/docs/en/build-with-claude/fast-mode
*/
fastMode?: boolean;

View file

@ -43,11 +43,10 @@ type ThreadListArgs = {
};
// localStorage perf-hint that the Dexie -> studio.db import already
// finished in a previous session. NOT consulted by the import gate
// itself -- the server-side ledger (chat_legacy_imports) is the source
// of truth so a studio.db wipe stays recoverable. The hint only short-
// circuits the listing paths' "should I also surface Dexie threads?"
// branches once the ledger has covered everything.
// finished. NOT the import gate -- the server-side ledger
// (chat_legacy_imports) is the source of truth so a studio.db wipe stays
// recoverable. The hint only short-circuits the listing paths' "also
// surface Dexie threads?" branches once the ledger has covered everything.
const LEGACY_CHAT_IMPORT_KEY = "unsloth_chat_legacy_imported_to_studio_db";
let legacyChatImportPromise: Promise<void> | null = null;
@ -265,9 +264,9 @@ async function backfillLegacyThreadFields(
}
}
// Fast-path: ask IndexedDB whether the "unsloth-chat" database exists
// without opening it. Modern Chromium / Firefox / Safari support this;
// older browsers return undefined and we fall through to the next probe.
// Fast-path: check whether the "unsloth-chat" DB exists without opening
// it. Supported on modern Chromium/Firefox/Safari; older browsers return
// undefined and we fall through to the next probe.
async function dexieDbAbsent(): Promise<boolean> {
if (typeof indexedDB === "undefined") return true;
const dbs = (indexedDB as IDBFactory).databases;
@ -291,24 +290,22 @@ async function dexieIsEmpty(): Promise<boolean> {
]);
return threadCount === 0 && messageCount === 0;
} catch {
// Dexie threw (corrupted DB / version mismatch / quota). Returning
// false forces the slow path, which uses the same Dexie under the
// hood; that path will throw too and the import promise gets reset
// so the next caller can retry rather than silently doing nothing.
// Dexie threw (corrupt DB / version mismatch / quota). Returning
// false forces the slow path (same Dexie underneath); it'll throw
// too and reset the import promise so the next caller can retry.
return false;
}
}
async function importLegacyChatsIfNeeded(): Promise<void> {
// Session-level cache: same tab, repeated sidebar mounts share one
// import. localStorage is NOT consulted here -- the server-side ledger
// is the source of truth so a studio.db wipe still re-triggers the
// import even if the browser kept its old hint.
// Session-level cache: repeated sidebar mounts in the same tab share
// one import. localStorage is NOT consulted -- the server-side ledger
// is the source of truth, so a studio.db wipe re-triggers the import
// even if the browser kept its old hint.
if (legacyChatImportPromise) return legacyChatImportPromise;
legacyChatImportPromise = (async () => {
// Fast-path: no Dexie database at all. New user, never had the
// browser-only Studio. ~0.1 ms, zero network.
// Fast-path: no Dexie DB -- new user, never had browser-only Studio.
if (await dexieDbAbsent()) {
markLegacyChatImportDone();
return;
@ -336,10 +333,9 @@ async function importLegacyChatsIfNeeded(): Promise<void> {
const unimportedIds: string[] = [];
const unimportedThreads: ThreadRecord[] = [];
// "Unimported" = missing from the ledger. We also include threads
// already present in the backend (without a ledger row) so the ledger
// gets backfilled for old-FE-then-new-FE users -- otherwise the next
// launch would redo the diff for the same threads forever.
// "Unimported" = missing from the ledger. Include threads already in
// the backend (without a ledger row) so the ledger gets backfilled
// for old-FE-then-new-FE users; else the next launch re-diffs forever.
for (const thread of legacyThreads) {
if (isChatThreadDeleted(thread.id)) continue;
if (importedThreadIds.has(thread.id)) continue;
@ -406,14 +402,12 @@ async function importLegacyChatsIfNeeded(): Promise<void> {
result = await recordChatImportLedger(newlyImportedIds);
} catch {
// Network error: leave the perf hint alone so the next launch
// retries. The import itself is idempotent via UPSERT, no
// duplicates.
// retries. Import is idempotent via UPSERT, so no duplicates.
return;
}
// Only flip the localStorage hint when the backend actually has the
// ledger. On older deployments (404/405/501) the hint would lie:
// "import done" while the ledger stays empty, defeating recovery
// when studio.db gets wiped later.
// Only flip the hint when the backend actually has the ledger. On
// older deployments (404/405/501) it would lie ("import done" with an
// empty ledger), defeating recovery after a studio.db wipe.
if (result.supported) {
markLegacyChatImportDone();
}
@ -576,8 +570,8 @@ export async function listStoredChatThreadsWithMessages(
): Promise<ThreadRecord[]> {
const threads = await listStoredChatThreads(args);
if (threads.length === 0) return [];
// One batched HTTP call instead of N. Per-thread legacy Dexie
// fallback only fires when the batch result is empty.
// One batched HTTP call instead of N. Per-thread legacy Dexie fallback
// only fires when the batch result is empty.
const threadIds = threads.map((t) => t.id);
let backendByThread: Map<string, MessageRecord[]>;
try {
@ -728,8 +722,8 @@ export interface ClearStoredChatsResult {
}
export async function clearStoredChats(): Promise<ClearStoredChatsResult> {
// Clear both sides independently and report each outcome so the
// toast can distinguish full vs partial success.
// Clear both sides independently and report each outcome so the toast
// can distinguish full vs partial success.
const [backendThreadsResult, legacyThreads] = await Promise.all([
listChatThreads()
.then((threads) => ({ ok: true as const, threads }))
@ -752,8 +746,8 @@ export async function clearStoredChats(): Promise<ClearStoredChatsResult> {
failedThreadIds: [],
};
try {
// Defer the history refresh until Dexie clear and tombstone state are
// finalized, so listeners never observe the composite clear mid-flight.
// Defer the history refresh until Dexie clear and tombstones finalize,
// so listeners never observe the composite clear mid-flight.
await clearBackendChats({ notify: false });
result.backend = "cleared";
} catch (error) {

View file

@ -2,10 +2,9 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Tombstones mask deleted threads in the Dexie read fallback. Each
* carries a `deletedAt` timestamp so old entries can be GC'd, keeping
* localStorage bounded. Reads accept both the legacy plain-string
* format and the new {id, deletedAt} tuple form.
* Tombstones mask deleted threads in the Dexie read fallback. Each carries a
* `deletedAt` so old entries can be GC'd, keeping localStorage bounded. Reads
* accept both the legacy plain-string format and the {id, deletedAt} tuple.
*/
interface Tombstone {
@ -62,8 +61,7 @@ function gc(): void {
for (const [id, t] of deletedThreads) {
if (t.deletedAt < cutoff) deletedThreads.delete(id);
}
// Cap absolute size: drop oldest if we somehow exceed the limit
// (e.g. a script clearing thousands of threads at once).
// Cap size: drop oldest if we exceed the limit (e.g. a bulk thread clear).
if (deletedThreads.size > TOMBSTONE_MAX_COUNT) {
const sorted = Array.from(deletedThreads.entries()).sort(
(a, b) => a[1].deletedAt - b[1].deletedAt,

View file

@ -2,16 +2,15 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* assistant-ui does not expose a public `deleteMessage` on `ThreadRuntime` / `MessageRuntime`
* in our version, but it already implements branch-safe deletion inside `MessageRepository`.
* We import that helper from `@assistant-ui/core/internal`, the package's exported internal
* surface. Avoid importing the deeper `runtime/utils/message-repository` path directly: newer
* `@assistant-ui/core` releases no longer export arbitrary deep paths.
* assistant-ui exposes no public `deleteMessage` in our version, but
* `MessageRepository` already does branch-safe deletion. We import it from
* `@assistant-ui/core/internal` (the exported internal surface); avoid the
* deeper `runtime/utils/message-repository` path since newer releases no
* longer export arbitrary deep paths.
*
* **Maintainability:** treat this file as the only place that imports `MessageRepository` from
* `@assistant-ui/core`. When bumping `@assistant-ui/react` / `@assistant-ui/core`, re-run chat
* delete + reload smoke tests; the path or API may change without a semver signal on public
* surface area.
* Keep this file the only importer of `MessageRepository`. When bumping
* `@assistant-ui/react` / `core`, re-run chat delete + reload smoke tests;
* the path or API may change without a semver signal.
*/
import { MessageRepository } from "@assistant-ui/core/internal";
import type {

View file

@ -2,14 +2,9 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Format a byte-per-second rate as a human-readable string.
*
* 512 "512 B/s"
* 1_234_567 "1.2 MB/s"
* 1_234_567_890 "1.15 GB/s"
*
* Returns `"--"` for non-finite or non-positive inputs so callers can
* render the label safely before the first stable sample arrives.
* Format a byte-per-second rate (e.g. 1_234_567 "1.2 MB/s"). Returns `"--"`
* for non-finite or non-positive inputs so the label renders safely before the
* first stable sample.
*/
export function formatRate(bytesPerSecond: number): string {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "--";
@ -21,13 +16,8 @@ export function formatRate(bytesPerSecond: number): string {
}
/**
* Format an ETA (in seconds) as a short human-readable string.
*
* 47 "47s"
* 125 "2m 5s"
* 3725 "1h 2m"
*
* Returns `"--"` for non-finite or non-positive inputs.
* Format an ETA in seconds as a short string (e.g. 125 "2m 5s", 3725
* "1h 2m"). Returns `"--"` for non-finite or non-positive inputs.
*/
export function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "--";

View file

@ -14,8 +14,8 @@ export function getImageInputUnavailableReason({
activeModel?: ChatModelSummary;
isExternalModel: boolean;
// true/false = caller knows; null/undefined = unknown (default-allow).
// External selections aren't in runtime.models[], so callers should
// resolve provider-type capability and pass it here.
// External selections aren't in runtime.models[], so callers resolve
// provider-type capability and pass it here.
externalSupportsVision?: boolean | null;
// Fallback toast label when activeModel is missing.
externalModelLabel?: string | null;
@ -40,11 +40,10 @@ export function getImageInputUnavailableReason({
return null;
}
if (!modelLoaded) return "Load a model before adding images.";
// loadedIsMultimodal is true for vision OR audio. Can't tell them apart
// from that one flag, so only block when activeModel confirms
// audio-only: audio capability set AND isVision === false. Otherwise
// trust the load response. The models-list entry might be stale, or
// not even there yet (gets auto-injected after load).
// loadedIsMultimodal is true for vision OR audio; that one flag can't tell
// them apart, so only block when activeModel confirms audio-only (audio
// capability set AND isVision === false). Otherwise trust the load
// response: the models-list entry may be stale or not yet injected.
if (loadedIsMultimodal) {
const isAudioOnly =
Boolean(activeModel?.isAudio || activeModel?.hasAudioInput) &&

View file

@ -8,11 +8,9 @@ type ContentPart = NonNullable<ChatModelRunResult["content"]>[number];
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
// ContentPart from @assistant-ui/react has readonly fields, so we cannot
// do `last.text += text` to coalesce adjacent same-type parts — tsc fails
// with TS2540 "Cannot assign to 'text' because it is a read-only property".
// Instead, replace the last element with a fresh merged object: same
// allocation cost as the mutation path but type-safe.
// ContentPart from @assistant-ui/react has readonly fields, so coalescing via
// `last.text += text` fails (TS2540). Instead replace the last element with a
// fresh merged object: same allocation cost as mutation but type-safe.
function appendTextPart(parts: ContentPart[], text: string): void {
if (!text) return;

View file

@ -4,13 +4,9 @@
/**
* Pure, framework-free math behind {@link useTransferStats}.
*
* Split out so it can be unit-tested without a DOM/React renderer, and
* so the training-start overlay, the chat download toast, and the
* model-load phase UI all share the exact same rate/ETA semantics.
*
* No React, no `useRef`/`useState`, no timers -- the caller owns the
* sample buffer and clock. This is the "what is the rate right now
* given these samples" question, nothing more.
* Split out for unit-testing without React, and so the training-start overlay,
* chat download toast, and model-load UI share identical rate/ETA semantics.
* No React/timers -- the caller owns the sample buffer and clock.
*/
export type TransferSample = { t: number; b: number };
@ -19,10 +15,9 @@ export type TransferStats = {
rateBytesPerSecond: number;
etaSeconds: number;
/**
* False until the window has at least {@link MIN_SAMPLES} samples
* spanning {@link MIN_WINDOW_SECONDS} with strictly forward progress.
* Consumers should hide rate/ETA while this is false so the UI doesn't
* flicker "123 GB/s" during the first tick.
* False until the window has {@link MIN_SAMPLES} samples spanning
* {@link MIN_WINDOW_SECONDS} with forward progress. Hide rate/ETA while false
* so the UI doesn't flicker "123 GB/s" during the first tick.
*/
stable: boolean;
};
@ -32,11 +27,9 @@ export const MIN_WINDOW_SECONDS = 3;
export const MAX_WINDOW_SECONDS = 15;
/**
* Mutate ``samples`` in place: append the new sample, drop any that
* fell out of the rolling window, and clear the buffer if the counter
* went backwards (user cancelled + restarted a download, etc.).
*
* Returns the same array for chain-ability.
* Mutate ``samples`` in place: append the sample, drop any out of the rolling
* window, and clear the buffer if the counter went backwards (cancel + restart).
* Returns the same array for chaining.
*/
export function appendSample(
samples: TransferSample[],
@ -56,13 +49,11 @@ export function appendSample(
}
/**
* Derive {@link TransferStats} from a window of cumulative-byte samples
* plus the known total.
*
* Derive {@link TransferStats} from a window of cumulative-byte samples plus the
* known total.
* * Needs {@link MIN_SAMPLES} samples spanning {@link MIN_WINDOW_SECONDS}
* seconds before it will report ``stable: true``.
* * ETA is clamped to 0 when: no progress, no total known, or the
* counter already hit the total.
* seconds before reporting ``stable: true``.
* * ETA clamps to 0 when there's no progress, no total, or total is hit.
*/
export function computeTransferStats(
samples: readonly TransferSample[],

View file

@ -37,10 +37,8 @@ export interface ExportOperationResponse {
success: boolean;
message: string;
/**
* Optional extras returned by the backend. The export endpoints set
* `details.output_path` to the resolved on-disk directory of the
* saved model when a local save was requested. Hub-only pushes leave
* `details` undefined.
* Optional backend extras. Local saves set `details.output_path` to the
* saved model's on-disk directory; hub-only pushes leave it undefined.
*/
details?: { output_path?: string | null } & Record<string, unknown>;
}

View file

@ -26,9 +26,8 @@ import { streamExportLogs, type ExportLogEntry } from "../api/export-api";
import { collapseAnim } from "../anim";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
// Max log lines kept in the dialog's local state. Matches the backend
// ring buffer's maxlen so the UI shows the full scrollback captured
// server side.
// Max log lines kept in local state. Matches the backend ring buffer's maxlen
// so the UI shows the full server-side scrollback.
const MAX_LOG_LINES = 4000;
interface UseExportLogsResult {
@ -38,14 +37,10 @@ interface UseExportLogsResult {
}
/**
* Subscribe to the live export log SSE stream while `exporting` is
* true, and accumulate lines in local state. Lines from a previous
* action are cleared:
*
* - when a new export starts (`exporting` flips to true), and
* - when the user switches export method, dialog opens fresh, or
* the dialog closes so re-opening into a different action's
* screen doesn't show the prior screen's saved output.
* Subscribe to the live export log SSE stream while `exporting` is true and
* accumulate lines. Prior-action lines are cleared when a new export starts and
* when the user switches method / reopens / closes the dialog, so reopening
* into a different action doesn't show stale output.
*/
function useExportLogs(
exporting: boolean,
@ -56,10 +51,9 @@ function useExportLogs(
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset log state whenever the user moves to a different screen --
// either by switching export method or by reopening the dialog -- so
// each (open × method) tuple shows only its own run history. The
// streaming effect below additionally clears on new export start.
// Reset log state on screen change (method switch or dialog reopen) so each
// (open × method) shows only its own run history. The streaming effect below
// additionally clears on new export start.
useEffect(() => {
setLines([]);
setError(null);
@ -74,17 +68,15 @@ function useExportLogs(
const abortCtrl = new AbortController();
let cancelled = false;
// Track the highest seq we've observed on a `log` event so we can
// resume the stream via `since=` / `Last-Event-ID` after a drop.
// The backend's SSE `id:` field carries this as ExportLogEvent.id.
// Highest seq seen on a `log` event, to resume via `since=` / Last-Event-ID
// after a drop. The backend's SSE `id:` field carries this as ExportLogEvent.id.
let lastSeq: number | null = null;
// Exponential backoff with jitter, capped. Reset on every
// successful connection so flaky networks don't accumulate delay.
// Capped exponential backoff with jitter; reset on each successful connect
// so flaky networks don't accumulate delay.
let backoffMs = 500;
const MAX_BACKOFF_MS = 5000;
// Flipped by a terminal event (explicit `complete` from the
// backend or a non-transient error we choose not to retry). Stops
// the outer reconnect loop even if `exporting` is still true.
// Set by a terminal event (backend `complete` or a non-retryable error) to
// stop the reconnect loop even while `exporting` is still true.
let terminated = false;
const run = async () => {
@ -115,9 +107,8 @@ function useExportLogs(
return next;
});
} else if (event.event === "complete") {
// Backend signalled the run is fully drained -- stop
// trying to reconnect even though `exporting` may not
// have flipped false yet on this tick.
// Run fully drained -- stop reconnecting even if `exporting`
// hasn't flipped false yet on this tick.
terminated = true;
} else if (event.event === "error" && event.error) {
setError(event.error);
@ -128,17 +119,15 @@ function useExportLogs(
if (cancelled) return;
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : String(err));
// Fall through to the backoff path below; a fetch-level
// failure is retryable the same way a clean EOF is.
// Fall through to backoff; a fetch-level failure retries like a clean EOF.
}
setConnected(false);
if (cancelled || terminated) return;
// Exponential backoff with jitter before reconnecting. The
// backend's ring buffer plus Last-Event-ID resume means we
// don't lose lines across the retry as long as the reconnect
// happens within the buffer's lifetime (~4000 lines).
// Exponential backoff with jitter before reconnecting. Ring buffer +
// Last-Event-ID resume means no lines lost across the retry as long as
// the reconnect lands within the buffer's lifetime (~4000 lines).
const delay = backoffMs + Math.floor(Math.random() * 250);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
try {
@ -163,9 +152,8 @@ function useExportLogs(
}
};
// run()'s own try/catch handles every failure path we care about;
// swallow anything that somehow escapes so React's dev overlay
// doesn't flag an unhandled rejection on dialog close.
// run() handles every failure path; swallow stragglers so React's dev
// overlay doesn't flag an unhandled rejection on dialog close.
void run().catch(() => {});
return () => {
@ -179,10 +167,9 @@ function useExportLogs(
}
/**
* Tick every second while `exporting` is true and report elapsed
* seconds. Powers the "Working… 27s" badge in the log header so the
* panel doesn't look frozen during long single-step phases (cache
* file copy, GGUF conversion) when no new lines are arriving.
* Tick every second while `exporting` is true and report elapsed seconds.
* Powers the "Working… 27s" badge so the panel doesn't look frozen during long
* single-step phases (cache copy, GGUF conversion) with no new lines.
*/
function useElapsedSeconds(exporting: boolean): number {
const [elapsed, setElapsed] = useState(0);
@ -209,8 +196,7 @@ function formatElapsed(seconds: number): string {
}
function formatLogLine(entry: ExportLogEntry): string {
// Strip trailing carriage returns that tqdm-style progress leaves
// in the stream so the scrollback doesn't render funky boxes.
// Strip trailing CRs from tqdm-style progress so scrollback doesn't render boxes.
return entry.line.replace(/\r+$/g, "");
}
@ -240,9 +226,8 @@ interface ExportDialogProps {
exportError: string | null;
exportSuccess: boolean;
/**
* Resolved on-disk realpath of the most recent successful export.
* Surfaced on the Export Complete screen so users can find their
* model. Null when the export only pushed to the Hub.
* Resolved on-disk realpath of the latest successful export, shown on the
* Export Complete screen. Null when the export only pushed to the Hub.
*/
exportOutputPath: string | null;
}
@ -272,8 +257,7 @@ export function ExportDialog({
exportSuccess,
exportOutputPath,
}: ExportDialogProps) {
// Live log capture is useful for any export path executed by the
// backend worker, including LoRA adapter-only export.
// Live log capture applies to any backend-worker export path, incl. LoRA-only.
const showLogPanel =
exportMethod === "merged" ||
exportMethod === "gguf" ||
@ -285,8 +269,7 @@ export function ExportDialog({
const elapsedSeconds = useElapsedSeconds(exporting && showLogPanel);
const logScrollRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll to bottom whenever a new line arrives, unless the
// user has scrolled up to read earlier output.
// Auto-scroll to bottom on each new line, unless the user scrolled up.
const [followTail, setFollowTail] = useState(true);
useEffect(() => {
@ -461,9 +444,8 @@ export function ExportDialog({
)}
</AnimatePresence>
{/* Success banner for log-driven exports.
Keep users on the log screen after completion so they can
inspect conversion output before closing. */}
{/* Success banner: keep users on the log screen after completion so
they can inspect conversion output before closing. */}
{exportSuccess && showLogPanel && (
<div className="flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="mt-0.5 size-4 shrink-0" />

View file

@ -121,9 +121,8 @@ export function ExportPage() {
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportSuccess, setExportSuccess] = useState(false);
// Resolved on-disk path of the most recent successful export, surfaced
// on the Export Complete screen so the user can find their model
// without digging through the server log. Null for Hub-only pushes.
// On-disk path of the last successful export, shown on the Export Complete
// screen so the user can find their model. Null for Hub-only pushes.
const [exportOutputPath, setExportOutputPath] = useState<string | null>(null);
const hfComboboxAnchorRef = useRef<HTMLDivElement>(null);
@ -438,11 +437,9 @@ export function ExportPage() {
});
}
// 2. Run export based on method. Capture the resolved output_path
// (when the backend wrote a local copy) so the success screen can
// show the user the realpath of their saved model. For multi-quant
// GGUF runs, the directory is the same for every quant so we just
// keep the last response.
// 2. Run export. Capture the resolved output_path (when the backend
// wrote a local copy) for the success screen. Multi-quant GGUF runs
// share one directory, so we just keep the last response.
let lastOutputPath: string | null = null;
if (exportMethod === "merged") {
if (isAdapter) {

View file

@ -39,10 +39,7 @@ function formatLR(value: number): string {
return `${rounded}e${exp}`;
}
/**
* Step learning rate up in a scientific-notation-friendly sequence:
* 1e-4 -> 2e-4 -> 3e-4 -> ... -> 9e-4 -> 1e-3 -> 2e-3 -> ...
*/
/** Step the LR in a scientific sequence: 1e-4 -> 2e-4 -> ... -> 9e-4 -> 1e-3 -> ... */
function stepLR(value: number, direction: 1 | -1): number {
if (value <= 0) return 1e-5;
const exp = Math.floor(Math.log10(value) + 1e-9);
@ -116,8 +113,7 @@ export function HyperparametersStep() {
const maxStepsSliderMax = Math.max(500, maxSteps, 30);
const epochsSliderMax = Math.max(10, epochs, 1);
// Use model's max_position_embeddings to cap context length options.
// Fall back to 65536 (64K) if not available.
// Cap context length by model's max_position_embeddings; fall back to 64K.
const maxCtx = maxPositionEmbeddings ?? 65536;
const contextLengthOptions = useMemo(
() => CONTEXT_LENGTHS.filter((len) => len <= maxCtx),

View file

@ -115,7 +115,7 @@ export function ModelSelectionStep() {
return applyPriorityOrdering(ids);
}, [hfResults]);
// Match Studio behavior: only show exception signals (OOM/TIGHT) in training flows.
// Match Studio: only show exception signals (OOM/TIGHT) in training flows.
const vramMap = useMemo(() => {
const fitMap = buildModelVramMap(
hfResults,

View file

@ -192,7 +192,7 @@ export function ModelTypeStep(): ReactElement {
)}
>
<CardContent className="flex items-center gap-4 py-4">
{/* Invisible spacer matching RadioGroupItem (size-4 flex) in other cards */}
{/* Invisible spacer matching RadioGroupItem (size-4) in other cards */}
<div className="size-4 shrink-0" aria-hidden="true" />
<div
className={cn(

View file

@ -1,9 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Read the JWT `sub` claim for display purposes only (not verified).
*/
/** Read the JWT `sub` claim for display only (not verified). */
export function decodeJwtSubject(token: string | null): string | null {
if (!token) return null;
try {

View file

@ -50,9 +50,7 @@ function encodeCanvasWithinLimit(
return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null;
}
/**
* Downscale the image and keep transparency when present while staying localStorage-friendly.
*/
/** Downscale the image, preserving transparency, to stay localStorage-friendly. */
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
const img = await loadImage(file);
const w = img.naturalWidth;

View file

@ -108,11 +108,9 @@ export function ExecutionsView({
[datasetColumnNames, hiddenDatasetColumns],
);
// Columns where at least one row has text long enough that it would wrap at
// the default narrow width. We give those columns a wider min-width so the
// text is readable without clicking anything. The table's wrapper already
// scrolls horizontally, so a few wide columns just add a horizontal
// scrollbar instead of squeezing everything into the viewport.
// Columns with at least one long-text row get a wider min-width so the text
// is readable without clicking. The wrapper scrolls horizontally, so wide
// columns just add a scrollbar instead of squeezing the viewport.
const wideColumns = useMemo(() => {
const result = new Set<string>();
if (!selectedExecution) {

View file

@ -55,9 +55,9 @@ export function InlineModel(props: InlineModelProps): ReactElement {
);
}
// model_config branch - mirror the local-aware provider sync from the
// dialog path so inline edits clear stale local-only metadata without
// synthesizing the legacy "local" placeholder.
// model_config branch: mirror the dialog path's local-aware provider sync so
// inline edits clear stale local-only metadata without synthesizing the
// legacy "local" placeholder.
const localNames = props.localProviderNames ?? new Set<string>();
const modelConfig = props.config;
const isLinkedToLocal = localNames.has(modelConfig.provider);

View file

@ -556,8 +556,8 @@ export function LocalRecipeModelSelector({
return;
}
} catch {
// Non-GGUF local models commonly have no variant endpoint. Fall
// through to regular selection so users can still choose them.
// Non-GGUF local models commonly have no variant endpoint;
// fall through to regular selection so they stay choosable.
} finally {
setProbingVariantModelId(null);
}

View file

@ -49,10 +49,8 @@ export function ModelConfigDialog({
const skipHealthCheckId = `${config.id}-skip-health-check`;
const providerAnchorRef = useRef<HTMLDivElement>(null);
const providerInputRef = useRef(config.provider);
// Sync providerInputRef with the current provider value. Updating a ref in
// an effect (vs reading/writing it during render) satisfies the
// react-hooks/refs rule and keeps the combobox blur path stable across
// re-renders.
// Sync providerInputRef in an effect (not during render) to satisfy the
// react-hooks/refs rule and keep the combobox blur path stable.
useEffect(() => {
providerInputRef.current = config.provider;
}, [config.provider]);

View file

@ -563,10 +563,9 @@ export function useRecipeExecutions({
resetForRecipe();
// Seed previewRows from the recipe's original run.rows (read from the
// loaded JSON, not the rebuilt payload (the builder hardcodes 5).
// Templates ship their own suggested preview size (e.g. GitHub Support
// Bot: 10); we honor it so users don't see a surprise 5.
// Seed previewRows from the recipe's original run.rows (the loaded JSON, not
// the rebuilt payload, which hardcodes 5). Templates ship a suggested preview
// size (e.g. GitHub Support Bot: 10); honor it so users don't see a surprise 5.
if (
typeof initialRunRows === "number" &&
Number.isFinite(initialRunRows) &&
@ -794,8 +793,8 @@ export function useRecipeExecutions({
}
// Flip to the Runs pane before validation starts. Validation can re-crawl
// the seed (multiple seconds for the github_repo reader), and runExecution()
// later no-ops this callback if the view has already been flipped.
// the seed (seconds for the github_repo reader); runExecution() later no-ops
// this callback if the view has already been flipped.
onExecutionStart?.();
const normalizedRows = sanitizeExecutionRows(rows, kind);
@ -811,11 +810,11 @@ export function useRecipeExecutions({
return false;
}
// Recipe and Chat share one singleton local inference backend. This
// direct load is a point-in-time handoff to job creation, not a lease:
// if Chat swaps models after this succeeds, the backend will reject or
// run against the active backend state. A future generation token should
// be validated across this load and the `/jobs` loaded-model gate.
// Recipe and Chat share one singleton local inference backend. This direct
// load is a point-in-time handoff to job creation, not a lease: if Chat
// swaps models after this succeeds, the backend runs against current state.
// A future generation token should be validated across this load and the
// `/jobs` loaded-model gate.
const restorePrevious = await prepareLocalModelForExecution(payload);
if (restorePrevious === false) {
return false;

View file

@ -78,16 +78,15 @@ const EDGE_TYPES: EdgeTypes = {
const COMPLETE_ISLAND_VISIBLE_MS = 7_000;
const TAB_SWITCH_FIT_DELAY_MS = 110;
/**
* Maximum RAF iterations to wait for React Flow's ResizeObserver to populate
* `node.measured` dimensions before calling fitView. ~20 frames 333 ms at
* 60 fps more than enough for the render layout ResizeObserver cycle.
* Max RAF iterations to wait for React Flow's ResizeObserver to populate
* `node.measured` before calling fitView. ~20 frames 333 ms at 60 fps,
* ample for the render layout ResizeObserver cycle.
*/
const MAX_FIT_VIEW_RETRIES = 20;
/**
* After all target nodes appear measured, wait this many extra stable frames
* before firing fitView. This absorbs `updateNodeInternals` calls from
* InternalsSync and individual node mount effects that can transiently reset
* measurements.
* Extra stable frames to wait after target nodes appear measured before
* firing fitView, absorbing `updateNodeInternals` calls from InternalsSync
* and node mount effects that can transiently reset measurements.
*/
const FIT_VIEW_STABLE_FRAMES = 3;
@ -227,10 +226,10 @@ export function RecipeStudioPage({
},
[viewModeStorageKey],
);
// Easy mode has no canvas overlay/progress island, so once a run starts the
// user sees the Run button stuck on "Running..." with nothing else changing.
// Flip to the Runs pane so they land where progress is actually rendered.
// Advanced (editor) keeps its island and stays put.
// Easy mode has no canvas overlay/progress island, so a started run would
// leave the Run button stuck on "Running..." with nothing else changing.
// Flip to the Runs pane where progress is rendered. Advanced (editor) keeps
// its island and stays put.
const handleExecutionStart = useCallback(() => {
setActiveView((currentView) =>
currentView === "easy" ? "executions" : currentView,
@ -396,10 +395,9 @@ export function RecipeStudioPage({
const runBusy = previewLoading || fullLoading || executionLocked;
const islandExecution = activeExecution ?? recentCompletedExecution;
// Easy mode runs a full run (artifact persisted, tracked in Runs pane)
// using runFull. runFull requires a non-empty fullRunName but the Easy form
// has no run-name input, so seed a default here as soon as Easy is active.
// User can still rename it from Advanced/Runs dialogs before clicking Run.
// Easy mode uses runFull (artifact persisted, tracked in Runs pane), which
// requires a non-empty fullRunName. The Easy form has no run-name input, so
// seed a default once Easy is active; user can rename from Advanced/Runs.
useEffect(() => {
if (!supportsEasyMode) return;
if (activeView !== "easy") return;
@ -527,22 +525,21 @@ export function RecipeStudioPage({
return;
}
if (retries >= MAX_FIT_VIEW_RETRIES) {
// Timed out waiting — fit with whatever we have (graceful fallback).
// Timed out: fit with whatever we have (graceful fallback).
doFit();
return;
}
const targets = getFitViewTargetNodes(reactFlowInstance.getNodes());
if (allTargetsMeasured(targets)) {
stableCount++;
// Wait a few extra frames after measurements appear to let
// updateNodeInternals (InternalsSync, node mount effects) settle.
// Extra frames after measurements appear let updateNodeInternals
// (InternalsSync, node mount effects) settle.
if (stableCount >= FIT_VIEW_STABLE_FRAMES) {
doFit();
return;
}
} else {
// Measurements were reset (e.g. by updateNodeInternals) — restart
// the stability counter.
// Measurements reset (e.g. by updateNodeInternals): restart counter.
stableCount = 0;
}
retries++;
@ -808,10 +805,9 @@ export function RecipeStudioPage({
updateConfig={updateConfig}
onRun={() => {
// Easy mode is a full run (artifact persisted, tracked in
// the Runs pane) capped at the user's row count. runFull
// requires a non-empty fullRunName; the effect below
// populates one on mount so the closure in runFull is
// already up to date by the time the user clicks Run.
// Runs) capped at the user's row count. runFull requires a
// non-empty fullRunName; the effect above populates one on
// mount so runFull's closure is current by click time.
void runFull();
}}
runLoading={fullLoading || executionLocked}

View file

@ -756,10 +756,9 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
configs = applyRenameToConfigs(configs, oldName, newName);
}
// When a provider toggles between local and external, keep already
// linked model_config nodes in sync. applyRenameToConfigs above has
// already propagated any name change, so providerName here is the
// post-rename value.
// When a provider toggles local/external, keep linked model_config nodes
// in sync. applyRenameToConfigs above already propagated any name change,
// so providerName here is the post-rename value.
if (current.kind === "model_provider" && next.kind === "model_provider") {
const prevIsLocal = current.is_local === true;
const nextIsLocal = next.is_local === true;

View file

@ -27,12 +27,9 @@ function isAuxNode(node: Node): boolean {
/**
* Returns the primary workflow nodes that fitView should target.
*
* Excludes markdown notes and aux (LLM input overlay) nodes so the viewport
* is framed around the primary workflow blocks. Falls back to all nodes if
* filtering would leave an empty set.
*
* The returned array contains full {@link Node} objects so callers can inspect
* `node.measured` without a second lookup pass.
* Excludes markdown notes and aux (LLM input overlay) nodes; falls back to
* all nodes if filtering would leave an empty set. Returns full {@link Node}
* objects so callers can inspect `node.measured` without a second lookup.
*/
export function getFitViewTargetNodes(nodes: Node[]): Node[] {
const primary = nodes.filter(
@ -42,9 +39,9 @@ export function getFitViewTargetNodes(nodes: Node[]): Node[] {
}
/**
* Builds a standard {@link FitViewOptions} object targeting the primary
* workflow nodes. Every call site that invokes `fitView` should go through
* this helper so zoom, padding, and node filtering stay consistent.
* Builds a standard {@link FitViewOptions} targeting the primary workflow
* nodes. All `fitView` call sites go through this so zoom, padding, and
* node filtering stay consistent.
*/
export function buildFitViewOptions(
nodes: Node[],

View file

@ -387,10 +387,10 @@ export function applyRecipeConnection(
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
// Keep model_config.provider in sync when a graph drag changes the link.
// Local providers now require an explicit selected load id; do not synthesize
// the legacy "local" placeholder. External relinks clear local-only GGUF
// metadata, while legacy placeholders are normalized back to empty.
// Keep model_config.provider in sync when a drag changes the link.
// Local providers need an explicit load id; don't synthesize the legacy
// "local" placeholder. External relinks clear local-only GGUF metadata;
// legacy placeholders normalize back to empty.
const isSourceLocal = source.is_local === true;
const isLegacyLocalPlaceholder =
target.model.trim().toLowerCase() === "local";

View file

@ -17,10 +17,7 @@ type LayoutOptions = {
configs?: Record<string, NodeConfig>;
};
/**
* Pipeline rank order used to enforce a logical flow even for disconnected nodes.
* Lower rank = earlier in the pipeline.
*/
/** Pipeline rank for logical flow; lower = earlier. */
function getPipelineRank(config: NodeConfig | undefined): number {
if (!config) {
return 2;
@ -65,17 +62,15 @@ function getEdgeWeight(edgeType: string | undefined): number {
/**
* Build phantom edges between disconnected data-pipeline nodes so dagre
* respects the pipeline rank order even when blocks aren't wired together.
*
* Groups nodes by rank, then inserts invisible edges from the last node of
* rank N to the first node of rank N+1 when no real edge already connects them.
* respects pipeline rank order even when blocks aren't wired together.
* Groups by rank, then inserts invisible edges from the last node of rank N
* to the first of rank N+1 when no real edge already connects them.
*/
function buildPhantomEdges(
nodes: Node[],
edges: Edge[],
configs: Record<string, NodeConfig>,
): Edge[] {
// Group nodes by rank
const byRank = new Map<number, string[]>();
for (const node of nodes) {
const rank = getPipelineRank(configs[node.id]);
@ -94,7 +89,6 @@ function buildPhantomEdges(
continue;
}
// Check if any real edge already connects these rank groups
const hasRealEdge = edges.some(
(e) => currentIds.includes(e.source) && nextIds.includes(e.target),
);
@ -102,7 +96,7 @@ function buildPhantomEdges(
continue;
}
// Insert one phantom edge from last node in current rank to first in next
// One phantom edge: last node in current rank to first in next.
phantoms.push({
id: `phantom-${ranks[i]}-${ranks[i + 1]}`,
source: currentIds[currentIds.length - 1],
@ -129,7 +123,7 @@ export function getLayoutedElements<TNode extends Node>(
configs,
} = options;
// When configs are provided, filter out infra and aux nodes from dagre
// With configs, filter infra and aux nodes out of dagre.
const hasConfigs = configs && Object.keys(configs).length > 0;
const dataNodes = hasConfigs
? nodes.filter((n) => !(isInfraNode(n.id, configs) || isAuxNode(n.id)))
@ -146,7 +140,7 @@ export function getLayoutedElements<TNode extends Node>(
)
: edges;
// Build phantom edges to enforce pipeline rank ordering for disconnected nodes
// Phantom edges enforce rank ordering for disconnected nodes.
const phantomEdges = hasConfigs
? buildPhantomEdges(dataNodes, dataEdges, configs)
: [];
@ -175,7 +169,7 @@ export function getLayoutedElements<TNode extends Node>(
dagre.layout(graph);
// Build position map from dagre results (data nodes only)
// Position map from dagre results (data nodes only).
const layoutedPositions = new Map<string, { x: number; y: number }>();
for (const node of dataNodes) {
const pos = graph.node(node.id);
@ -187,7 +181,7 @@ export function getLayoutedElements<TNode extends Node>(
});
}
// Apply positions: data nodes get dagre positions, infra/aux keep original
// Data nodes get dagre positions; infra/aux keep original.
const layoutedNodes = nodes.map((node) => {
const position = layoutedPositions.get(node.id);
if (!position) {

View file

@ -8,11 +8,10 @@ export function buildModelProvider(
config: ModelProviderConfig,
errors: string[],
): Record<string, unknown> {
// Local providers do not use any of the advanced request overrides -
// the backend overrides endpoint/api_key/provider_type and strips the
// extra fields in _inject_local_providers. Skip parsing the hidden
// JSON inputs here so imported or hydrated recipes with stale extra
// headers/body cannot block the client-side validation step.
// Local providers ignore advanced request overrides: the backend overrides
// endpoint/api_key/provider_type and strips extras in _inject_local_providers.
// Skip parsing hidden JSON inputs so stale headers/body in imported recipes
// can't block client-side validation.
if (config.is_local === true) {
return {
name: config.name,

View file

@ -134,7 +134,7 @@ function buildSamplerParams(
if (!raw) {
return {};
}
// UI historically used "uuid4" as a "format". data_designer uuid sampler is always uuid4.
// UI historically used "uuid4" as a format; data_designer uuid is always uuid4.
if (raw.toLowerCase() === "uuid4") {
return {};
}

View file

@ -36,9 +36,8 @@ export function CreateKeyForm({
onCreated(result.key);
setName("");
} catch {
// API helpers in ../api/api-keys.ts throw generic English Error
// messages; always use the translated message so zh-CN users do not
// see English text bleed through from internal exceptions.
// api-keys.ts throws English Error messages; use the translated one so
// zh-CN users don't see English bleed through from internal exceptions.
onError(t("settings.apiKeys.createError"));
} finally {
setLoading(false);

View file

@ -116,21 +116,20 @@ export function SettingsDialog() {
showCloseButton={false}
overlayClassName="bg-background/40"
onCloseAutoFocus={(e) => {
// Restore focus to the element that triggered openDialog().
// Radix's FocusScope races our rAF-scheduled tab-button focus
// and loses the previous-focus reference, so we restore by hand.
// Restore focus to the element that triggered openDialog(). Radix's
// FocusScope races our rAF-scheduled tab focus and loses the
// previous-focus reference, so restore it by hand.
if (opener && opener.isConnected) {
e.preventDefault();
opener.focus({ preventScroll: true });
}
}}
className={cn(
// Cap at 820px but shrink to the viewport so we don't clip
// on iPad-portrait widths (640-820px) where the fixed
// `w-[820px]` overflows by 26px on each side.
// Cap at 820px but shrink to the viewport so it doesn't clip on
// iPad-portrait widths (640-820px) where fixed `w-[820px]` overflows.
"settings-surface !max-w-[min(820px,calc(100vw-2rem))] h-[560px] w-[min(820px,calc(100vw-2rem))] p-0 overflow-hidden",
// Soft shadow only, no outline ring. Pin --radius to the light value
// so the corner rounding is the same in dark mode.
// Soft shadow, no outline ring. Pin --radius to the light value so
// corner rounding matches in dark mode.
"shadow-border rounded-xl ring-0 [--radius:1.1rem]",
"max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none",
)}

View file

@ -15,10 +15,10 @@ export type SettingsTab =
interface SettingsDialogState {
open: boolean;
activeTab: SettingsTab;
// Element focused at the moment openDialog() ran. Radix's FocusScope
// would normally track this, but the rAF-scheduled focus() in
// settings-dialog.tsx races its previous-focus capture, leaving focus
// on <body> after close. We restore explicitly via onCloseAutoFocus.
// Element focused when openDialog() ran. Radix's FocusScope normally tracks
// this, but the rAF-scheduled focus() in settings-dialog.tsx races its
// previous-focus capture, leaving focus on <body> after close. We restore
// explicitly via onCloseAutoFocus.
opener: HTMLElement | null;
openDialog: (tab?: SettingsTab) => void;
closeDialog: () => void;

View file

@ -32,9 +32,8 @@ function resolveTheme(theme: Theme): ResolvedTheme {
function applyToDocument(resolved: ResolvedTheme) {
if (typeof document === "undefined") return;
// Keep "dark"/"light" mutually exclusive. next-themes (via Sonner)
// adds "light" on first mount; without the explicit toggle we'd end
// up with `class="light dark"` after a switch.
// Keep "dark"/"light" mutually exclusive: next-themes (via Sonner) adds
// "light" on first mount, so without this toggle we'd get "light dark".
const cl = document.documentElement.classList;
cl.toggle("dark", resolved === "dark");
cl.toggle("light", resolved === "light");
@ -72,15 +71,14 @@ function getServerSnapshot(): Theme {
}
/**
* Single source of truth for setting the theme. All writers (the Settings
* dialog's segmented control AND the sidebar dropdown's animated toggler)
* must route through this so the DOM class, localStorage, and React
* subscribers stay in sync.
* Single source of truth for setting the theme. All writers (Settings dialog
* control, sidebar dropdown toggler) route through this so the DOM class,
* localStorage, and React subscribers stay in sync.
*/
export function setTheme(next: Theme): void {
if (typeof window === "undefined") return;
// Persist "system" explicitly so next-themes (mounted with
// defaultTheme="light") doesn't clobber the choice on reload.
// Persist "system" explicitly so next-themes (defaultTheme="light")
// doesn't clobber the choice on reload.
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {

View file

@ -32,9 +32,8 @@ export function ApiKeysTab() {
? { duration: 0 }
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
// API helpers in ../api/api-keys.ts throw generic English Error messages
// ("Failed to load API access", etc.). Always use the translated message
// so zh-CN users do not see those English strings bleed through.
// ../api/api-keys.ts throws generic English errors; use the translated
// message so zh-CN users don't see English strings bleed through.
const load = useCallback(async () => {
setLoading(true);
setError(null);

View file

@ -30,15 +30,10 @@ import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
// Keys cleared by "Reset all local preferences".
//
// NEVER include auth / session keys here — resetting them would log the user
// out, which is not what users expect from a "reset preferences" button.
//
// Explicitly EXCLUDED:
// - "unsloth_auth_token" (auth: access token)
// - "unsloth_auth_refresh_token" (auth: refresh token)
// - "unsloth_auth_must_change_password" (auth: forced password change flag)
// - "unsloth_onboarding_done" (session: would force re-onboarding)
// NEVER include auth/session keys here — clearing them would log the user out
// or force re-onboarding. Explicitly excluded: unsloth_auth_token,
// unsloth_auth_refresh_token, unsloth_auth_must_change_password,
// unsloth_onboarding_done.
const PREFS_KEYS: string[] = [
// Appearance
"theme",
@ -70,10 +65,8 @@ const PREFS_KEYS: string[] = [
"tour:studio:v1",
];
// Set to true from resetAllPrefs so the unmount-commit effect skips writing
// back the in-memory draft — otherwise the cleanup would re-persist the old
// HF token into localStorage after it was just cleared, and the subsequent
// reload would read the re-written value.
// Set by resetAllPrefs so the unmount-commit effect skips writing back the
// in-memory draft, else cleanup would re-persist the just-cleared HF token.
let resetInProgress = false;
function resetAllPrefs() {

View file

@ -95,7 +95,7 @@ export function HistoricalTrainingView({
const [detail, setDetail] = useState<TrainingRunDetailResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Derive loading from detail/error -- no separate state needed
// Derive loading from detail/error; no separate state.
const loading = detail === null && error === null;
useEffect(() => {
@ -114,7 +114,7 @@ export function HistoricalTrainingView({
});
return () => {
controller.abort();
// Reset on runId change so loading derives correctly for the next fetch
// Reset on runId change so loading derives correctly for the next fetch.
setDetail(null);
setError(null);
};

View file

@ -125,7 +125,7 @@ function Sparkline({
const w = 120;
const gradientId = `sparkFill-${id}`;
// Build points with vertical padding so the stroke isn't clipped
// Vertical padding so the stroke isn't clipped.
const pts = values.map((v, i) => ({
x: (i / (values.length - 1)) * w,
y: pad + (1 - (v - min) / range) * (h - pad * 2),
@ -206,7 +206,7 @@ export function HistoryCardGrid({
}, [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
// Cancel any in-flight poll so its stale response can't clobber this fetch.
pollControllerRef.current?.abort();
userControllerRef.current?.abort();
const controller = new AbortController();
@ -261,7 +261,7 @@ export function HistoryCardGrid({
};
}, [fetchRuns]);
// Poll while any run is still "running" so the card shows live progress
// Poll while any run is "running" so cards show live progress.
const hasRunningRun = runs.some((r) => r.status === "running");
const visibleCount = runs.length;
useEffect(() => {
@ -275,11 +275,11 @@ export function HistoryCardGrid({
try {
const limit = Math.max(PAGE_SIZE, visibleCount);
const result = await listTrainingRuns(limit, 0, controller.signal);
if (pollIdRef.current !== pid) return; // stale poll — discard
if (pollIdRef.current !== pid) return; // stale poll
setRuns(result.runs);
setTotal(result.total);
} catch {
// silently handle — poll will retry
// ignore; poll will retry
}
}, RUNNING_POLL_INTERVAL_MS);
return () => {
@ -294,11 +294,11 @@ export function HistoryCardGrid({
try {
await deleteTrainingRun(deleteTarget);
emitTrainingRunDeleted(deleteTarget);
// Re-fetch preserving visible count so offsets stay consistent for "Load more"
// Re-fetch preserving visible count so "Load more" offsets stay consistent.
const currentCount = runs.length - 1;
const limit = Math.max(PAGE_SIZE, currentCount);
fetchRuns(0, false, limit).catch(() => {
// Refresh failed — card is already removed, no stale display
// Refresh failed; card is already removed, no stale display.
});
} catch {
setDeleteError(translate("studio.history.deleteError"));

View file

@ -303,8 +303,8 @@ const FROM_CANONICAL: Record<string, Record<string, string>> = {
};
/**
* Remap a columnrole mapping between formats.
* Normalises every role to canonical chatml first, then maps to the target format.
* Remap a columnrole mapping between formats: normalise to canonical chatml,
* then map to the target format.
*/
export function remapRolesForFormat(
mapping: Record<string, string>,

View file

@ -86,8 +86,7 @@ export function DatasetPreviewDialog({
);
const { isStarting, startError, startTrainingRun } = useTrainingActions();
// If the backend reports image data, treat as VLM even if the prop
// hasn't caught up yet (isDatasetImage may still be null in the store).
// Treat backend-reported image data as VLM even if the prop hasn't caught up.
const effectiveIsAudio = !!data?.is_audio;
const effectiveIsVlm = isVlm || !!data?.is_image;
@ -119,7 +118,7 @@ export function DatasetPreviewDialog({
});
if (result.success && result.suggested_mapping) {
// Remap from chatml roles (user/assistant/system) to format-specific roles
// Remap chatml roles to format-specific roles
const table = ROLE_REMAP[datasetFormat];
const mapped: Record<string, string> = {};
for (const [col, role] of Object.entries(result.suggested_mapping)) {
@ -155,14 +154,12 @@ export function DatasetPreviewDialog({
setManualMapping(remapRolesForFormat(manualMapping, datasetFormat));
}, [datasetFormat]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle role change for a column
const handleRoleChange = useCallback(
(colName: string, role: string | undefined) => {
const next = { ...manualMapping };
// Remove this column's previous role
delete next[colName];
if (role) {
// Remove any other column that had this role (each role can only be assigned once)
// Each role maps to one column, so drop any other column holding it
for (const [col, r] of Object.entries(next)) {
if (r === role) delete next[col];
}
@ -230,7 +227,6 @@ export function DatasetPreviewDialog({
const rows = data?.preview_samples ?? [];
const columns = data?.columns ?? [];
// Determine source label
const sourceLabel = useMemo(() => {
if (!datasetName) return "";
if (datasetSource === "huggingface") {
@ -243,7 +239,6 @@ export function DatasetPreviewDialog({
return `Local Files (${datasetName})`;
}, [datasetName, datasetSource, datasetSubset, datasetSplit]);
// Build TanStack Table columns from the column names
const tableColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
if (!columns.length) return [];
@ -507,10 +502,7 @@ export function DatasetPreviewDialog({
);
}
// ---------------------------------------------------------------------------
// Metadata row
// ---------------------------------------------------------------------------
function MetaRow({
label,
value,

Some files were not shown because too many files have changed in this diff Show more