diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py index b30c0f5aea..47a5bc1dd3 100644 --- a/studio/backend/routes/llama.py +++ b/studio/backend/routes/llama.py @@ -31,6 +31,7 @@ class LlamaUpdateJob(BaseModel): from_tag: Optional[str] = None to_tag: Optional[str] = None error: Optional[str] = None + progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.") started_at: Optional[str] = None finished_at: Optional[str] = None diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 05a107377c..d21d653b76 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -28,6 +28,55 @@ import utils.llama_cpp_update as upd # noqa: E402 MARKER = "UNSLOTH_PREBUILT_INFO.json" +class _FakeInstallerPopen: + """Stands in for the streamed installer process in _run_update.""" + + def __init__( + self, + cmd, + *, + returncode = 0, + lines = None, + on_start = None, + captured_kwargs = None, + **kwargs, + ): + if captured_kwargs is not None: + captured_kwargs.update(kwargs) + if on_start is not None: + on_start(list(cmd)) + self.returncode = returncode + self.stdout = iter(lines or []) + + def wait(self): + return self.returncode + + def kill(self): + pass + + +def _patch_installer_popen( + monkeypatch, + *, + returncode = 0, + lines = None, + on_start = None, + captured_kwargs = None, +): + monkeypatch.setattr( + upd.subprocess, + "Popen", + lambda cmd, **kw: _FakeInstallerPopen( + cmd, + returncode = returncode, + lines = lines, + on_start = on_start, + captured_kwargs = captured_kwargs, + **kw, + ), + ) + + def _write_install( dir_: Path, tag: str, @@ -260,14 +309,15 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path): def _fake_run(cmd, **kwargs): cmd = list(cmd) - # Status polls probe `llama-server --version`; keep the installer argv. - if "--version" in cmd: - return _Proc() - captured["cmd"] = cmd - _write_install(install_dir, "b9585") # installer writes the marker + assert "--version" in cmd # only status polls still use run() return _Proc() + def _on_start(cmd): + captured["cmd"] = cmd + _write_install(install_dir, "b9585") # installer writes the marker + monkeypatch.setattr(upd.subprocess, "run", _fake_run) + _patch_installer_popen(monkeypatch, on_start = _on_start) res = upd.start_update() assert res["started"] is True, res @@ -298,21 +348,27 @@ def test_start_update_happy_path(monkeypatch, tmp_path): stdout = "installed" stderr = "" - def _fake_run(cmd, **kwargs): - cmd = list(cmd) - # Status polls probe `llama-server --version`; keep the installer argv. - if "--version" in cmd: - return _Proc() + def _on_start(cmd): captured["cmd"] = cmd # Simulate the installer writing a new marker with the latest tag. _write_install(install_dir, "b9518") - return _Proc() - monkeypatch.setattr(upd.subprocess, "run", _fake_run) + popen_kwargs: dict = {} + _patch_installer_popen( + monkeypatch, + lines = [ + "[llama-prebuilt] resolving release\n", + "Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n", + "Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n", + ], + on_start = _on_start, + captured_kwargs = popen_kwargs, + ) res = upd.start_update() assert res["started"] is True assert res["job"]["from_tag"] == "b9493" + assert res["job"]["progress"] == 0.0 # Wait for the background worker. deadline = time.time() + 10 @@ -328,6 +384,10 @@ def test_start_update_happy_path(monkeypatch, tmp_path): assert str(install_dir) in captured["cmd"] assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"] assert "unslothai/llama.cpp" in captured["cmd"] + # Progress lines were parsed and success pins progress at 1.0. + assert job["progress"] == 1.0 + # The worker asks the installer for fine-grained progress milestones. + assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5" def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path): @@ -336,12 +396,7 @@ def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path): monkeypatch.setattr(upd, "_find_binary", lambda: binary) monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py") - class _Proc: - returncode = 2 - stdout = "" - stderr = "boom: network error" - - monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc()) + _patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"]) res = upd.start_update() assert res["started"] is True @@ -410,14 +465,15 @@ def _capture_install_cmd( def _fake_run(cmd, **kwargs): cmd = list(cmd) - # Status polls probe `llama-server --version`; keep the installer argv. - if "--version" in cmd: - return _Proc() - captured["cmd"] = cmd - _write_install(install_dir, latest, repo = repo, asset = asset) + assert "--version" in cmd # only status polls still use run() return _Proc() + def _on_start(cmd): + captured["cmd"] = cmd + _write_install(install_dir, latest, repo = repo, asset = asset) + monkeypatch.setattr(upd.subprocess, "run", _fake_run) + _patch_installer_popen(monkeypatch, on_start = _on_start) res = upd.start_update() assert res["started"] is True, res @@ -535,18 +591,12 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path): seen = {} - class _Proc: - returncode = 0 - stdout = "ok" - stderr = "" - - def _fake_run(cmd, **kwargs): + def _on_start(cmd): # The maintenance flag must be set while the installer runs. seen["flag_during_install"] = backend._llama_update_in_progress _write_install(install_dir, "b9518") - return _Proc() - monkeypatch.setattr(upd.subprocess, "run", _fake_run) + _patch_installer_popen(monkeypatch, on_start = _on_start) res = upd.start_update() assert res["started"] is True @@ -571,12 +621,7 @@ def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_pa backend = _FakeBackend() _inject_backend(monkeypatch, backend) - class _Proc: - returncode = 1 - stdout = "" - stderr = "boom" - - monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc()) + _patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"]) res = upd.start_update() assert res["started"] is True @@ -606,16 +651,7 @@ def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path): monkeypatch.setitem(sys.modules, "routes", routes_pkg) monkeypatch.setitem(sys.modules, "routes.inference", inference_mod) - class _Proc: - returncode = 0 - stdout = "ok" - stderr = "" - - def _fake_run(cmd, **kwargs): - _write_install(install_dir, "b9518") - return _Proc() - - monkeypatch.setattr(upd.subprocess, "run", _fake_run) + _patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518")) res = upd.start_update() assert res["started"] is True diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index b138b29af3..55bf4b3d84 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -59,10 +59,17 @@ _job: dict = { "from_tag": None, "to_tag": None, "error": None, + "progress": None, "started_at": None, "finished_at": None, } +# Matches the installer's download progress lines, e.g. +# "Downloading x.zip: 35.0% (12.3 MiB/35.1 MiB) at 8.2 MiB/s". +_PROGRESS_LINE_RE = re.compile(r"(\d+(?:\.\d+)?)%\s*\(") +# The download dominates the update; extract/validate fill the last slice. +_DOWNLOAD_PROGRESS_CEILING = 0.95 + def _utcnow() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -357,15 +364,46 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path ] cmd.extend(_rocm_install_args(asset)) logger.info("llama update: installing", cmd = " ".join(cmd)) - proc = subprocess.run( + # Stream the installer output so download percent lines feed + # job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP. + env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5") + proc = subprocess.Popen( cmd, - capture_output = True, + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, text = True, - timeout = _INSTALL_TIMEOUT_SECONDS, + env = env, ) - if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip()[-1500:] - raise RuntimeError(f"installer exited {proc.returncode}: {tail or 'no output'}") + timed_out = threading.Event() + + def _kill_on_timeout() -> None: + timed_out.set() + proc.kill() + + watchdog = threading.Timer(_INSTALL_TIMEOUT_SECONDS, _kill_on_timeout) + watchdog.daemon = True + watchdog.start() + tail_lines: list[str] = [] + try: + assert proc.stdout is not None + for line in proc.stdout: + tail_lines.append(line) + if len(tail_lines) > 80: + del tail_lines[0] + m = _PROGRESS_LINE_RE.search(line) + if m is None: + continue + fraction = min(float(m.group(1)) / 100.0, 1.0) * _DOWNLOAD_PROGRESS_CEILING + with _job_lock: + _job["progress"] = max(_job.get("progress") or 0.0, fraction) + returncode = proc.wait() + finally: + watchdog.cancel() + if timed_out.is_set(): + raise RuntimeError(f"installer timed out after {_INSTALL_TIMEOUT_SECONDS}s") + if returncode != 0: + tail = "".join(tail_lines).strip()[-1500:] + raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}") # New UNSLOTH_PREBUILT_INFO.json is on disk; drop caches so the next # status read reflects the freshly installed tag. @@ -382,6 +420,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path ), to_tag = new_tag, error = None, + progress = 1.0, finished_at = _utcnow(), ) logger.info("llama update: success", to_tag = new_tag) @@ -467,6 +506,7 @@ def start_update() -> dict: from_tag = from_tag, to_tag = None, error = None, + progress = 0.0, started_at = _utcnow(), finished_at = None, ) @@ -491,6 +531,7 @@ def _reset_job_for_tests() -> None: from_tag = None, to_tag = None, error = None, + progress = None, started_at = None, finished_at = None, ) diff --git a/studio/backend/utils/studio_version.py b/studio/backend/utils/studio_version.py index 98c48fe45c..9c18070fbb 100644 --- a/studio/backend/utils/studio_version.py +++ b/studio/backend/utils/studio_version.py @@ -15,6 +15,7 @@ _DEV_VERSION = "dev" _GIT_TIMEOUT_SECONDS = 1.0 _STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$") _GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$") +_GIT_BRANCH_RE = re.compile(r"^[0-9A-Za-z._/-]+$") _MAX_VERSION_LENGTH = 64 @@ -71,6 +72,35 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None: return tag if is_valid_studio_release_version(tag) else None +def _git_branch(repo_root: Path) -> str | None: + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd = repo_root, + check = False, + stdout = subprocess.PIPE, + stderr = subprocess.DEVNULL, + text = True, + timeout = _GIT_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired): + return None + + if result.returncode != 0: + return None + + branch = result.stdout.strip() + # "HEAD" means detached, e.g. a tag or commit checkout. + if ( + not branch + or branch == "HEAD" + or len(branch) > _MAX_VERSION_LENGTH + or _GIT_BRANCH_RE.fullmatch(branch) is None + ): + return None + return branch + + def get_studio_version(repo_root: Path | None = None) -> str: """Return the installed Studio release tag for display, or ``dev``. @@ -81,7 +111,10 @@ def get_studio_version(repo_root: Path | None = None) -> str: if _is_source_checkout(resolved_repo_root): git_tag = _exact_git_studio_tag(resolved_repo_root) - return git_tag if git_tag is not None else _DEV_VERSION + if git_tag is not None: + return git_tag + branch = _git_branch(resolved_repo_root) + return f"GitHub {branch}" if branch is not None else _DEV_VERSION stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION if is_valid_studio_release_version(stamped_version): diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 7baa69571c..f1a200b09b 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -60,6 +60,7 @@ import { Logout05Icon, MoreVerticalIcon, Search01Icon, + PlusSignIcon, PowerIcon, PencilEdit02Icon, LayoutAlignLeftIcon, @@ -558,7 +559,8 @@ export function AppSidebar() { : "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"; const buttonClass = cn( "sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", - variant === "project" ? "pl-[39px]" : "pl-3", + // pl-3.5 starts the title at the same x as the Recents label text. + variant === "project" ? "pl-[39px]" : "pl-3.5", variant === "project" ? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" : "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8", @@ -837,7 +839,28 @@ export function AppSidebar() { navigate({ to: "/projects" }); closeMobileIfOpen(); }} - /> + className="group/projects-item relative" + > + + -
+
{displayTitle} Unsloth
diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index c8a4dd1e40..83320f8e15 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -3,9 +3,10 @@ import { Button } from "@/components/ui/button"; import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check"; +import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref"; import { toast } from "@/lib/toast"; import { AnimatePresence, motion } from "motion/react"; -import { type ReactElement, useEffect, useRef } from "react"; +import type { ReactElement } from "react"; const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; @@ -15,15 +16,19 @@ interface LlamaUpdateBannerProps { /** * Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a - * newer prebuilt is detected and stays up until dismissed (click outside / X) - * or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update. + * newer prebuilt is detected and stays up until the user explicitly acts on it + * (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place + * via POST /api/llama/update. Can be turned off entirely in Settings -> + * General -> Notifications (on by default). */ export function LlamaUpdateBanner({ enabled = true, }: LlamaUpdateBannerProps): ReactElement | null { - const { status, visible, applying, apply, dismiss } = useLlamaUpdateCheck({ - enabled, - }); + const showBannerPref = useShowLlamaUpdateBanner(); + const { status, visible, applying, apply, dismiss, snooze } = + useLlamaUpdateCheck({ + enabled: enabled && showBannerPref, + }); async function handleUpdate() { const result = await apply(); @@ -40,30 +45,12 @@ export function LlamaUpdateBanner({ const show = visible && status != null && (status.update_available || applying); - const bannerRef = useRef(null); - - // Dismiss when the user clicks anything outside the banner. Kept off while an - // update is applying so the progress stays visible. - useEffect(() => { - if (!show || applying) return; - function onPointerDown(event: PointerEvent) { - if ( - bannerRef.current && - !bannerRef.current.contains(event.target as Node) - ) { - dismiss(); - } - } - document.addEventListener("pointerdown", onPointerDown, true); - return () => - document.removeEventListener("pointerdown", onPointerDown, true); - }, [show, applying, dismiss]); + const updateProgress = status?.job.progress ?? null; return ( {show ? ( -
+
{applying ? null : ( -
+ {updateProgress != null && updateProgress > 0 ? ( +
+ ) : ( + // No percent yet (resolving the release): sweep until the + // first download progress arrives. +
+ )} +
+ ) : ( +
+ + +
+ )}
) : null} diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx index 1eb3f81d10..8e373c82a0 100644 --- a/studio/frontend/src/components/tauri/startup-screen.tsx +++ b/studio/frontend/src/components/tauri/startup-screen.tsx @@ -228,7 +228,7 @@ function RepairingContent({
-

Updating existing Studio install...

+

Updating existing Unsloth install...

{latest && (

{latest}

)} diff --git a/studio/frontend/src/components/ui/tooltip.tsx b/studio/frontend/src/components/ui/tooltip.tsx index 954b1871f3..cbc1a09a8f 100644 --- a/studio/frontend/src/components/ui/tooltip.tsx +++ b/studio/frontend/src/components/ui/tooltip.tsx @@ -98,9 +98,27 @@ function TooltipContent({ }: React.ComponentProps & { variant?: TooltipVariant; }) { + // Single-line compact tooltips render as a full pill; wrapped ones keep + // the squarer corners so tall pills do not look like capsules. A ref + // callback measures on mount: Radix mounts the portal content without + // re-rendering this wrapper, so an effect here would never see the node. + const measureRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el || variant !== "default") return; + const cs = getComputedStyle(el); + const lineHeight = Number.parseFloat(cs.lineHeight) || 16; + const innerHeight = + el.clientHeight - + Number.parseFloat(cs.paddingTop) - + Number.parseFloat(cs.paddingBottom); + el.classList.toggle("rounded-full!", innerHeight < lineHeight * 1.5); + }, + [variant], + ); return ( s.deviceType); + const installCmd = + deviceType === "windows" + ? STUDIO_INSTALL_WINDOWS_CMD + : STUDIO_INSTALL_UNIX_CMD; const [copiedVersion, setCopiedVersion] = useState(null); const dismissTimerRef = useRef | null>(null); @@ -36,7 +44,7 @@ export function WebUpdateBanner({ } async function handleCopyCommand() { - if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) { + if (!(await copyToClipboard(installCmd))) { return; } setCopiedVersion(status?.latestVersion ?? null); @@ -89,8 +97,8 @@ export function WebUpdateBanner({ Package update available: {status.latestVersion}

- Installed package: {status.currentVersion}. To update Studio, - run this in your terminal, then restart Studio. + Installed package: {status.currentVersion}. To update Unsloth, + run this in your terminal, then restart Unsloth.

diff --git a/studio/frontend/src/config/env.ts b/studio/frontend/src/config/env.ts index 92dad2ae27..61ab111d45 100644 --- a/studio/frontend/src/config/env.ts +++ b/studio/frontend/src/config/env.ts @@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise { if (fetched) return usePlatformStore.getState().deviceType; try { - const res = await fetch(apiUrl("/api/health")); + // /api/health only reports the server's device_type to authed callers. + // Read the token from storage directly: importing features/auth here + // would be an import cycle (auth/session imports this store). + const token = + typeof window === "undefined" + ? null + : localStorage.getItem("unsloth_auth_token"); + const res = await fetch(apiUrl("/api/health"), { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); if (res.ok) { const data = (await res.json()) as { device_type?: string; chat_only?: boolean }; const deviceType = data.device_type ?? detectLocalPlatform(); const chatOnly = data.chat_only ?? false; - usePlatformStore.setState({ deviceType, chatOnly, fetched: true }); + // Cache only a server-reported platform. Unauthenticated responses fall + // back to the browser platform, which can differ from the host (WSL, + // SSH); keeping fetched=false retries once a token exists. + usePlatformStore.setState({ + deviceType, + chatOnly, + fetched: data.device_type !== undefined, + }); return deviceType; } } catch { diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts index 6356f89378..144e85d40a 100644 --- a/studio/frontend/src/features/auth/api.ts +++ b/studio/frontend/src/features/auth/api.ts @@ -175,7 +175,7 @@ export async function authFetch( "You appear to be offline. Check your network connection and try again.", ); } - throw new Error("Studio isn't running -- please relaunch it."); + throw new Error("Unsloth isn't running -- please relaunch it."); } throw err; } diff --git a/studio/frontend/src/features/auth/tauri-auto-auth.ts b/studio/frontend/src/features/auth/tauri-auto-auth.ts index 760af26395..e825d5de31 100644 --- a/studio/frontend/src/features/auth/tauri-auto-auth.ts +++ b/studio/frontend/src/features/auth/tauri-auto-auth.ts @@ -26,7 +26,7 @@ let pending: { promise: Promise; force: boolean } | null = null; let lastTauriAuthFailure: string | null = null; const TAURI_AUTH_FAILURE_FALLBACK = - "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio."; + "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth."; const BACKEND_NOT_READY_MESSAGE = "Backend is not ready"; function authFailureMessage(error: unknown): string { diff --git a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts index a679e8ed83..146a12bd40 100644 --- a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts +++ b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts @@ -18,7 +18,7 @@ const describeMediaError = (error: unknown): string => { return "Dictation could not access the microphone."; } if (error.name === "NotAllowedError") { - return "Microphone access is blocked. Allow microphone access for this Studio page, then try again."; + return "Microphone access is blocked. Allow microphone access for this Unsloth page, then try again."; } if (error.name === "NotFoundError") { return "No microphone was found for dictation."; @@ -31,7 +31,7 @@ const describeMediaError = (error: unknown): string => { const describeSpeechError = (error: string, message?: string): string => { if (error === "not-allowed") { - return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page."; + return "Speech recognition was blocked by the browser. Check microphone permissions for this Unsloth page."; } if (error === "service-not-allowed") { return "Speech recognition is blocked by the browser speech service."; diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index e35a8e35e0..0594b55083 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({

Connections

- Manage model connections for chat through the Studio proxy. + Manage model connections for chat.

diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 03c183fe76..5751a1a426 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -325,6 +325,7 @@ function CollapsibleSection({ label, labelHref, headerAction, + onLabelClick, children, defaultOpen = false, first = false, @@ -342,6 +343,8 @@ function CollapsibleSection({ * nested in a button. */ headerAction?: ReactNode; + /** When set, clicking the label runs this instead of toggling collapse. */ + onLabelClick?: () => void; children?: ReactNode; defaultOpen?: boolean; first?: boolean; @@ -395,7 +398,7 @@ function CollapsibleSection({
+