diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 081eda4e32..0a8d71610d 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -766,6 +766,7 @@ jobs:
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
+ PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
@@ -911,6 +912,8 @@ jobs:
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
metadata = {
'version': os.environ['APP_VERSION'],
+ # App version is SemVer; CHANGELOG.md is keyed by the backend release.
+ 'pypi_version': os.environ['PYPI_VERSION'],
'notes': notes,
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'platforms': {
diff --git a/.gitignore b/.gitignore
index fafd17aa95..fa6997cb06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,6 +208,9 @@ tmp/
**/node_modules/
auth.db
+# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
+studio/CHANGELOG.md
+
# Tauri local build/generated output
studio/src-tauri/target/
studio/src-tauri/gen/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..241e013cea
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,88 @@
+# Changelog
+
+Release notes for Unsloth and Unsloth Studio.
+
+Unsloth Studio reads this file to show release notes inside the "New Unsloth
+version" update popup. Edit it here and the popup picks the change up on the
+next update check, with no release or rebuild required.
+
+## Format
+
+Every release is a level-2 heading whose first token is the version, optionally
+followed by a date:
+
+```md
+## 2026.7.6 - 2026-07-22
+```
+
+`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
+heading, up to the next level-2 heading, is that release's notes and renders as
+Markdown in the popup.
+
+Notes are matched to one exact version. When Studio offers an update to
+`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
+is missing, the popup links out to the online changelog rather than showing
+notes from an unrelated release, so a new version needs its own section here
+before its notes can appear.
+
+Keep the newest release at the top. Lead each bullet with the change itself:
+the collapsed popup highlights the first sentence and dims the rest.
+`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
+rename the heading at release time.
+
+
+
+## Unreleased
+
+## 2026.7.5
+
+### What's Changed
+
+- AMD support is here. Train, run RL, chat with and deploy 500+ models on
+ Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
+ up to 2x faster with 70% less VRAM and no accuracy loss.
+- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
+ training alongside the NVIDIA, AMD and Apple paths.
+- Local speech to text dictation runs fully offline, with slim Whisper bundles
+ and a picker for custom models.
+- DoRA training is available in Studio, selectable next to LoRA and full
+ fine-tuning in the training tab.
+- The update popup previews release notes inline, pulled from this file and
+ matched to the exact version being offered.
+
+### AMD, 23 July update
+
+Our AMD collaboration, custom Triton kernels and math algorithms bring local
+training and inference to AMD hardware. The 23 July update builds on the
+[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
+
+- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
+ detect GPUs on Strix Halo and other AMD cards.
+- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
+ automatically instead of stopping the install.
+- Unified memory safetensors loading is 2x faster, with much faster gradient
+ checkpointing on unified memory devices.
+- Voice dictation through whisper.cpp has preliminary support.
+- Rollback environments left by installs no longer eat 5GB of disk. They are
+ cleaned up automatically.
+
+Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
+compatibility is improved for MI300X and MI325X. Full guide:
+[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
+
+### Running larger models
+
+- Automatic GPU placement, or pick exactly which GPUs and layers to use.
+- Move MoE expert layers into system memory so larger models fit.
+- Split a model across several GPUs, or use tensor parallelism.
+- Hardware settings are saved per model and quant.
+
+### Also in this release
+
+- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
+- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
+ output and tool retries are more reliable.
+- The model download location is configurable, so weights can live on a second
+ drive instead of the default cache.
+- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
+ GGUF files are reused instead of downloaded again.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000..7bce036343
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include _changelog_build.py
+include CHANGELOG.md
diff --git a/_changelog_build.py b/_changelog_build.py
new file mode 100644
index 0000000000..f5bcf2052c
--- /dev/null
+++ b/_changelog_build.py
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+"""Snapshot CHANGELOG.md into the studio package at build time.
+
+CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
+rather than in build.sh, means every packaging path ships it, so release notes
+still render when the popup cannot reach GitHub."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+from setuptools.command.build_py import build_py as _build_py
+
+ROOT = Path(__file__).resolve().parent
+SOURCE = ROOT / "CHANGELOG.md"
+SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
+
+
+class build_py(_build_py):
+ def run(self) -> None:
+ # Beside the sources only if writable (PEP 517 may build an immutable
+ # checkout); into the staging directory always.
+ if SOURCE.is_file():
+ try:
+ shutil.copyfile(SOURCE, SNAPSHOT)
+ except OSError:
+ pass
+ super().run()
+ if not SOURCE.is_file():
+ return
+ staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
+ staged.parent.mkdir(parents = True, exist_ok = True)
+ shutil.copyfile(SOURCE, staged)
diff --git a/build.sh b/build.sh
index 2a836e19d9..5b09a7791b 100644
--- a/build.sh
+++ b/build.sh
@@ -103,9 +103,13 @@ else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
-# 4. Build wheel/sdist
+# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio
+# package so release notes render offline.
python -m build
+# Drop the snapshot so a source checkout never serves a stale copy.
+rm -f studio/CHANGELOG.md
+
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi
diff --git a/pyproject.toml b/pyproject.toml
index ce19d21399..8895bf0686 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"}
[tool.setuptools]
include-package-data = true
+[tool.setuptools.cmdclass]
+# Snapshots CHANGELOG.md into studio/ so every build path ships it.
+build_py = "_changelog_build.build_py"
+
[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [
+ "CHANGELOG.md",
"*.sh",
"*.ps1",
"*.bat",
diff --git a/studio/backend/main.py b/studio/backend/main.py
index 02f5a20106..9a2e598314 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -347,6 +347,7 @@ from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
+from utils.changelog import get_release_notes, is_supported_version_query
from utils.studio_version import get_studio_version
from utils.api_errors import install_api_error_handlers
@@ -1154,6 +1155,18 @@ def studio_update_status(_current_subject: str = Depends(get_current_subject)):
return get_studio_update_status(UNSLOTH_VERSION)
+@app.get("/api/studio/release-notes")
+def studio_release_notes(
+ version: str = Query(..., max_length = 64),
+ refresh: bool = Query(False),
+ _current_subject: str = Depends(get_current_subject),
+):
+ """Return CHANGELOG.md notes for exactly `version` (never a nearby one)."""
+ if not is_supported_version_query(version):
+ raise HTTPException(status_code = 422, detail = "Invalid version.")
+ return get_release_notes(version, refresh = refresh)
+
+
@app.get(
"/api/studio/download-transport-capabilities",
response_model = TransportCapabilities,
diff --git a/studio/backend/utils/changelog.py b/studio/backend/utils/changelog.py
new file mode 100644
index 0000000000..84cd54df05
--- /dev/null
+++ b/studio/backend/utils/changelog.py
@@ -0,0 +1,1056 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Release notes for the update popup, sourced from CHANGELOG.md.
+
+Notes are keyed to one exact version: the popup asks for the version it is
+offering and gets that section or nothing, so an older release's notes can
+never appear next to a newer update.
+
+The remote copy on the default branch wins over the bundled one, since the
+offered version is newer than the installed checkout. Both reads are lazy,
+cached and skipped when update checks are off.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import threading
+import time
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from packaging.version import InvalidVersion, Version
+
+from .update_status import DISABLE_ENV_VAR, RELEASE_NOTES_URL
+
+CHANGELOG_FILENAME = "CHANGELOG.md"
+CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/unslothai/unsloth/main/CHANGELOG.md"
+CHANGELOG_URL_ENV_VAR = "UNSLOTH_CHANGELOG_URL"
+CHANGELOG_PATH_ENV_VAR = "UNSLOTH_CHANGELOG_PATH"
+CHANGELOG_TIMEOUT_SECONDS = 3
+CHANGELOG_MAX_BYTES = 2 * 1024 * 1024
+_CHANGELOG_CHUNK_BYTES = 64 * 1024
+_CHANGELOG_MIN_READ_SECONDS = 0.05
+CHANGELOG_SUCCESS_TTL_SECONDS = 30 * 60
+CHANGELOG_FAILURE_TTL_SECONDS = 5 * 60
+RELEASE_NOTES_MAX_CHARS = 20_000
+
+# CommonMark requires a space, tab or line end after the hashes: a non-breaking
+# space copied from rich text renders as text, not a heading, but a bare `##` is
+# an empty heading and still ends the release above.
+_HEADING_PATTERN = re.compile(r"^ {0,3}##(?:[ \t]+(?P
.*?))?[ \t]*$")
+_FENCE_PATTERN = re.compile(r"^ {0,3}(?P`{3,}|~{3,})(?P.*)$")
+# CommonMark type 1 HTML blocks: contents are literal until a closing tag,
+# which the spec says need not be the one that opened the block.
+_RAW_HTML_OPEN = re.compile(r"^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)", re.IGNORECASE)
+_RAW_HTML_CLOSE = re.compile(r"(pre|script|style|textarea)\s*>", re.IGNORECASE)
+# Types 3 to 5 (processing instructions, declarations, CDATA) are literal too,
+# each ending on its own delimiter. Comments open mid-line, so are separate.
+_RAW_BLOCKS = (
+ (_RAW_HTML_OPEN, _RAW_HTML_CLOSE),
+ (re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
+ (re.compile(r"^ {0,3}")),
+ # A declaration needs an uppercase letter, so `")),
+)
+# Type 6 blocks run to the next blank line, so `` only holds Markdown
+# once a blank line has closed the block. Open and close tags both start one.
+_HTML_BLOCK_OPEN = re.compile(r"^ {0,3}?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)")
+# Blocks that break into an open paragraph, so none is open after them and one
+# they are written below is closed rather than continued.
+_INTERRUPTS = re.compile(
+ r"^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)"
+)
+# A definition is a block of its own but may not interrupt a paragraph, so it
+# ends the one above it only when there is none to continue.
+_LINK_DEFINITION = re.compile(r"^ {0,3}\[(?:[^\[\]\\]|\\.)+\]:")
+# Blocks that are not paragraph text, so a following underline is not setext.
+_PARAGRAPH_TEXT = re.compile(r"^ {0,3}(?|\d{1,9}[.)]([ \t]|$))\S")
+# A line of = or - under a paragraph line makes that line a heading.
+_SETEXT_UNDERLINE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
+# A quoted paragraph continues on unmarked lines, which belong to the quote.
+_BLOCK_QUOTE = re.compile(r"^ {0,3}>")
+_QUOTE_MARKER = re.compile(r"^ {0,3}>[ \t]?")
+# A heading at an item's content column belongs to that item, not the document.
+# The marker needs whitespace after it, so `2.0` is a version, not an item.
+_LIST_ITEM = re.compile(r"^[ \t]*(?P[-*+]|\d{1,9}[.)])(?P[ \t]+|$)")
+_THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
+# Content indented more than this after a marker is an indented code block, so
+# the item's content starts one column past the marker instead.
+_MAX_ITEM_PADDING = 4
+_HTML_BLOCK_TAGS = frozenset(
+ """
+address article aside base basefont blockquote body caption center col colgroup
+dd details dialog dir div dl dt fieldset figcaption figure footer form frame
+frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu
+menuitem nav noframes ol optgroup option p param search section summary table
+tbody td tfoot th thead title tr track ul
+""".split()
+)
+# Type 7: any other complete tag alone on a line. It cannot interrupt a
+# paragraph, so it only counts after a break.
+_HTML_ATTRIBUTE = (
+ r"""(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)"""
+)
+_HTML_TAG_ONLY_LINE = re.compile(
+ rf"^ {{0,3}}(?:<[a-zA-Z][a-zA-Z0-9-]*{_HTML_ATTRIBUTE}*\s*/?>|[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$"
+)
+# Levels above studio/ are the repo root in a checkout and site-packages in an
+# install, so they are searched only when one of these markers is present.
+_CHECKOUT_ONLY_LEVELS = (3, 4)
+_CHECKOUT_MARKERS = ("pyproject.toml", ".git")
+_COMMENT_BLOCK_OPEN = re.compile(r"^ {0,3}"
+# Stands in for a line the renderer hides. `#` is a block of its own, so list
+# tracking reads it like a comment: never a marker, never a lazy continuation.
+_HIDDEN_BLOCK = "#"
+_VERSION_TOKEN_PATTERN = re.compile(r"^[\[(]?v?(?P[0-9][0-9A-Za-z.!+-]*?)[\])]?$")
+_SAFE_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.!+-]{0,63}$")
+
+
+@dataclass(frozen = True)
+class _ListState:
+ """The open list items, innermost last, by the column their content starts."""
+
+ columns: tuple[int, ...] = ()
+ # True while the innermost item has had no content since its marker.
+ empty_item: bool = False
+
+
+@dataclass(frozen = True)
+class ChangelogEntry:
+ """One `## ` section of the changelog."""
+
+ version: str
+ heading: str
+ body: str
+
+
+@dataclass(frozen = True)
+class ChangelogSource:
+ text: str | None
+ source: str | None
+ error: str | None = None
+
+
+@dataclass
+class _ChangelogCacheEntry:
+ source: ChangelogSource
+ expires_at: float
+
+
+_cache_condition = threading.Condition()
+_remote_cache: _ChangelogCacheEntry | None = None
+_remote_fetching = False
+
+
+def reset_changelog_cache() -> None:
+ """Clear the in-process changelog cache. Intended for tests."""
+ global _remote_cache, _remote_fetching
+ with _cache_condition:
+ _remote_cache = None
+ _remote_fetching = False
+ _cache_condition.notify_all()
+
+
+def is_supported_version_query(version: str) -> bool:
+ """Whether `version` is shaped like something we can look up at all.
+
+ Sections are indexed only when their version parses, so a query that does
+ not parse (`latest`, `main`) can never match and is rejected outright."""
+ candidate = version.strip()
+ if not _SAFE_VERSION_PATTERN.match(candidate):
+ return False
+ return _parse_version(candidate) is not None
+
+
+def _markdown_lines(text: str) -> list[str]:
+ """``text`` split the way CommonMark ends lines.
+
+ str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form
+ feed, none of which end a line in Markdown. A separator sitting in prose
+ before "## 9.9.9" would otherwise index a release the renderer never shows
+ and truncate the notes above it.
+ """
+ return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+
+
+def parse_changelog(text: str) -> list[ChangelogEntry]:
+ """Parse `## ` sections, in file order.
+
+ Headings whose first token is not a version (`## Unreleased`, `## Format`)
+ end the previous section but are not indexed.
+ """
+ # A Windows editor can leave a BOM on the first line, hiding a heading.
+ text = text.lstrip("")
+ entries: list[ChangelogEntry] = []
+ heading: str | None = None
+ version: str | None = None
+ body: list[str] = []
+ open_fence: str | None = None
+ # Content column of the list item the open block belongs to, 0 at document
+ # level. A fence and an HTML block are scoped to their container, so the
+ # item's end closes them. Only one of the three is ever open.
+ block_column = 0
+ in_comment = False
+ in_raw_html: int | None = None
+ in_html_block = False
+ after_paragraph = False
+ paragraph: list[str] = []
+ in_quote = False
+ quoted = False
+ lists = _ListState()
+
+ def flush() -> None:
+ if version is not None and heading is not None:
+ entries.append(
+ ChangelogEntry(
+ version = version,
+ heading = heading,
+ body = "\n".join(body).strip(),
+ )
+ )
+
+ for line in _markdown_lines(text):
+ # The line as list tracking sees it: blank wherever nothing renders.
+ structural = ""
+ opened_block = False
+ in_block = open_fence is not None or in_html_block or in_raw_html is not None or in_comment
+ # A fence, comment or HTML block inside a list item runs only to the end
+ # of that item, so a line dedented out of the item closes both. Lazy
+ # continuation reaches into none of them. A raw block or comment inside an
+ # item also ends on a blank line: the item takes the break, so what
+ # follows is a block of the item's own.
+ leaves = (
+ _indent_width(line) < block_column
+ if line.strip()
+ else in_raw_html is not None or in_comment
+ )
+ if in_block and block_column and leaves:
+ open_fence = None
+ in_html_block = False
+ in_raw_html = None
+ in_comment = False
+ block_column = 0
+ # The paragraph the line could have continued is block content, so
+ # it closes the item rather than reading as more of it.
+ after_paragraph = False
+ # A fence written as a list item's first content opens inside that item, so
+ # an opener is read past a marker on the same line. Only an opener: fenced
+ # content is literal and a closer carries no marker.
+ fence_line = line if open_fence else _item_content(line, after_paragraph)
+ # Raw HTML first: its contents are literal, so a fence in it is not one.
+ if in_raw_html is not None:
+ visible, in_raw_html = _strip_raw_html(line, in_raw_html)
+ elif in_html_block:
+ # A blank line is the only thing that ends a type 6 block.
+ in_html_block = line.strip() != ""
+ visible = ""
+ elif (fence := _FENCE_PATTERN.match(fence_line)) and not in_comment:
+ was_open = open_fence
+ open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
+ opened_block = was_open is None and open_fence is not None
+ # Hidden from heading matching, but its indent still closes items.
+ visible = ""
+ structural = line
+ elif open_fence:
+ visible = ""
+ else:
+ # A block already open owns this line, so it is content rather than a
+ # block written at the column it happens to start in.
+ hidden = in_comment or in_raw_html is not None
+ # A comment is an HTML block too, so one written as a list item's first
+ # content opens inside it exactly as a fence does: the opener is read
+ # past a marker on the same line.
+ block_open = (
+ not in_comment
+ and _COMMENT_BLOCK_OPEN.match(_item_content(line, after_paragraph)) is not None
+ )
+ # Commented-out sections are not rendered, so they are not releases.
+ visible, in_comment = _strip_comments(line, in_comment, block_open)
+ # An HTML block written as a list item's first content opens inside
+ # that item, as a fence does, so an opener is read past a marker on the
+ # same line. The marker stays, so its item is still tracked. A comment
+ # blanks its own line, so that line is read as written: the block
+ # renders as nothing, but the item it is content of still opens.
+ source = line if block_open else visible
+ content = _item_content(source, after_paragraph)
+ marker = source[: len(source) - len(content)]
+ # Nor is anything inside a raw HTML block such as
.
+ stripped, in_raw_html = _strip_raw_html(content, in_raw_html)
+ opened_block = in_raw_html is not None or (block_open and in_comment)
+ # Taken before the opener is hidden: it renders as nothing, but its
+ # indent still closes a list item it sits left of, and a marker on its
+ # line still opens one. A comment or raw block keeps only those, since
+ # the text it hides is not Markdown and must open no list.
+ if block_open or stripped != content:
+ if not hidden:
+ structural = _hidden_structure(line, marker)
+ visible = ""
+ else:
+ visible = marker + stripped
+ if visible.strip():
+ structural = visible
+ elif not hidden:
+ structural = _hidden_structure(line)
+ if stripped and _opens_html_block(stripped, after_paragraph):
+ in_html_block = True
+ opened_block = True
+ visible = ""
+ # A `##` inside a fenced block is sample markdown, not a real heading.
+ match = _HEADING_PATTERN.match(visible) if visible else None
+ # `1.0` over a line of dashes is the same heading written setext style.
+ setext = (
+ after_paragraph
+ and match is None
+ and paragraph != []
+ and _SETEXT_UNDERLINE.match(visible) is not None
+ and (visible.strip()[:1] == "-")
+ # Never a boundary inside a list item: dedented the dashes are a
+ # thematic break, and at the content column the heading is nested.
+ and not lists.columns
+ )
+ if setext:
+ if version is not None:
+ # The whole paragraph is the heading, read as body on arrival.
+ del body[len(body) - len(paragraph) :]
+ flush()
+ # A wrapped heading keeps every line, so token one is the version.
+ heading = "\n".join(paragraph)
+ version = _version_from_heading(heading)
+ body = []
+ paragraph = []
+ after_paragraph = False
+ continue
+ # A dashed underline is not a list marker, so track lists after setext.
+ lazy_marker = _lazy_marker(structural, lists, after_paragraph, quoted)
+ lists = _open_lists(structural, lists, after_paragraph, quoted)
+ # Taken after the opening line closed the items it is dedented out of,
+ # so the block belongs to the item it is really written inside.
+ if opened_block:
+ block_column = lists.columns[-1] if lists.columns else 0
+ elif open_fence is None and not in_html_block and in_raw_html is None and not in_comment:
+ block_column = 0
+ # At an open item's content column a heading is nested, not a boundary.
+ if lists.columns and _indent_width(visible) >= lists.columns[0]:
+ match = None
+ # The line at its own nesting level: past the container's indentation
+ # and past a marker on the same line, so `- ## 2.0` reads as a heading.
+ column = lists.columns[-1] if lists.columns else 0
+ content = _strip_indent(visible, column)
+ if (item := _LIST_ITEM.match(content)) is not None:
+ content = content[item.end() :]
+ # Only ordinary text continues a paragraph. Indented code counts four
+ # spaces past the container, so an item's own indent does not count.
+ indented_code = not after_paragraph and _indent_width(visible) - column >= 4
+ # An underline ends the paragraph it underlines, so it needs one open in
+ # its own container: the quote above owns its own, and a row left of an
+ # open item is lazy text of the item's paragraph. Three dashes are a
+ # thematic break either way, which `_INTERRUPTS` already ends on.
+ underline = (
+ _SETEXT_UNDERLINE.match(visible) is not None
+ and after_paragraph
+ and not quoted
+ and _indent_width(visible) >= column
+ )
+ after_paragraph = (
+ # Read inside its container, so an empty item and a fence written as an
+ # item's own content leave no paragraph open below them. A marker the
+ # paragraph above swallows is its text, not an item.
+ (bool(content.strip()) or lazy_marker)
+ and match is None
+ and _HEADING_PATTERN.match(content) is None
+ and _FENCE_PATTERN.match(content) is None
+ and not indented_code
+ and _INTERRUPTS.match(visible) is None
+ and (after_paragraph or _LINK_DEFINITION.match(visible) is None)
+ and not underline
+ )
+ # A quote's paragraph runs on over plain text and owns every line of it.
+ # An empty quote holds none, so the line below starts the document's.
+ flush_left = visible.lstrip(" \t")
+ quote_line = _BLOCK_QUOTE.match(visible) is not None
+ in_quote = (
+ _may_be_lazy(_quote_content(visible))
+ if quote_line
+ else in_quote and _continues_paragraph(visible, column)
+ )
+ if quote_line:
+ # The only paragraph a quote line leaves open is the quote's own,
+ # and a quote holding a heading or nothing at all leaves none.
+ after_paragraph = in_quote
+ # Whose paragraph the line below would continue. A quote owns the one its
+ # own lines hold, so a marker outside the quote is a block of its own
+ # rather than more of the text above it.
+ quoted = quote_line or in_quote
+ # The lines a later underline turns into one heading. A paragraph opens
+ # only on plain text and then runs on until something interrupts it.
+ continues = (
+ not _interrupts_paragraph(flush_left)
+ if paragraph
+ else _PARAGRAPH_TEXT.match(flush_left) is not None
+ )
+ # A paragraph inside an open item is that item's, and only one written
+ # at document level can be the heading a later underline makes of it.
+ if after_paragraph and not in_quote and not lists.columns and continues:
+ paragraph = [*paragraph, visible.strip()]
+ else:
+ paragraph = []
+ if match is None:
+ if version is not None:
+ body.append(line)
+ continue
+
+ flush()
+ # An empty heading has no title, so it ends the release above without
+ # indexing one: `_version_from_heading` finds no version and `flush` skips.
+ heading = match.group("title") or ""
+ version = _version_from_heading(heading)
+ body = []
+
+ flush()
+ return entries
+
+
+def find_release_notes(text: str, version: str) -> ChangelogEntry | None:
+ """Return the section for exactly `version`, or None.
+
+ Equality is version-aware (`2026.07.5` matches `2026.7.5`) but never fuzzy:
+ a near-miss returns None so the caller shows no notes, not the wrong ones.
+ """
+ entries = parse_changelog(text)
+ for entry in entries:
+ # An exact heading wins, so `## 1.0` is never shadowed by `## 1.0.0`.
+ if entry.version == version:
+ return entry
+
+ wanted = _parse_version(version)
+ for entry in entries:
+ if wanted is not None:
+ candidate = _parse_version(entry.version)
+ if candidate is not None and candidate == wanted:
+ return entry
+ return None
+
+
+def get_release_notes(version: str, refresh: bool = False) -> dict[str, Any]:
+ """Return release notes for exactly `version` for the update popup.
+
+ `refresh` retries a cached remote failure, so the UI's retry action is not
+ stuck behind the failure TTL once connectivity returns.
+ """
+ version = version.strip()
+ if not is_supported_version_query(version):
+ return _notes_response(version = version, error = "Unsupported version.")
+
+ local = _read_local_changelog()
+ remote = ChangelogSource(text = None, source = None)
+ if os.environ.get(DISABLE_ENV_VAR) != "1":
+ remote = get_remote_changelog(refresh = refresh)
+
+ # Remote first: the offered version is newer than the local copy.
+ for candidate in (remote, local):
+ if not candidate.text:
+ continue
+ entry = find_release_notes(candidate.text, version)
+ if entry is not None:
+ return _notes_response(
+ version = version,
+ markdown = entry.body,
+ heading = entry.heading,
+ source = candidate.source,
+ )
+
+ # Nothing matched: the bundled copy cannot know a version newer than the
+ # install, so report a remote failure and let the UI offer a retry.
+ return _notes_response(version = version, error = remote.error)
+
+
+def get_remote_changelog(refresh: bool = False) -> ChangelogSource:
+ """Fetch CHANGELOG.md from the repo using a small in-process TTL cache."""
+ global _remote_cache, _remote_fetching
+
+ if refresh:
+ # Only a cached failure is dropped, so retries cannot hammer the remote.
+ with _cache_condition:
+ if _remote_cache and _remote_cache.source.text is None:
+ _remote_cache = None
+
+ # A caller waits for an in-flight fetch only as long as it may take, then
+ # answers locally rather than holding a worker behind a stalled upstream.
+ deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS + 1
+ while True:
+ now = time.monotonic()
+ with _cache_condition:
+ if _remote_cache and _remote_cache.expires_at > now:
+ return _remote_cache.source
+ if not _remote_fetching:
+ _remote_fetching = True
+ break
+ if now >= deadline:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes are still loading.",
+ )
+ _cache_condition.wait(timeout = deadline - now)
+
+ try:
+ try:
+ source = _fetch_remote_changelog()
+ except Exception:
+ source = ChangelogSource(
+ text = None,
+ source = None,
+ error = "Could not fetch release notes.",
+ )
+
+ ttl = CHANGELOG_SUCCESS_TTL_SECONDS if source.text else CHANGELOG_FAILURE_TTL_SECONDS
+ with _cache_condition:
+ _remote_cache = _ChangelogCacheEntry(source = source, expires_at = time.monotonic() + ttl)
+ return source
+ finally:
+ # Released here, not on the Exception path: stranding the single-flight
+ # flag on BaseException makes every later caller wait out the deadline.
+ with _cache_condition:
+ _remote_fetching = False
+ _cache_condition.notify_all()
+
+
+def _fetch_remote_changelog() -> ChangelogSource:
+ url = os.environ.get(CHANGELOG_URL_ENV_VAR, "").strip() or CHANGELOG_RAW_URL
+ if not url.startswith(("http://", "https://")):
+ return ChangelogSource(text = None, source = None, error = "Invalid changelog URL.")
+
+ request = urllib.request.Request(
+ url,
+ headers = {
+ "User-Agent": "unsloth-studio-update-check",
+ # Or a compressing proxy hands back bytes we would decode as notes.
+ "Accept-Encoding": "identity",
+ },
+ )
+ deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS
+ try:
+ with urllib.request.urlopen(request, timeout = CHANGELOG_TIMEOUT_SECONDS) as response:
+ chunks: list[bytes] = []
+ received = 0
+ while received <= CHANGELOG_MAX_BYTES:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes took too long to load.",
+ )
+ # The socket timeout is per operation, so re-cap it each read.
+ _limit_read(response, remaining)
+ chunk = response.read1(_CHANGELOG_CHUNK_BYTES)
+ if not chunk:
+ break
+ chunks.append(chunk)
+ received += len(chunk)
+ body = b"".join(chunks)
+ if len(body) > CHANGELOG_MAX_BYTES:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes response was too large.",
+ )
+ return ChangelogSource(text = body.decode("utf-8", errors = "replace"), source = "remote")
+ except TimeoutError:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes took too long to load.",
+ )
+ except OSError:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Could not reach the changelog for release notes.",
+ )
+ except UnicodeError:
+ return ChangelogSource(text = None, source = None, error = "Malformed changelog.")
+
+
+def _limit_read(response: Any, remaining: float) -> None:
+ """Cap the next socket read at the time left in the fetch budget."""
+ sock = getattr(getattr(response, "fp", None), "raw", None)
+ sock = getattr(sock, "_sock", None)
+ if sock is None:
+ return
+ try:
+ sock.settimeout(max(remaining, _CHANGELOG_MIN_READ_SECONDS))
+ except OSError:
+ pass
+
+
+def _read_local_changelog() -> ChangelogSource:
+ """Read the CHANGELOG.md bundled with this install, if there is one."""
+ for path in _local_changelog_candidates():
+ try:
+ if not path.is_file():
+ continue
+ if path.stat().st_size > CHANGELOG_MAX_BYTES:
+ continue
+ return ChangelogSource(
+ text = path.read_text(encoding = "utf-8", errors = "replace"),
+ source = "local",
+ )
+ except OSError:
+ continue
+ return ChangelogSource(text = None, source = None)
+
+
+def _is_source_checkout(root: Path) -> bool:
+ """Whether `root` is this repository rather than an install directory."""
+ try:
+ return any((root / marker).exists() for marker in _CHECKOUT_MARKERS)
+ except OSError:
+ return False
+
+
+def _local_changelog_candidates() -> list[Path]:
+ override = os.environ.get(CHANGELOG_PATH_ENV_VAR, "").strip()
+ candidates: list[Path] = []
+ if override:
+ candidates.append(Path(override).expanduser())
+
+ # changelog.py -> utils -> backend -> studio -> repo root. Repo root first
+ # so a checkout's editable file beats the snapshot packaging writes into
+ # studio/. Installed, those outer levels are site-packages, hence the marker.
+ parents = Path(__file__).resolve().parents
+ for index in (3, 2, 1, 4):
+ if index >= len(parents):
+ continue
+ root = parents[index]
+ if index in _CHECKOUT_ONLY_LEVELS and not _is_source_checkout(root):
+ continue
+ candidates.append(root / CHANGELOG_FILENAME)
+
+ seen: set[Path] = set()
+ unique: list[Path] = []
+ for candidate in candidates:
+ if candidate not in seen:
+ seen.add(candidate)
+ unique.append(candidate)
+ return unique
+
+
+def _opens_fence(marker: str, rest: str) -> bool:
+ """A backtick fence's info string may not contain a backtick."""
+ return marker[0] != "`" or "`" not in rest
+
+
+def _next_fence_state(open_fence: str | None, marker: str, rest: str) -> str | None:
+ """Track the open fence marker.
+
+ A closer must be the same character, at least as long, and carry nothing
+ after it. So neither a ``` sample nor a ```` line with trailing text ends
+ a ```` block early, while an opening fence may still have an info string.
+ Only spaces and tabs count as nothing: other Unicode whitespace is content.
+ """
+ if open_fence is None:
+ return marker if _opens_fence(marker, rest) else None
+ closes = marker[0] == open_fence[0] and len(marker) >= len(open_fence)
+ if closes and not rest.strip(" \t"):
+ return None
+ return open_fence
+
+
+def _code_span_ranges(line: str) -> list[tuple[int, int]]:
+ """Code span bounds. A run of backticks closes only on a run of its length."""
+ # Collect the runs once: rescanning per opener is quadratic on a line of
+ # distinct unmatched runs, and notes are reparsed on every request.
+ runs: list[tuple[int, int]] = []
+ index = 0
+ while index < len(line):
+ if line[index] != "`" or _is_escaped(line, index):
+ index += 1
+ continue
+ ticks = _run_length(line, index)
+ runs.append((index, ticks))
+ index += ticks
+
+ # A run closes only on a later run of its length, so one cursor per length.
+ by_length: dict[int, list[int]] = {}
+ for position, (_, ticks) in enumerate(runs):
+ by_length.setdefault(ticks, []).append(position)
+
+ spans: list[tuple[int, int]] = []
+ cursors: dict[int, int] = {}
+ current = 0
+ while current < len(runs):
+ start, ticks = runs[current]
+ same = by_length[ticks]
+ cursor = cursors.get(ticks, 0)
+ while cursor < len(same) and same[cursor] <= current:
+ cursor += 1
+ cursors[ticks] = cursor
+ if cursor >= len(same):
+ # Nothing closes this run, so it is literal text.
+ current += 1
+ continue
+ closer = same[cursor]
+ cursors[ticks] = cursor + 1
+ spans.append((start, runs[closer][0] + ticks))
+ current = closer + 1
+ return spans
+
+
+def _run_length(line: str, index: int) -> int:
+ end = index
+ while end < len(line) and line[end] == "`":
+ end += 1
+ return end - index
+
+
+def _is_escaped(line: str, index: int) -> bool:
+ slashes = 0
+ while index - 1 - slashes >= 0 and line[index - 1 - slashes] == "\\":
+ slashes += 1
+ return slashes % 2 == 1
+
+
+def _strip_comments(line: str, in_comment: bool, block_open: bool) -> tuple[str, bool]:
+ """Return the line with HTML-comment spans removed, and the trailing state.
+
+ Only a comment that starts a line opens a block and hides the lines below
+ it. One written mid-sentence is inline HTML: it hides the rest of its own
+ line at most, so a note mentioning `` and `` are complete comments, so the closer may overlap
+ # the opener; searching past it would swallow every later release.
+ return ("", _COMMENT_CLOSE not in line)
+
+ visible: list[str] = []
+ index = 0
+ spans = _code_span_ranges(line)
+ # Spans are ordered and disjoint and each opener sits at or past the one
+ # before, so the search resumes rather than restarts: restarting per opener is
+ # quadratic, and a long line of code spans is reparsed on every request.
+ cursor = 0
+ while index < len(line):
+ opening = line.find(_COMMENT_OPEN, index)
+ if opening == -1:
+ visible.append(line[index:])
+ break
+
+ while cursor < len(spans) and spans[cursor][1] <= opening:
+ cursor += 1
+ if cursor < len(spans) and spans[cursor][0] <= opening:
+ visible.append(line[index : spans[cursor][1]])
+ index = spans[cursor][1]
+ continue
+
+ visible.append(line[index:opening])
+ close = line.find(_COMMENT_CLOSE, opening + len(_COMMENT_OPEN))
+ if close == -1:
+ # Unterminated inline comment: it hides this line and no more.
+ break
+ index = close + len(_COMMENT_CLOSE)
+ return "".join(visible), False
+
+
+def _hidden_structure(line: str, marker: str = "") -> str:
+ """`line` as list tracking sees it once the renderer hides its text.
+
+ A comment or a raw HTML block renders nothing, but it is still a block
+ written at its own column, so it closes the items it sits to the left of.
+ Only the indentation survives: what is inside the block is not Markdown and
+ must not open a list of its own. `marker` is the part of the line that opens
+ a list item the block is the content of, which survives with it."""
+ if marker:
+ return marker + _HIDDEN_BLOCK
+ if not line.strip():
+ return ""
+ return line[: len(line) - len(line.lstrip(" \t"))] + _HIDDEN_BLOCK
+
+
+def _indent_width(line: str) -> int:
+ """Columns of leading whitespace, counting a tab to the next stop of four."""
+ width = 0
+ for char in line:
+ if char == " ":
+ width += 1
+ elif char == "\t":
+ width += 4 - width % 4
+ else:
+ break
+ return width
+
+
+def _strip_indent(line: str, columns: int) -> str:
+ """`line` with up to `columns` columns of leading whitespace removed."""
+ width = 0
+ index = 0
+ while index < len(line) and width < columns and line[index] in " \t":
+ width += 1 if line[index] == " " else 4 - width % 4
+ index += 1
+ return line[index:]
+
+
+def _interrupts_paragraph(line: str) -> bool:
+ """Whether `line` starts a block that can break into an open paragraph.
+
+ A quote marker always can. A list item can only when it has content, and an
+ ordered one only when it starts at 1: anything else is text of the
+ paragraph it appears to interrupt."""
+ if _BLOCK_QUOTE.match(line):
+ return True
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ if item is None:
+ return False
+ marker = item.group("marker")
+ if not line[item.end() :].strip():
+ return False
+ return marker[-1] not in ".)" or marker[:-1] == "1"
+
+
+def _item_content(line: str, after_paragraph: bool) -> str:
+ """`line` read from the content column of a list item that opens on it.
+
+ A block written as an item's first content sits inside that item, so
+ ``- ```` opens a fence even though its marker is not within three columns of
+ the container. The padding is capped the way `_open_lists` caps it, or
+ ``- ```` would read as a fence rather than the indented code it is. A
+ marker the paragraph above swallows opens no item, so its line is returned
+ whole, as is one four columns past its container. Ported to the frontend as
+ `itemContent` in markdown-list-columns.ts."""
+ if _indent_width(line) >= 4 or (after_paragraph and not _interrupts_paragraph(line)):
+ return line
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ if item is None:
+ return line
+ padding = _indent_width(item.group("space"))
+ # Over-indented content starts one column past the marker; the rest of the
+ # padding is the content's own indentation.
+ over = padding - 1 if padding > _MAX_ITEM_PADDING else 0
+ return " " * over + line[item.end() :]
+
+
+def _quote_content(line: str) -> str:
+ """What a blockquote line holds, with its markers stripped."""
+ while (marker := _QUOTE_MARKER.match(line)) is not None:
+ line = line[marker.end() :]
+ return line
+
+
+def _may_be_lazy(line: str) -> bool:
+ """Whether `line` can continue a paragraph it is indented out of.
+
+ Only plain text can: a heading, a fence, a break or an HTML block starts a
+ block of its own, which closes the item instead. An underline is not one of
+ them: it may never be lazy, so `===` written left of an open item is read as
+ more of the item's paragraph. Nor is a definition, which is a block of its
+ own but may not interrupt a paragraph. A row of dashes still closes the
+ item, as `_INTERRUPTS` reads three or more as the thematic break they are."""
+ return (
+ _PARAGRAPH_TEXT.match(line) is not None
+ and _INTERRUPTS.match(line) is None
+ and _FENCE_PATTERN.match(line) is None
+ # Types 1 to 6 interrupt a paragraph, so a `
` left of an open item
+ # closes it. Type 7 cannot, and is deliberately excluded.
+ and not _opens_html_block(line, True)
+ )
+
+
+def _continues_paragraph(line: str, column: int) -> bool:
+ """Whether `line` reads as more of a paragraph open in its container.
+
+ Measured from `column`, where that container's content starts: four columns
+ past it the line is an indented code block, which may not interrupt a
+ paragraph, so indentation alone never closes the one above it."""
+ inner = _strip_indent(line, column)
+ return _indent_width(inner) >= 4 or _may_be_lazy(inner)
+
+
+def _close_dedented(
+ columns: tuple[int, ...], line: str, indent: int, after_paragraph: bool
+) -> tuple[int, ...]:
+ """`columns` with every item `line` is written to the left of closed.
+
+ Read inside the container the item sits in, not from the margin: a line that
+ only looks indented there is lazy text of the item's paragraph, which leaves
+ the item open rather than closing it."""
+ while columns and indent < columns[-1]:
+ outer = columns[-2] if len(columns) > 1 else 0
+ if after_paragraph and _continues_paragraph(line, outer):
+ break
+ columns = columns[:-1]
+ return columns
+
+
+def _lazy_marker(line: str, state: _ListState, after_paragraph: bool, quoted: bool) -> bool:
+ """Whether a marker-shaped `line` is really text of the paragraph above it.
+
+ Only a marker inside the paragraph's own item interrupts it; one to the left
+ closes that item and opens a sibling. A quote owns the paragraph its lines
+ hold, so a marker written outside the quote opens a list of its own."""
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ columns = state.columns
+ return (
+ item is not None
+ and after_paragraph
+ and not quoted
+ and (not columns or _indent_width(line) >= columns[-1])
+ and not _interrupts_paragraph(line)
+ )
+
+
+def _open_lists(
+ line: str,
+ state: _ListState,
+ after_paragraph: bool,
+ quoted: bool = False,
+) -> _ListState:
+ """The list items still open after `line`.
+
+ A dedented line closes an item, unless it is a lazy paragraph continuation.
+ A new marker nests under a deeper column and replaces a sibling. `quoted`
+ marks a paragraph the blockquote above owns: a marker written outside the
+ quote is not text of it, so it opens a list of its own.
+ """
+ columns = state.columns
+ if not line.strip():
+ # A blank line leaves the list open, unless the item is still empty: an
+ # item may begin with one blank line, and later content is outside it.
+ return _ListState(columns[:-1] if state.empty_item else columns)
+ indent = _indent_width(line)
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ empty = item is not None and not line[item.end() :].strip()
+ if _lazy_marker(line, state, after_paragraph, quoted):
+ # A lazy continuation or an underline, so the open items are untouched.
+ return state
+ columns = _close_dedented(columns, line, indent, after_paragraph)
+ # Four columns past its container the marker is an indented code block, or
+ # lazy text of the paragraph above it, so it opens no list of its own.
+ if item is None or indent - (columns[-1] if columns else 0) >= 4:
+ return _ListState(columns)
+ marker = item.group("marker")
+ padding = _indent_width(item.group("space"))
+ if padding == 0 or padding > _MAX_ITEM_PADDING:
+ # An empty or over-indented item still holds one column of content.
+ padding = 1
+ while columns and columns[-1] > indent:
+ columns = columns[:-1]
+ return _ListState((*columns, indent + len(marker) + padding), empty_item = empty)
+
+
+def _opens_html_block(line: str, after_paragraph: bool) -> bool:
+ """True if `line` starts a CommonMark type 6 or type 7 HTML block."""
+ match = _HTML_BLOCK_OPEN.match(line)
+ if match is not None and match.group(1).lower() in _HTML_BLOCK_TAGS:
+ return True
+ return not after_paragraph and _HTML_TAG_ONLY_LINE.match(line) is not None
+
+
+def _strip_raw_html(line: str, open_block: int | None) -> tuple[str, int | None]:
+ """Drop the parts of a line inside a raw block, and return the open block.
+
+ The state is the index of the open block in `_RAW_BLOCKS`, or None."""
+ if open_block is not None:
+ close = _RAW_BLOCKS[open_block][1].search(line)
+ return ("", None) if close else ("", open_block)
+
+ # A block only opens at the start of a line; mid-line tags are inline HTML.
+ for index, (opener, closer) in enumerate(_RAW_BLOCKS):
+ opening = opener.match(line)
+ if opening is None:
+ continue
+ rest = line[opening.end() :]
+ close = closer.search(rest)
+ return ("", None) if close else ("", index)
+ return line, None
+
+
+def _version_from_heading(heading: str) -> str | None:
+ token = heading.split()[0] if heading.split() else ""
+ match = _VERSION_TOKEN_PATTERN.match(token)
+ if match is None:
+ return None
+ version = match.group("version")
+ return version if _parse_version(version) is not None else None
+
+
+def _parse_version(version: str) -> Version | None:
+ try:
+ return Version(version)
+ except InvalidVersion:
+ return None
+
+
+def _close_open_fence(markdown: str) -> str:
+ """Close a fence the truncation cut in half, so the rest still renders."""
+ open_fence: str | None = None
+ for line in _markdown_lines(markdown):
+ fence = _FENCE_PATTERN.match(line)
+ if fence:
+ open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
+ return f"{markdown}\n{open_fence}" if open_fence else markdown
+
+
+def _renders_visibly(markdown: str) -> bool:
+ """Whether a section body renders anything at all."""
+ in_comment = False
+ for line in _markdown_lines(markdown):
+ opens_raw = any(opener.match(line) for opener, _ in _RAW_BLOCKS)
+ if not in_comment and (_FENCE_PATTERN.match(line) or opens_raw):
+ # A code block or raw HTML block renders even when it is empty.
+ return True
+ # No containers are tracked here, so the opener is read at the margin. The
+ # answer does not turn on it: an item renders its marker whatever the block
+ # inside hides, so a commented-out item renders something either way.
+ visible, in_comment = _strip_comments(
+ line, in_comment, _COMMENT_BLOCK_OPEN.match(line) is not None
+ )
+ if visible.strip():
+ return True
+ return False
+
+
+def _notes_response(
+ *,
+ version: str,
+ markdown: str | None = None,
+ heading: str | None = None,
+ source: str | None = None,
+ error: str | None = None,
+) -> dict[str, Any]:
+ # A section that renders as nothing counts as unpublished, not as empty.
+ if markdown and not _renders_visibly(markdown):
+ markdown = None
+ source = None
+
+ truncated = False
+ if markdown and len(markdown) > RELEASE_NOTES_MAX_CHARS:
+ markdown = _close_open_fence(markdown[:RELEASE_NOTES_MAX_CHARS].rstrip())
+ truncated = True
+
+ return {
+ "version": version,
+ "markdown": markdown or None,
+ "heading": heading,
+ # False means no notes for this exact version; the UI links out.
+ "matched": bool(markdown),
+ "truncated": truncated,
+ "source": source,
+ "release_notes_url": RELEASE_NOTES_URL,
+ "error": error,
+ }
diff --git a/studio/backend/utils/update_status.py b/studio/backend/utils/update_status.py
index ad9dabcf36..d4b8ca1c16 100644
--- a/studio/backend/utils/update_status.py
+++ b/studio/backend/utils/update_status.py
@@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
+FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@@ -107,11 +108,32 @@ def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
)
+def _is_version(value: str) -> bool:
+ try:
+ Version(value)
+ except InvalidVersion:
+ return False
+ return True
+
+
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
+ disabled = os.environ.get(DISABLE_ENV_VAR) == "1"
- if os.environ.get(DISABLE_ENV_VAR) == "1":
+ # Dev-only: the popup is PyPI-install-only, so fake a version to review it
+ # from a checkout. The documented opt-out still wins.
+ forced_version = os.environ.get(FAKE_UPDATE_ENV_VAR, "").strip()
+ if forced_version and not disabled and _is_version(forced_version):
+ return _status_response(
+ current_version = current_version,
+ latest_version = forced_version,
+ install_source = "pypi",
+ update_available = True,
+ can_show_web_notification = True,
+ )
+
+ if disabled:
return _status_response(
current_version = current_version,
latest_version = None,
diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx
index d746ed952c..b076c8cf8d 100644
--- a/studio/frontend/src/app/provider.tsx
+++ b/studio/frontend/src/app/provider.tsx
@@ -214,7 +214,8 @@ function TauriUpdateLayer({
}
return (
-
+ // Capped like the browser stack: the download panel shares it, so both must fit.
+
{children}
- {/* One bottom-right stack so overlays never overlap; they stack with a
- gap, download panel anchored at the corner with banners above. */}
-
+ {/* One bottom-right stack so overlays never overlap: download panel at the
+ corner, banners above, each owning its width. */}
+ {/* Capped to the viewport, or a long download list plus expanded notes
+ pushes the top of the stack off screen. */}
+
diff --git a/studio/frontend/src/components/tauri/update-banner.tsx b/studio/frontend/src/components/tauri/update-banner.tsx
index 6f5e655889..49c9c44aaa 100644
--- a/studio/frontend/src/components/tauri/update-banner.tsx
+++ b/studio/frontend/src/components/tauri/update-banner.tsx
@@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
+import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
import type {
DesktopUpdatePolicyMode,
RetainedUpdateFailure,
@@ -22,6 +23,8 @@ interface UpdateBannerProps {
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
+ // Release page for this version, preferred over the generic changelog.
+ releasePageUrl?: string | null;
// false fills a shared overlay stack; true self-anchors.
positioned?: boolean;
onInstall: () => void;
@@ -30,6 +33,7 @@ interface UpdateBannerProps {
}
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
+const LEADING_V = /^v/;
function formatVersion(version: string | null | undefined): string {
if (!version) return "";
@@ -44,6 +48,7 @@ export function UpdateBanner({
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
+ releasePageUrl = null,
positioned = true,
onInstall,
onDismiss,
@@ -52,6 +57,8 @@ export function UpdateBanner({
const [copying, setCopying] = useState(false);
const [manualReport, setManualReport] = useState(null);
const [manualMessage, setManualMessage] = useState(null);
+ // Version whose notes are expanded; a new offer collapses the panel.
+ const [notesVersion, setNotesVersion] = useState(null);
const showFailure = Boolean(lastFailure) && !dismissed;
const showAvailable = status === "available" && !dismissed && !showFailure;
const show = showFailure || (showAvailable && Boolean(info));
@@ -62,6 +69,11 @@ export function UpdateBanner({
const currentVersion = formatVersion(info?.currentVersion);
const latestVersion = formatVersion(info?.version);
const Icon = showFailure ? CircleAlert : Download;
+ // Keyed by the backend release, not the app's SemVer; headings drop the v.
+ const notesTargetVersion =
+ (info?.pypiVersion ?? info?.version)?.replace(LEADING_V, "") ?? null;
+ const notesOpen =
+ notesTargetVersion !== null && notesVersion === notesTargetVersion;
async function handleCopyDiagnostics() {
setCopying(true);
@@ -94,13 +106,14 @@ export function UpdateBanner({
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className={cn(
+ // Wider than the other overlays: notes preview plus three buttons.
positioned
- ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
- : "pointer-events-auto w-full",
+ ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
+ : "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
)}
data-testid="tauri-update-banner"
>
-