Merge branch 'main' into feature/deep-research

Resolve the composer pill-collapse conflict in assistant-ui/thread.tsx:
keep main's mobile-aware collapse (isMobile plus pillCount) and fold the
Deep Research pill into that count, while preserving the Deep Research
composer state block. Define activeThreadId once.
This commit is contained in:
danielhanchen 2026-07-22 06:14:21 +00:00
commit db9336fab8
21 changed files with 1373 additions and 242 deletions

View file

@ -1844,7 +1844,7 @@ exit 0
$nameArchTable = @(
@{ P = "9070 XT|9080"; A = "gfx1201" } # RDNA 4 (RX 9070 XT / 9080)
@{ P = "9070|9060"; A = "gfx1200" } # RDNA 4 (RX 9070 / 9060)
@{ P = "8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
@{ P = "8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
@{ P = "890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1150" } # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
@{ P = "RX 7900|RX 7800|RX 7700(?!S)|PRO W7900|PRO W7800|PRO W7700"; A = "gfx1100" } # RDNA 3 desktop/workstation (Navi 31)
@{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500|PRO V710"; A = "gfx1102" } # RDNA 3 (Navi 33)
@ -2030,16 +2030,18 @@ exit 0
# _strip_index_url_credentials (install.sh / py / setup.ps1).
function Remove-IndexUrlCredentials {
param([string]$Url)
$sep = $Url.IndexOf('://')
# Ordinal, not culture-aware: on non-English locales (e.g. th-TH) linguistic
# IndexOf treats "://" as ignorable, mis-locates it, and crashes Substring (issue #7279).
$sep = $Url.IndexOf('://', [System.StringComparison]::Ordinal)
if ($sep -lt 0) { return $Url }
$scheme = $Url.Substring(0, $sep)
$rest = $Url.Substring($sep + 3)
# Drop query / fragment (may hold auth tokens).
$q = $rest.IndexOfAny([char[]]('?', '#'))
if ($q -ge 0) { $rest = $rest.Substring(0, $q) }
$slash = $rest.IndexOf('/')
$slash = $rest.IndexOf('/', [System.StringComparison]::Ordinal)
$authority = if ($slash -ge 0) { $rest.Substring(0, $slash) } else { $rest }
$at = $authority.LastIndexOf('@')
$at = $authority.LastIndexOf('@', [System.StringComparison]::Ordinal)
$host_ = if ($at -ge 0) { $authority.Substring($at + 1) } else { $authority }
if ($slash -ge 0) { return "${scheme}://${host_}$($rest.Substring($slash))" }
return "${scheme}://${host_}"

View file

@ -1636,7 +1636,7 @@ _maybe_reroute_strixhalo_to_2404() {
# CUDA_VISIBLE_DEVICES=""/-1 and the /proc/driver/nvidia fallback for PATH/timeout gaps.
if _has_usable_nvidia_gpu; then return 0; fi
# Strix APUs show in /proc/cpuinfo; discrete cards don't, so also try WMI. Either reroutes.
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
@ -2127,6 +2127,23 @@ _has_amd_rocm_gpu() {
return 1
}
# Returns 0 if an AMD display GPU is on the PCI bus even when ROCm can't use it
# (e.g. a Strix Halo iGPU with no /dev/kfd). Only sharpens the "no GPU detected"
# hint. vendor 0x1002 = AMD/ATI; class 0x03* = display controller.
_amd_gpu_present_via_pci() {
[ -d /sys/bus/pci/devices ] || return 1
for _pci_vendor in /sys/bus/pci/devices/*/vendor; do
[ -r "$_pci_vendor" ] || continue
read -r _v < "$_pci_vendor" 2>/dev/null || continue
[ "$_v" = "0x1002" ] || continue
_cls="${_pci_vendor%vendor}class"
[ -r "$_cls" ] || continue
read -r _c < "$_cls" 2>/dev/null || continue
case "$_c" in 0x03*) return 0 ;; esac
done
return 1
}
# ── Detect GPU and choose PyTorch index URL ──
# Mirrors Get-TorchIndexUrl in install.ps1.
# On CPU-only machines this returns the cpu index, avoiding the solver
@ -2649,7 +2666,7 @@ _maybe_bootstrap_rocm_wsl() {
[ -e /dev/dxg ] || return 0
# Strix APUs show in /proc/cpuinfo (the CPU model); discrete cards don't, so also
# ask the Windows host. Either signal suffices; the bootstrap detects arch from rocminfo.
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
@ -2818,29 +2835,45 @@ case "$TORCH_INDEX_URL" in
fi
;;
esac
# ── Strix Halo / Strix Point: force rocm7.2 wheels, bypass Radeon repo ───────
# gfx1151 (Strix Halo) and gfx1150 (Strix Point) have a ROCm 7.1 driver bug
# that causes a segfault in torch._grouped_mm (moe_utils.py line 167).
# The Radeon repo now ships cp313 wheels for rocm-rel-7.1, so when
# _amd_gpu_radeon=true the installer silently lands on the broken combo.
# Detect these GPUs when TORCH_INDEX_URL is rocm7.1 and override to rocm7.2.
case "$TORCH_INDEX_URL" in
*/rocm7.1|*/rocm7.1.*)
# 0 when a rocmX.Y index leaf ($1, the final path segment) is older than floor
# $2.$3 (int compare, so rocm7.2 < rocm7.13). Non-rocm leaves (gfx*, cu*, cpu) and
# non-numeric versions return 1. Leaf-based (like $_torch_index_leaf) so a mirror
# base holding its own rocm token compares the family leaf, not the base path.
_rocm_leaf_below() {
case "$1" in rocm[0-9]*.[0-9]*) : ;; *) return 1 ;; esac
_rb=${1#rocm}; _maj=${_rb%%.*}; _min=${_rb#*.}; _min=${_min%%.*}
case "$_maj$_min" in *[!0-9]*) return 1 ;; esac
if [ "$_maj" -lt "$2" ]; then return 0; fi
if [ "$_maj" -eq "$2" ] && [ "$_min" -lt "$3" ]; then return 0; fi
return 1
}
# ── Strix Halo / Strix Point: route to the AMD arch-specific index ───────────
# gfx1151/gfx1150 need torch 2.11+rocm7.13 from repo.amd.com/rocm/whl/gfx<arch>/,
# which carries AMD's real fixes (the rocm7.1 _grouped_mm segfault, moe_utils.py:167,
# and later Strix kernel bugs). Every generic pytorch.org index below rocm7.13 lacks
# them (and the Radeon repo can be offline, unslothai#7264), so reroute a detected
# Strix GPU whenever the picked index is older than the arch build -- covers today's
# rocm6.0-7.2 and any future 7.x < 7.13; rocm7.13+ already has the fixes, so leave it.
case "$_torch_index_leaf" in
rocm[0-9]*)
# Collect every gfx token in rocminfo / amd-smi enumeration order
# (skip duplicates), then index by HIP_VISIBLE_DEVICES /
# ROCR_VISIBLE_DEVICES so a mixed Strix iGPU + non-Strix dGPU box
# where the user selected the dGPU does NOT get rerouted to the
# Strix per-gfx index.
# || true on each probe: no gfx match makes grep exit 1, which under
# set -euo pipefail would abort the installer before the next fallback
# runs (now that the case matches every rocm* index, not just rocm7.1).
_gfx_all=""
if command -v rocminfo >/dev/null 2>&1; then
_gfx_all=$(rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}')
_gfx_all=$(rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
fi
if [ -z "$_gfx_all" ] && command -v amd-smi >/dev/null 2>&1; then
_gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}')
_gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
# PowerShell paths also probe `amd-smi static --asic`; mirror it
# so a host with hipinfo-less amd-smi reports the gfx target.
if [ -z "$_gfx_all" ]; then
_gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}')
_gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
fi
fi
_runtime_gfx=""
@ -2865,13 +2898,14 @@ case "$TORCH_INDEX_URL" in
case "$_runtime_gfx" in
gfx1151|gfx1150) _strix_gfx="$_runtime_gfx" ;;
esac
if [ -n "$_strix_gfx" ]; then
# Skip rocm7.13+ generic indexes: they already ship the fixes, so the
# arch build (rocm7.13) would be a downgrade rather than a rescue.
if [ -n "$_strix_gfx" ] && _rocm_leaf_below "$_torch_index_leaf" 7 13; then
echo "" >&2
echo " [WARN] $_strix_gfx (Strix) + ROCm 7.1 detected -- known _grouped_mm segfault" >&2
echo " [WARN] ROCm 7.1 wheels are broken for gfx1150/gfx1151 (moe_utils.py:167)" >&2
echo " [WARN] Routing to AMD arch-specific index (torch 2.11+rocm7.13 has the real fix)" >&2
echo " [WARN] Upgrade ROCm to 7.2+ to use the standard index:" >&2
echo " [WARN] https://rocm.docs.amd.com/en/latest/deploy/linux/index.html" >&2
echo " [WARN] $_strix_gfx (Strix) detected -- routing to the AMD arch-specific index" >&2
echo " [WARN] torch 2.11+rocm7.13 has AMD's real gfx1150/gfx1151 fixes (the ROCm 7.1" >&2
echo " [WARN] _grouped_mm segfault, moe_utils.py:167, and later Strix kernel bugs)," >&2
echo " [WARN] and is more reliable than the rocm7.2 index or an offline Radeon repo." >&2
echo "" >&2
# AMD's arch-specific index serves torch 2.11.0+rocm7.13.0 which has AMD's
# actual fix for the gfx1151/gfx1150 _grouped_mm kernel bug -- preferred
@ -2960,7 +2994,7 @@ elif case "$TORCH_INDEX_URL" in */rocm*|*/gfx*) true ;; *) false ;; esac; then
case "$_gpu_disp_mkt" in
*"9070 XT"*|*9080*) _gpu_disp_gfx="gfx1201" ;; # RDNA 4
*9070*|*9060*) _gpu_disp_gfx="gfx1200" ;; # RDNA 4
*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _gpu_disp_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
*"8065S"*|*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _gpu_disp_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
*"890M"*|*"880M"*|*"860M"*|*"840M"*|*"Strix Point"*|*"Krackan"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) _gpu_disp_gfx="gfx1150" ;; # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
*"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*|*"PRO V710"*) _gpu_disp_gfx="gfx1102" ;; # RDNA 3 (Navi 33)
*"RX 7900"*|*"RX 7800"*|*"RX 7700"*|*"PRO W7900"*|*"PRO W7800"*|*"PRO W7700"*) _gpu_disp_gfx="gfx1100" ;; # RDNA 3 desktop / workstation (Navi 31)
@ -3031,6 +3065,13 @@ case "$TORCH_INDEX_URL" in
substep " driver is current; or run unsloth/scripts/install_rocm_wsl_strixhalo.sh yourself."
else
substep "AMD ROCm users: see https://docs.unsloth.ai/get-started/install-and-update/amd"
# Only when ROCm truly can't see the GPU: a detected-but-too-old
# ROCm (rocminfo works, wheels need 6.0+) has its own guidance.
if ! _has_amd_rocm_gpu && _amd_gpu_present_via_pci; then
substep "An AMD GPU is on the PCI bus but ROCm cannot see it (no /dev/kfd," "$C_WARN"
substep " rocminfo, or amd-smi). Install the ROCm kernel stack so /dev/kfd exists;"
substep " Strix Halo (gfx1151/gfx1150) needs a recent kernel (6.11+) and ROCm 7.x."
fi
fi
substep "Re-run with --no-torch for GGUF-only (faster, no PyTorch):"
substep " curl -fsSL https://unsloth.ai/install.sh | sh -s -- --no-torch"

View file

@ -247,6 +247,59 @@ def _wsl_system_rocm_lib_dirs() -> "list[str]":
return out
def _bundled_hip_present(binary_dir: str) -> bool:
"""True when a prebuilt bundle ships its own HIP backend library."""
if not binary_dir:
return False
try:
# Glob the version suffix (libggml-hip.so, .so.0, .so.0.11.1) the same
# way the installer's runtime health check matches libggml-hip.so*.
return any(Path(str(binary_dir)).glob("libggml-hip.so*"))
except OSError:
return False
def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> "list[str]":
"""System ROCm lib dir(s) to prepend before a prebuilt's bundled HIP, on native Linux.
The bundled bare-metal HIP runtime can mismatch the host amdkfd driver and crash
in hsa_init(); prepending the whole system ROCm lib dir loads a driver-matched,
version-consistent stack (libhsa-runtime64 / libamdhip64 / librocblas) ahead of it.
The whole dir is deliberate: mixing the bundle's rocBLAS with a different-version
system HIP/ROCR risks missing symbols. UNSLOTH_LLAMA_NO_SYSTEM_ROCM=1 keeps the pure
bundle (for a host whose system ROCm lacks this arch); no-op on WSL / non-Linux.
"""
if os.environ.get("UNSLOTH_LLAMA_NO_SYSTEM_ROCM") == "1":
return []
if sys.platform != "linux" or os.path.exists("/dev/dxg"):
return []
if not os.path.exists("/dev/kfd"):
return []
if not _bundled_hip_present(binary_dir):
return []
# Env-configured ROCm root first; /opt/rocm only as a fallback so a stale
# /opt/rocm doesn't shadow the driver-matching install these vars point at.
candidates = []
for var in ("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"):
val = os.environ.get(var)
if val:
candidates.append(val)
candidates.append("/opt/rocm")
out: "list[str]" = []
seen: "set[str]" = set()
for base in candidates:
for lib_sub in ("lib", "lib64"):
d = os.path.join(base, lib_sub)
if d in seen:
continue
seen.add(d)
if os.path.exists(os.path.join(d, "libhsa-runtime64.so")) or os.path.exists(
os.path.join(d, "libhsa-runtime64.so.1")
):
out.append(d)
return out
# Plan-without-action re-prompt state now lives in tool_call_parser (imported above).
# Default max_tokens to the effective context when known. The floor is high
@ -3592,6 +3645,9 @@ class LlamaCppBackend:
lib_dirs.extend(_wsl_system_rocm_lib_dirs())
if lib_dirs:
env.setdefault("HSA_ENABLE_DXG_DETECTION", "1")
# Native Linux AMD: system ROCm libs before the bundle's HIP runtime,
# which can be incompatible with the host amdkfd driver.
lib_dirs.extend(_native_linux_system_rocm_lib_dirs(binary_dir))
lib_dirs.append(binary_dir)
_arch = platform.machine() # x86_64, aarch64, etc.

View file

@ -90,6 +90,79 @@ _FAST_PATH_HOOKS_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS"
# run_training_process() and isn't GC'd mid-run.
_WINDOWS_ROCM_GROUPED_MM_LIB = None
def _install_grouped_mm_cpu_fallback(torch_mod, logger, label):
"""Register a Python mm/bmm fallback for torch._grouped_mm and return the Library.
RDNA4 (gfx1200/gfx1201) ships a null HIP _grouped_mm kernel on ROCm <= 7.12
(fixed in 7.13; ROCm/TheRock #5284). JitDecomp dispatches _grouped_mm to the
null kernel and crashes; overriding the CUDA dispatch key bypasses it. Shared
by the Windows and Linux ROCm guards. Keep the returned Library referenced so
the registration outlives the caller.
"""
import warnings as _warnings
_gm_lib = torch_mod.library.Library("aten", "IMPL")
def _grouped_mm_safe_impl(
self,
mat2,
offs = None,
bias = None,
out_dtype = None,
):
"""Python mm/bmm fallback for _grouped_mm on gfx120X (null HIP kernel, ROCm <= 7.12)."""
_t = torch_mod
if offs is None:
# No offsets: 2-D -> mm, 3-D batched -> bmm (unconditional mm broke 3-D MoE).
if self.dim() == 3 and mat2.dim() == 3:
result = _t.bmm(self.contiguous(), mat2.contiguous())
elif self.dim() == 3 and mat2.dim() == 2:
result = _t.matmul(self.contiguous(), mat2.contiguous())
elif self.dim() == 2 and mat2.dim() == 3:
result = _t.matmul(self.contiguous(), mat2.contiguous())
else:
result = _t.mm(self.contiguous(), mat2.contiguous())
else:
# Grouped: offs[i] is the exclusive end-row of group i.
offs_list = offs.tolist()
pieces = []
prev = 0
for idx, end in enumerate(offs_list):
end = int(end)
a_part = self[prev:end].contiguous()
b_part = mat2[idx].contiguous() if mat2.dim() == 3 else mat2.contiguous()
pieces.append(_t.mm(a_part, b_part))
prev = end
# Include trailing rows not covered by offs.
if prev < self.shape[0]:
a_tail = self[prev:].contiguous()
b_tail = mat2[-1].contiguous() if mat2.dim() == 3 else mat2.contiguous()
pieces.append(_t.mm(a_tail, b_tail))
result = (
_t.cat(pieces, dim = 0)
if pieces
else _t.zeros(0, mat2.shape[-1], device = self.device, dtype = self.dtype)
)
if bias is not None:
result = result + bias
if out_dtype is not None:
result = result.to(out_dtype)
elif result.dtype != self.dtype:
result = result.to(self.dtype)
return result
with _warnings.catch_warnings():
_warnings.simplefilter("ignore")
_gm_lib.impl("_grouped_mm", _grouped_mm_safe_impl, "CUDA")
logger.info(
"%s: patched _grouped_mm CUDA dispatch (null HIP kernel on gfx120X, "
"ROCm <= 7.12 -- bypassed with Python mm fallback)",
label,
)
return _gm_lib
# Subprocesses don't inherit os.add_dll_directory registrations. Replicate
# main.py's Windows ROCm DLL setup so the first `import torch` finds
# amdhip64.dll. Handles retained at module scope so they aren't GC'd.
@ -702,8 +775,9 @@ def _rocm_classify_unified_memory(props: Any) -> tuple[str, bool]:
3. Device-name substring match (last resort when all arch attrs absent;
AMD SDK / Radeon wheels may not populate them):
- gfx1150 Strix Point: ``Radeon 890M``, ``Radeon 880M``
- gfx1151 Strix Halo: ``Radeon 8060S`` (Ryzen AI MAX+ 395),
``Radeon 8050S`` (cut-down SKU)
- gfx1151 Strix Halo / Gorgon Halo: ``Radeon 8065S`` (Ryzen AI
Max+ 495), ``Radeon 8060S`` (Ryzen AI MAX+
395), ``Radeon 8050S`` (cut-down SKU)
"""
gcn_arch = ""
for _attr in ("gcnArchName", "gcn_arch_name", "arch_name", "gfx_arch_name"):
@ -728,7 +802,11 @@ def _rocm_classify_unified_memory(props: Any) -> tuple[str, bool]:
# Arch attrs absent — fall back to device-name matching.
dev_lower = (getattr(props, "name", "") or "").lower()
is_unified = (
"890m" in dev_lower or "880m" in dev_lower or "8060s" in dev_lower or "8050s" in dev_lower
"890m" in dev_lower
or "880m" in dev_lower
or "8065s" in dev_lower
or "8060s" in dev_lower
or "8050s" in dev_lower
)
return gcn_arch, is_unified
@ -2689,80 +2767,8 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
# so 7.13+ uses the real GPU kernel.
if not _hip_ver_at_least(7, 13):
try:
import warnings as _warnings
_gm_lib = _torch_for_rocm.library.Library("aten", "IMPL")
def _grouped_mm_safe_impl(
self,
mat2,
offs = None,
bias = None,
out_dtype = None,
):
"""Python mm/bmm fallback for _grouped_mm on gfx1200 (null HIP kernel, ROCm ≤ 7.12)."""
_t = _torch_for_rocm
if offs is None:
# No offsets: 2-D -> mm, 3-D batched -> bmm
# (unconditional mm broke 3-D MoE).
if self.dim() == 3 and mat2.dim() == 3:
result = _t.bmm(self.contiguous(), mat2.contiguous())
elif self.dim() == 3 and mat2.dim() == 2:
# Broadcast 2-D mat2 across the batch dim.
result = _t.matmul(self.contiguous(), mat2.contiguous())
elif self.dim() == 2 and mat2.dim() == 3:
# Broadcast 2-D self across batch via matmul.
result = _t.matmul(self.contiguous(), mat2.contiguous())
else:
result = _t.mm(self.contiguous(), mat2.contiguous())
else:
# Grouped: offs[i] is the exclusive end-row of group i.
offs_list = offs.tolist()
pieces = []
prev = 0
for idx, end in enumerate(offs_list):
end = int(end)
a_part = self[prev:end].contiguous()
if mat2.dim() == 3:
b_part = mat2[idx].contiguous()
else:
b_part = mat2.contiguous()
pieces.append(_t.mm(a_part, b_part))
prev = end
# Include trailing rows not covered by offs.
if prev < self.shape[0]:
a_tail = self[prev:].contiguous()
b_tail = (
mat2[-1].contiguous() if mat2.dim() == 3 else mat2.contiguous()
)
pieces.append(_t.mm(a_tail, b_tail))
result = (
_t.cat(pieces, dim = 0)
if pieces
else _t.zeros(
0,
mat2.shape[-1],
device = self.device,
dtype = self.dtype,
)
)
if bias is not None:
result = result + bias
if out_dtype is not None:
result = result.to(out_dtype)
elif result.dtype != self.dtype:
result = result.to(self.dtype)
return result
with _warnings.catch_warnings():
_warnings.simplefilter("ignore")
_gm_lib.impl("_grouped_mm", _grouped_mm_safe_impl, "CUDA")
_WINDOWS_ROCM_GROUPED_MM_LIB = _gm_lib # prevent GC
logger.info(
"Windows ROCm: patched _grouped_mm CUDA dispatch "
"(null HIP kernel on gfx1200, ROCm ≤ 7.12 — "
"bypassed with Python mm fallback)"
_WINDOWS_ROCM_GROUPED_MM_LIB = _install_grouped_mm_cpu_fallback(
_torch_for_rocm, logger, "Windows ROCm"
)
except Exception as _patch_exc:
logger.warning(
@ -2776,6 +2782,44 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
"skipping Python fallback (AMD fixed gfx1200 null kernel in ROCm 7.13)"
)
# ── 1f-linux. Linux ROCm RDNA4 _grouped_mm null kernel ──
# The win32 guard above misses Linux: RDNA4 (gfx1200/gfx1201) hits the same null
# HIP _grouped_mm kernel at ROCm <= 7.12 (fixed 7.13, ROCm/TheRock #5284). Gate on
# arch + HIP < 7.13 so NVIDIA/CUDA and non-RDNA4 AMD are untouched; no-op if fixed.
if sys.platform.startswith("linux") and _hw.IS_ROCM:
try:
_torch_lin = sys.modules.get("torch")
if _torch_lin is not None and _torch_lin.cuda.is_available():
# Prefer torch.version.hip, else rocmX.Y from torch.__version__ (AMD
# SDK / Radeon wheels leave version.hip unset). Unknown version on a
# gfx120X build -> assume affected unless it is a post-fix rocmsdk wheel.
_hip_str = str(getattr(getattr(_torch_lin, "version", None), "hip", "") or "")
_ver = getattr(_torch_lin, "__version__", "").lower()
_m = re.match(r"(\d+)\.(\d+)", _hip_str) or re.search(r"rocm(\d+)\.(\d+)", _ver)
if _m:
_hip_lt_713 = (int(_m.group(1)), int(_m.group(2))) < (7, 13)
else:
_hip_lt_713 = "rocmsdk" not in _ver
# Scan every visible GPU (device_map="balanced" can place layers on a
# later RDNA4 card, so device 0 is not enough). Match gfx120X by arch,
# or by RX 9000 / R9700 name when the wheel omits gcnArchName.
_rdna4 = False
for _i in range(_torch_lin.cuda.device_count()):
_props = _torch_lin.cuda.get_device_properties(_i)
_lin_arch, _ = _rocm_classify_unified_memory(_props)
_lin_name = (getattr(_props, "name", "") or "").lower()
if _lin_arch.lower() in ("gfx1200", "gfx1201") or (
not _lin_arch and re.search(r"rx\s*90[0-9]0|r9700", _lin_name)
):
_rdna4 = True
break
if _rdna4 and _hip_lt_713:
_WINDOWS_ROCM_GROUPED_MM_LIB = _install_grouped_mm_cpu_fallback(
_torch_lin, logger, "Linux ROCm gfx120X"
)
except Exception as _gm_lin_exc:
logger.warning("Linux ROCm gfx120X: could not patch _grouped_mm: %s", _gm_lin_exc)
# ── 1g. ROCm OOM guard ──
# On ROCm, exhausting VRAM can hang the HIP driver instead of raising.
# set_per_process_memory_fraction caps the allocator so PyTorch raises

View file

@ -163,6 +163,9 @@ class TestDeviceNameFallback:
"AMD Radeon 8060S",
"Radeon 8050S Graphics", # cut-down Strix Halo SKU
"AMD Radeon 8050S",
# gfx1151 Gorgon Halo (Ryzen AI Max 400 refresh)
"Radeon 8065S Graphics", # Ryzen AI Max+ 495
"AMD Radeon 8065S",
# case variants
"RADEON 8060S GRAPHICS",
"radeon 8050s",

View file

@ -55,6 +55,7 @@ import {
Archive03Icon,
ArrowRight02Icon,
BadgeInfoIcon,
BubbleChatIcon,
ChefHatIcon,
CloudIcon,
CpuIcon,
@ -108,6 +109,7 @@ import {
deleteChatItem,
listStoredChatThreads,
moveChatItemToProject,
notifyChatHistoryUpdated,
renameChatItem,
renameChatProject,
useChatRuntimeStore,
@ -115,6 +117,7 @@ import {
useChatSearchStore,
useChatSidebarItems,
usePinnedChatsStore,
usePinnedProjectsStore,
useChatPreferencesStore,
type ProjectRecord,
type SidebarItem,
@ -140,7 +143,14 @@ import {
} from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useExportRuntimeStore } from "@/features/export";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Fragment,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { isDownloadCancelled } from "@/lib/native-files";
import { toast } from "@/lib/toast";
import { ShutdownDialog } from "@/components/shutdown-dialog";
@ -199,6 +209,9 @@ const TestTubeOutlineIcon = TestTube01Icon.slice(
type ConversationExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl";
// A pinned project shows this many recent chats before "Show more".
const PINNED_PROJECT_CHAT_LIMIT = 4;
const CHAT_EXPORT_OPTIONS: Array<{
label: string;
format: ConversationExportFormat;
@ -445,14 +458,63 @@ export function AppSidebar() {
),
[allChatItems, pinnedIdSet],
);
// Pinned chats, in pin order (most recent first).
const [pinnedOpen, setPinnedOpen] = useState(true);
// "Projects" section: projects the user pinned, in pin order (most recent
// first). The section only appears once at least one project is pinned.
const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds);
const unpinProject = usePinnedProjectsStore((s) => s.unpin);
const pinnedProjectRecords = useMemo(() => {
const byId = new Map(projects.map((p) => [p.id, p]));
return pinnedProjectIds
.map((id) => byId.get(id))
.filter((p): p is ProjectRecord => Boolean(p));
}, [projects, pinnedProjectIds]);
// Pinned chats, in pin order (most recent first). Includes chats that live
// inside a project: pinning promotes a chat into this list, and it is removed
// from the project's nested list below so it never shows twice.
const pinnedChatItems = useMemo(() => {
const byId = new Map(allChatItems.map((item) => [item.id, item]));
return pinnedIds
.map((id) => byId.get(id))
.filter((item): item is SidebarItem => Boolean(item));
}, [allChatItems, pinnedIds]);
const [pinnedOpen, setPinnedOpen] = useState(true);
// A pinned project reveals its recent chats (most recent first) nested below.
// Pinned chats are excluded here since they render in the pinned-chats list.
const chatsByProjectId = useMemo(() => {
const map = new Map<string, SidebarItem[]>();
for (const item of allChatItems) {
if (!item.projectId) continue;
if (pinnedIdSet.has(item.id)) continue;
const list = map.get(item.projectId);
if (list) list.push(item);
else map.set(item.projectId, [item]);
}
for (const list of map.values())
list.sort((a, b) => b.updatedAt - a.updatedAt);
return map;
}, [allChatItems, pinnedIdSet]);
// Default expanded (not collapsed); the row toggles this. Show-more reveals
// chats past the first PINNED_PROJECT_CHAT_LIMIT.
const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
() => new Set(),
);
const [expandedChatProjectIds, setExpandedChatProjectIds] = useState<
Set<string>
>(() => new Set());
const toggleProjectCollapsed = (id: string) =>
setCollapsedProjectIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const toggleProjectShowAll = (id: string) =>
setExpandedChatProjectIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const anyChatRunning = useChatRuntimeStore((s) =>
@ -732,6 +794,8 @@ export function AppSidebar() {
const shouldDeleteProjectFiles =
target.kind === "project" && deleteProjectFiles;
setConfirmingDelete(null);
// Reset so the next project delete never inherits this checkbox.
setDeleteProjectFiles(false);
if (target.kind === "chat") {
await deleteChatWithCleanup(target.item);
return;
@ -741,7 +805,20 @@ export function AppSidebar() {
await deleteChatProject(target.project.id, {
deleteFiles: shouldDeleteProjectFiles,
});
if (activeProjectId === target.project.id) {
// Refresh chat history so the project's reparented chats don't linger
// as stale top-level rows.
notifyChatHistoryUpdated();
// activeProjectId is only the ?project= param; on a thread-only URL the
// project is resolved from the thread into the runtime store, so check
// that too or we strand the user on a now-deleted thread. Only redirect
// from a chat route: the runtime store value can be stale elsewhere.
const runtimeProjectId =
useChatRuntimeStore.getState().activeProjectId;
if (
isChatRoute &&
(activeProjectId === target.project.id ||
runtimeProjectId === target.project.id)
) {
useChatRuntimeStore.getState().setActiveProjectId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
}
@ -828,8 +905,11 @@ export function AppSidebar() {
// pl-3 (12px) over the content's pl-1.5 (6px) = 18px, aligning the
// title with the nav items above.
variant === "project" ? "pl-[39px]" : "pl-3",
// Pinned chats carry a chat icon, so add the nav-item icon gap.
isPinned && variant !== "project" && "gap-[8.5px]",
variant === "project"
? "group-hover/project-chat-item:pr-6 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-6"
? // Room for the hover pin quick-action plus the kebab.
"group-hover/project-chat-item:pr-14 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: isPinned
? // Pinned rows show an extra unpin button on hover, so reserve more room
// (pr-8 when the menu is open keeps the unpin button clear of the title).
@ -889,10 +969,43 @@ export function AppSidebar() {
closeMobileIfOpen();
}}
>
{isPinned && variant !== "project" && (
<HugeiconsIcon icon={BubbleChatIcon} strokeWidth={1.75} className="size-icon! shrink-0" />
)}
<span className="truncate">
{pendingRename?.id === item.id ? pendingRename.title : item.title}
</span>
</SidebarMenuButton>
{variant === "project" && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
togglePinnedChat(item.id);
}}
aria-label={isPinned ? "Unpin chat" : "Pin chat"}
className="sidebar-row-action is-unpin-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={isPinned ? PinOffIcon : PinIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
)}
{variant === "recent" && isPinned && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
togglePinnedChat(item.id);
}}
aria-label="Unpin chat"
className="sidebar-row-action is-unpin-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={PinOffIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@ -1344,28 +1457,156 @@ export function AppSidebar() {
</SidebarGroup>
</Collapsible>
{/* Pinned chats: own section above Recents */}
{!isStudioRoute && !showTrainingRecents && pinnedChatItems.length > 0 && (
<Collapsible open={pinnedOpen} onOpenChange={setPinnedOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Pinned
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarMenu>
{pinnedChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{/* Pinned: pinned projects (with their chats) and pinned chats */}
{!isStudioRoute &&
!showTrainingRecents &&
(pinnedProjectRecords.length > 0 ||
pinnedChatItems.length > 0) && (
<Collapsible open={pinnedOpen} onOpenChange={setPinnedOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Pinned
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1.5 pr-2">
<SidebarMenu>
{pinnedProjectRecords.map((project) => {
const projectChats =
chatsByProjectId.get(project.id) ?? [];
const expanded = !collapsedProjectIds.has(project.id);
const showAll = expandedChatProjectIds.has(project.id);
const visibleChats =
expanded && !showAll
? projectChats.slice(0, PINNED_PROJECT_CHAT_LIMIT)
: projectChats;
return (
<Fragment key={project.id}>
<SidebarMenuItem
className="group/recent-item relative"
>
<SidebarMenuButton
// Highlight the folder only on the project home; when
// a chat inside it is open, only that chat row is active.
isActive={activeProjectId === project.id && !activeThreadId}
onClick={() => toggleProjectCollapsed(project.id)}
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-hover/recent-item:pr-16 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8"
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon! shrink-0" />
<span className="truncate text-[14.5px] leading-[19px] tracking-nav">{project.name}</span>
</SidebarMenuButton>
{/* New chat in this project */}
<button
type="button"
aria-label="New chat"
onClick={(e) => {
e.stopPropagation();
openNewChat(project.id);
}}
className="sidebar-row-action is-unpin-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
{/* Project options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Project options"
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
sideOffset={0}
className="unsloth-plus-menu menu-flat-destructive w-56"
>
<DropdownMenuItem onSelect={() => openProject(project.id)}>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon" />
<span>Project home</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openNewChat(project.id)}>
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={1.75} className="size-icon" />
<span>New chat</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Seed the shared draft so the dialog opens
// with the current name, not stale text.
setRenameDraft(project.name);
setRenamingTarget({
kind: "project",
project,
current: project.name,
});
}}
>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename project</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => unpinProject(project.id)}>
<HugeiconsIcon icon={PinOffIcon} strokeWidth={1.75} className="size-icon" />
<span>Unpin project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
// Start each delete with the file toggle off:
// Cancel closes programmatically and skips the
// dialog onOpenChange reset.
setDeleteProjectFiles(false);
setConfirmingDelete({ kind: "project", project });
}}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
{expanded &&
visibleChats.map((chat) =>
renderChatSidebarItem(chat, "project"),
)}
{expanded &&
projectChats.length > PINNED_PROJECT_CHAT_LIMIT && (
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => toggleProjectShowAll(project.id)}
// Force the muted token: .sidebar-nav-btn's own
// color rule outweighs a plain text utility, so
// Show more would otherwise match the chat rows.
className="sidebar-nav-btn h-[30px] rounded-full pl-9 pr-4 font-medium text-nav-fg-muted!"
>
<span className="text-[13px] leading-[18px] tracking-nav">
{showAll ? "Show less" : "Show more"}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</Fragment>
);
})}
{pinnedChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{!isStudioRoute && !showTrainingRecents && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>

View file

@ -187,6 +187,7 @@ import {
} from "react";
import { create } from "zustand";
import { extractTaggedText, updateThreadMessage } from "@/features/chat/utils/update-thread-message";
import { useIsMobile } from "@/hooks/use-mobile";
// True while a file is dragged anywhere over the chat page, so the composer
// can show its "Drop files here" affordance.
@ -1381,7 +1382,13 @@ export const ProjectComposer: FC<{
}> = ({ disabled, placeholder }) => {
return (
<GeneratedImageOverlayProvider>
<ComposerAnimated disabled={disabled} placeholder={placeholder} />
{/* New chat in a project: queuing follow-ups here misbinds the thread,
so the queue only runs once the user is inside a chat session. */}
<ComposerAnimated
disabled={disabled}
placeholder={placeholder}
disableQueue
/>
</GeneratedImageOverlayProvider>
);
};
@ -1391,11 +1398,17 @@ const ComposerAnimated: FC<{
placeholder?: string;
threadId?: string | null;
menuSide?: "top" | "bottom";
}> = ({ disabled, threadId, menuSide }) => {
disableQueue?: boolean;
}> = ({ disabled, threadId, menuSide, disableQueue }) => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-[46rem]">
<div className="relative z-10 w-full">
<Composer disabled={disabled} threadId={threadId} menuSide={menuSide} />
<Composer
disabled={disabled}
threadId={threadId}
menuSide={menuSide}
disableQueue={disableQueue}
/>
</div>
</div>
);
@ -1430,7 +1443,8 @@ const Composer: FC<{
placeholder?: string;
threadId?: string | null;
menuSide?: "top" | "bottom";
}> = ({ disabled, threadId, menuSide }) => {
disableQueue?: boolean;
}> = ({ disabled, threadId, menuSide, disableQueue }) => {
const aui = useAui();
const pageDragging = useContext(PageDragContext);
const { overlay, closeOverlay } = useGeneratedImageOverlay();
@ -1488,17 +1502,19 @@ const Composer: FC<{
useChatRuntimeStore.getState().setDeepResearchEnabled(false);
}
}, [deepResearchEnabled, hasResearchMessage, researchThreadId, researchUsed]);
// More than 4 pills: collapse to icons only. Search and Code always show; the
// permission pill shows in every mode except "off" (it renders null there);
// Images, RAG, Canvas and MCP are conditional.
const pillsCompact =
// More than 4 pills: collapse to icons only. Search, Code, and permissions
// always show; Images, RAG, Canvas, MCP and Deep Research are conditional.
// Narrow viewports collapse too: the labelled row is wider than a
// phone-width composer.
const isMobile = useIsMobile();
const pillCount =
3 +
(ragEnabled ? 1 : 0) +
(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) +
(effectiveDeepResearchEnabled ? 1 : 0) >
4;
(ragEnabled ? 1 : 0) +
(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) +
(effectiveDeepResearchEnabled ? 1 : 0);
const pillsCompact = isMobile || pillCount > 4;
const setPendingImageEditReference = useChatRuntimeStore(
(s) => s.setPendingImageEditReference,
);
@ -1799,6 +1815,11 @@ const Composer: FC<{
if (threadIsRunning || promptQueueActive) {
event.preventDefault();
// Project new-chat composer: never queue, just ask the user to wait.
if (disableQueue) {
toast.error("Wait for the current response to finish");
return;
}
if (!canQueueCurrentPrompt) {
if (overlay || hasAttachments || hasPendingAudio) {
toast.error(
@ -1876,6 +1897,7 @@ const Composer: FC<{
composerText,
createPromptQueueTarget,
disabled,
disableQueue,
hasAttachments,
hasPendingAudio,
interceptSend,
@ -1896,9 +1918,12 @@ const Composer: FC<{
const startQueue = useCallback(
(items: string[], waitForCurrentRun = threadIsRunning) => {
// Saved-prompt Run-list calls this directly, so honour disableQueue here
// too: queuing from the project new-chat composer misbinds the thread.
if (disableQueue) return;
startPromptQueue(items, createPromptQueueTarget(), waitForCurrentRun);
},
[createPromptQueueTarget, threadIsRunning],
[createPromptQueueTarget, threadIsRunning, disableQueue],
);
const queueContextValue: PromptQueueCallbacks = { startQueue, stopQueue };
@ -1970,8 +1995,11 @@ const Composer: FC<{
isComposing ||
hasPendingAttachments
}
queueDisabled={!canQueueCurrentPrompt}
// disableQueue (project new-chat composer) also blocks the queue
// button, so a running thread shows Stop instead of Queue.
queueDisabled={disableQueue || !canQueueCurrentPrompt}
onQueueClick={() => {
if (disableQueue) return;
const queuedPrompt = composerText.trim();
if (queuedPrompt.length === 0) {
return;

View file

@ -22,7 +22,7 @@ export function Navbar() {
);
}
return (
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none">
<header className="absolute top-0 inset-x-0 z-[45] h-[48px] pointer-events-none">
<div className="flex h-full items-start pt-[11px] pl-2">
<SidebarTrigger className="pointer-events-auto !size-[34px]" />
</div>

View file

@ -21,8 +21,31 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
ResizableHandle,
ResizablePanel,
@ -47,14 +70,23 @@ import {
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { isDownloadCancelled } from "@/lib/native-files";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
Archive03Icon,
BubbleChatTemporaryIcon,
Delete02Icon,
Download01Icon,
Edit03Icon,
Folder01Icon,
Folder02Icon,
FolderExportIcon,
LayoutAlignRightIcon,
MoreHorizontalIcon,
MoreVerticalIcon,
PinIcon,
PinOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
@ -73,7 +105,7 @@ import {
useState,
} from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { listLocalModels } from "./api/chat-api";
import { listLocalModels, notifyChatHistoryUpdated } from "./api/chat-api";
import { ArtifactSurface } from "./artifacts/artifact-surface";
import {
clearAutoOpenedArtifacts,
@ -96,12 +128,21 @@ import {
} from "./external-providers";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import type { SelectedModelInput } from "./hooks/use-chat-model-runtime";
import { useChatProjects } from "./hooks/use-chat-projects";
import {
deleteChatProject,
moveChatItemToProject,
renameChatProject,
useChatProjects,
} from "./hooks/use-chat-projects";
import {
type SidebarItem,
archiveChatItem,
deleteChatItem,
renameChatItem,
useChatSidebarItems,
} from "./hooks/use-chat-sidebar-items";
import { usePinnedChatsStore } from "./stores/pinned-chats-store";
import { usePinnedProjectsStore } from "./stores/pinned-projects-store";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
@ -939,6 +980,47 @@ function formatProjectChatDate(timestamp: number): string {
}).format(new Date(timestamp));
}
// Unique thread nonce; falls back off crypto.randomUUID for non-secure
// (HTTP LAN) contexts where it is unavailable.
function createThreadNonce(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
// Chat export formats, mirroring the sidebar chat menu.
type ProjectChatExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl";
const PROJECT_CHAT_EXPORT_OPTIONS: Array<{
label: string;
format: ProjectChatExportFormat;
}> = [
{ label: "Raw JSONL", format: "raw-jsonl" },
{ label: "CSV", format: "csv" },
{ label: "ShareGPT JSONL", format: "sharegpt-jsonl" },
];
async function exportProjectConversation(
threadId: string,
format: ProjectChatExportFormat,
): Promise<void> {
const exports = await import("./prompt-storage/prompt-storage-dialog");
if (format === "raw-jsonl") return exports.exportConversationRawJsonl(threadId);
if (format === "csv") return exports.exportConversationCsv(threadId);
return exports.exportConversationShareGPT(threadId);
}
async function exportProjectChatItem(
item: SidebarItem,
format: ProjectChatExportFormat,
): Promise<void> {
const ids =
item.type === "single"
? [item.id]
: (await listStoredChatThreads({ pairId: item.id })).map((t) => t.id);
for (const id of ids) await exportProjectConversation(id, format);
}
function extractMessageText(content: MessageRecord["content"]): string {
if (typeof content === "string") {
return content;
@ -973,6 +1055,9 @@ function ProjectLanding({
items: SidebarItem[];
}): ReactElement {
const navigate = useNavigate();
// Gates body-portaled surfaces so they can't linger or act while the landing
// is off-route (e.g. behind another tab).
const active = useChatActive();
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const initialActiveThreadRef = useRef<string | null>(null);
const [projectTab, setProjectTab] = useState<"chats" | "sources">("chats");
@ -980,7 +1065,7 @@ function ProjectLanding({
null,
);
const [newThreadNonce, setNewThreadNonce] = useState(() =>
crypto.randomUUID(),
createThreadNonce(),
);
const [previews, setPreviews] = useState<
Record<string, { snippet: string; date: string }>
@ -999,13 +1084,65 @@ function ProjectLanding({
title: string;
} | null>(null);
// Project-level options (the header kebab menu).
const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds);
const togglePinProject = usePinnedProjectsStore((s) => s.togglePin);
const projectPinned = pinnedProjectIds.includes(projectId);
const [renamingProject, setRenamingProject] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
const [deletingProject, setDeletingProject] = useState(false);
async function handleProjectExport(
format: ProjectChatExportFormat,
): Promise<void> {
try {
const threads = await listStoredChatThreads({
projectId,
includeArchived: false,
});
const ids = [...new Set(threads.map((t) => t.id))];
for (const id of ids) await exportProjectConversation(id, format);
} catch (error) {
if (!isDownloadCancelled(error)) toast.error("Export failed.");
}
}
async function commitProjectRename(): Promise<void> {
const name = projectNameDraft.trim();
setRenamingProject(false);
if (!name || name === projectName) return;
try {
await renameChatProject(projectId, name);
} catch (err) {
toast.error("Failed to rename project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function commitProjectDelete(): Promise<void> {
setDeletingProject(false);
try {
await deleteChatProject(projectId);
// Refresh chat history so the project's now-deleted chats don't linger
// in the sidebar, matching the sidebar delete path.
notifyChatHistoryUpdated();
useChatRuntimeStore.getState().setActiveProjectId(null);
navigate({ to: "/chat", search: { new: createThreadNonce() } });
} catch (err) {
toast.error("Failed to delete project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
useEffect(() => {
initialActiveThreadRef.current =
useChatRuntimeStore.getState().activeThreadId;
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
setPendingNewThreadId(null);
setNewThreadNonce(crypto.randomUUID());
setNewThreadNonce(createThreadNonce());
setRenamingId(null);
setPendingRename(null);
}, [projectId]);
@ -1040,16 +1177,98 @@ function ProjectLanding({
[renameDraft],
);
// Full chat actions, matching the sidebar chat menu.
const { projects } = useChatProjects();
const pinnedChatIds = usePinnedChatsStore((s) => s.pinnedIds);
const togglePinnedChat = usePinnedChatsStore((s) => s.togglePin);
const confirmDeleteChats = useChatPreferencesStore(
(s) => s.confirmDeleteChats,
);
const pinnedChatIdSet = useMemo(
() => new Set(pinnedChatIds),
[pinnedChatIds],
);
const [confirmingDelete, setConfirmingDelete] = useState<SidebarItem | null>(
null,
);
// Landing has no active thread selected, so the onView callback here is a
// no-op; the items list refreshes itself once storage emits its update.
const noopView = useCallback(() => {}, []);
const handleArchive = useCallback(
async (item: SidebarItem) => {
try {
await archiveChatItem(item, activeThreadId ?? undefined, noopView);
} catch (err) {
toast.error("Failed to archive chat", {
description: err instanceof Error ? err.message : undefined,
});
}
},
[activeThreadId, noopView],
);
const runDelete = useCallback(
async (item: SidebarItem) => {
try {
await deleteChatItem(item, activeThreadId ?? undefined, noopView);
} catch (err) {
toast.error("Failed to delete chat", {
description: err instanceof Error ? err.message : undefined,
});
}
},
[activeThreadId, noopView],
);
const handleDelete = useCallback(
(item: SidebarItem) => {
if (confirmDeleteChats) setConfirmingDelete(item);
else void runDelete(item);
},
[confirmDeleteChats, runDelete],
);
const handleMoveToProject = useCallback(
async (item: SidebarItem, targetId: string | null) => {
try {
await moveChatItemToProject(item, targetId);
} catch (err) {
toast.error("Failed to move chat", {
description: err instanceof Error ? err.message : undefined,
});
}
},
[],
);
const handleExport = useCallback(
async (item: SidebarItem, format: ProjectChatExportFormat) => {
try {
await exportProjectChatItem(item, format);
} catch (error) {
if (!isDownloadCancelled(error)) toast.error("Export failed.");
}
},
[],
);
useEffect(() => {
if (!activeThreadId) {
setPendingNewThreadId(null);
// Leaving a created chat for a new one: rotate the nonce so the runtime
// switches to a fresh thread instead of appending to the old chat.
if (pendingNewThreadId) {
setNewThreadNonce(createThreadNonce());
setPendingNewThreadId(null);
}
return;
}
if (activeThreadId === initialActiveThreadRef.current) {
return;
}
setPendingNewThreadId(activeThreadId);
}, [activeThreadId]);
}, [activeThreadId, pendingNewThreadId]);
useEffect(() => {
let cancelled = false;
@ -1113,8 +1332,8 @@ function ProjectLanding({
} as CSSProperties
}
>
{/* 46rem matches the composer so every block shares the same edges. */}
<div className="mx-auto flex w-full max-w-[46rem] flex-col pt-[120px] pb-14">
{/* Slightly narrower than the composer max; every block shares this. */}
<div className="mx-auto flex w-full max-w-[44rem] flex-col pt-[120px] pb-14">
<div className="mb-12 flex items-center gap-4">
<span className="flex size-13 shrink-0 items-center justify-center rounded-[18px] bg-muted text-foreground/80">
<HugeiconsIcon
@ -1123,9 +1342,64 @@ function ProjectLanding({
className="size-6.5"
/>
</span>
<h1 className="truncate font-sans text-[30px] font-medium leading-tight tracking-normal text-foreground">
<h1 className="min-w-0 flex-1 truncate font-sans text-[30px] font-medium leading-tight tracking-normal text-foreground">
{projectName}
</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
aria-label="Project options"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted data-[state=open]:text-foreground"
>
<HugeiconsIcon icon={MoreHorizontalIcon} strokeWidth={1.75} className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={6}
className="unsloth-plus-menu menu-flat-destructive w-52"
>
<DropdownMenuItem
onSelect={() => {
setProjectNameDraft(projectName);
setRenamingProject(true);
}}
>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename project</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => togglePinProject(projectId)}>
<HugeiconsIcon icon={projectPinned ? PinOffIcon : PinIcon} strokeWidth={1.75} className="size-icon" />
<span>{projectPinned ? "Unpin project" : "Pin project"}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
<span>Export</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-48">
{PROJECT_CHAT_EXPORT_OPTIONS.map(({ label, format }) => (
<DropdownMenuItem
key={format}
onSelect={() => void handleProjectExport(format)}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeletingProject(true)}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ProjectComposer
@ -1146,12 +1420,9 @@ function ProjectLanding({
type="button"
onClick={() => setProjectTab("sources")}
data-active={projectTab === "sources"}
className="flex h-10 items-center gap-1.5 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
className="h-10 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
>
Sources
<span className="rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold leading-none text-emerald-700 dark:text-emerald-300">
New
</span>
</button>
</div>
@ -1211,11 +1482,6 @@ function ProjectLanding({
aria-label="Rename chat"
className="w-full border-0 bg-transparent text-[15px] font-semibold leading-5 text-foreground outline-none"
/>
{preview?.snippet ? (
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
{preview.snippet}
</div>
) : null}
</div>
</div>
);
@ -1242,11 +1508,6 @@ function ProjectLanding({
<div className="truncate text-[15px] font-semibold leading-5 text-foreground">
{displayTitle}
</div>
{preview?.snippet ? (
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
{preview.snippet}
</div>
) : null}
</div>
<span className="shrink-0 text-[14px] text-muted-foreground transition-opacity max-md:opacity-0 pointer-coarse:opacity-0 group-hover:opacity-0 group-has-[[data-state=open]]:opacity-0">
{preview?.date ??
@ -1272,7 +1533,7 @@ function ProjectLanding({
side="bottom"
align="end"
sideOffset={4}
className="unsloth-plus-menu w-56"
className="unsloth-plus-menu menu-flat-destructive w-56"
>
<DropdownMenuItem onSelect={() => openRename(item)}>
<HugeiconsIcon
@ -1282,6 +1543,106 @@ function ProjectLanding({
/>
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => togglePinnedChat(item.id)}
>
<HugeiconsIcon
icon={
pinnedChatIdSet.has(item.id)
? PinOffIcon
: PinIcon
}
strokeWidth={1.75}
className="size-icon"
/>
<span>
{pinnedChatIdSet.has(item.id)
? "Unpin chat"
: "Pin chat"}
</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon
icon={FolderExportIcon}
strokeWidth={1.75}
className="size-icon"
/>
<span>Move to project</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-52">
<DropdownMenuItem
disabled={item.projectId !== projectId}
onSelect={() =>
void handleMoveToProject(item, null)
}
>
<span>Recents</span>
</DropdownMenuItem>
{projects.map((p) => (
<DropdownMenuItem
key={p.id}
disabled={item.projectId === p.id}
onSelect={() =>
void handleMoveToProject(item, p.id)
}
>
<HugeiconsIcon
icon={Folder01Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon
icon={Download01Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>Export</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-52">
{PROJECT_CHAT_EXPORT_OPTIONS.map(
({ label, format }) => (
<DropdownMenuItem
key={format}
onSelect={() =>
void handleExport(item, format)
}
>
{label}
</DropdownMenuItem>
),
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => void handleArchive(item)}
>
<HugeiconsIcon
icon={Archive03Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>Archive</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => handleDelete(item)}
>
<HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -1292,6 +1653,96 @@ function ProjectLanding({
</div>
</div>
)}
<AlertDialog
open={active && confirmingDelete !== null}
onOpenChange={(open) => {
if (!open) setConfirmingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chat</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes "{confirmingDelete?.title}". This cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const target = confirmingDelete;
setConfirmingDelete(null);
if (target) void runDelete(target);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={active && renamingProject}
onOpenChange={(open) => {
if (!open) setRenamingProject(false);
}}
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<Input
value={projectNameDraft}
onChange={(e) => setProjectNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitProjectRename();
}
}}
autoFocus={true}
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setRenamingProject(false)}>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitProjectRename()}
disabled={
!projectNameDraft.trim() || projectNameDraft.trim() === projectName
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={active && deletingProject}
onOpenChange={(open) => {
if (!open) setDeletingProject(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project</AlertDialogTitle>
<AlertDialogDescription>
Delete "{projectName}"? Its chats will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void commitProjectDelete()}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ChatRuntimeProvider>
);
}

View file

@ -75,8 +75,11 @@ export function ProjectSwitcher({
side="bottom"
align="start"
sideOffset={0}
className="unsloth-plus-menu ring-0 min-w-56 max-w-72 max-h-72 font-heading"
className="unsloth-plus-menu ring-0 min-w-56 max-w-72 font-heading"
>
{/* Scroll the list here, not the container, so the rounded corners on
the scrollbar side are not squared off. */}
<div className="max-h-72 overflow-y-auto">
{showLoadingRow ? (
<DropdownMenuItem disabled={true} className="text-muted-foreground">
Loading
@ -117,6 +120,7 @@ export function ProjectSwitcher({
<DropdownMenuItem onSelect={onViewAllProjects}>
View all projects
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -17,6 +17,7 @@ export {
listRecommendedFolders,
listScanFolders,
loadModel,
notifyChatHistoryUpdated,
removeScanFolder,
revealCachedModel,
type BrowseFoldersResponse,
@ -53,6 +54,7 @@ export {
export { PermissionModeDropdown } from "./permission-mode-select";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
export { usePinnedProjectsStore } from "./stores/pinned-projects-store";
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
export {
PLUS_MENU_ORDER,

View file

@ -39,6 +39,7 @@ import {
renameChatProject,
useChatProjects,
useChatRuntimeStore,
usePinnedProjectsStore,
type ProjectRecord,
} from "@/features/chat";
import {
@ -47,13 +48,15 @@ import {
Edit03Icon,
Folder02Icon,
FolderAddIcon,
PinIcon,
PinOffIcon,
Search01Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { MoreHorizontalIcon } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
exportProjectConversations,
exportBulkConversationsMerged,
@ -68,21 +71,38 @@ import {
type SortMode = "activity" | "name";
function formatUpdatedAgo(ts: number): string {
const diff = Date.now() - ts;
if (!Number.isFinite(diff) || diff < 0) return "just now";
const s = Math.floor(diff / 1000);
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`;
const y = Math.floor(mo / 12);
return `${y} year${y === 1 ? "" : "s"} ago`;
// Reveal this many more projects each time the user scrolls near the bottom.
const PROJECTS_PAGE_STEP = 12;
// Visible count before the fit-to-height measurement runs.
const PROJECTS_INITIAL_FALLBACK = 8;
// Approx list row height in px, used to estimate how many rows fit the page.
const PROJECTS_ROW_HEIGHT = 68;
// Modified column, matching a file-list feel: Today / Yesterday / N days ago,
// then a short date once it is over a week old.
function formatModified(ts: number): string {
if (!Number.isFinite(ts)) return "";
const now = new Date();
const then = new Date(ts);
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfThen = new Date(
then.getFullYear(),
then.getMonth(),
then.getDate(),
).getTime();
const dayDiff = Math.round((startOfToday - startOfThen) / 86_400_000);
if (dayDiff <= 0) return "Today";
if (dayDiff === 1) return "Yesterday";
if (dayDiff < 7) return `${dayDiff} days ago`;
return then.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: then.getFullYear() === now.getFullYear() ? undefined : "numeric",
});
}
export function ProjectsPage() {
@ -91,6 +111,17 @@ export function ProjectsPage() {
const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("activity");
// Rows that fit the page height (measured), plus any revealed via Show more.
const [baseFit, setBaseFit] = useState(PROJECTS_INITIAL_FALLBACK);
const [extraCount, setExtraCount] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds);
const togglePinProject = usePinnedProjectsStore((s) => s.togglePin);
const pinnedProjectIdSet = useMemo(
() => new Set(pinnedProjectIds),
[pinnedProjectIds],
);
const [creating, setCreating] = useState(false);
const [nameDraft, setNameDraft] = useState("");
@ -162,7 +193,7 @@ export function ProjectsPage() {
await handleImport(file, target);
}
const visibleProjects = useMemo(() => {
const sortedProjects = useMemo(() => {
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? projects.filter((p) => p.name.toLowerCase().includes(trimmed))
@ -174,6 +205,51 @@ export function ProjectsPage() {
);
return filtered;
}, [projects, query, sortMode]);
// Default view shows as many rows as fit the page, then loads more as the
// user scrolls near the bottom. Search always spans every project.
const isSearching = query.trim() !== "";
const visibleCount = baseFit + extraCount;
const visibleProjects = isSearching
? sortedProjects
: sortedProjects.slice(0, visibleCount);
const hasMore = !isSearching && sortedProjects.length > visibleCount;
// Estimate how many rows fit below the list's top so the first page fills the
// screen without loading everything up front.
useEffect(() => {
function measure() {
const el = listRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const reserve = 24; // bottom breathing room
const fits = Math.floor(
(window.innerHeight - top - reserve) / PROJECTS_ROW_HEIGHT,
);
setBaseFit(Math.max(PROJECTS_PAGE_STEP, fits));
}
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [hasLoaded]);
// Infinite scroll: reveal another page-step whenever the sentinel near the
// list bottom scrolls into view.
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasMore) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setExtraCount((n) => n + PROJECTS_PAGE_STEP);
}
},
{ rootMargin: "300px" },
);
io.observe(el);
return () => io.disconnect();
// Re-observe after each load so it keeps filling while the sentinel stays
// in view (IntersectionObserver does not re-fire on a steady intersection).
}, [hasMore, visibleCount]);
function openProject(projectId: string) {
const runtime = useChatRuntimeStore.getState();
@ -274,7 +350,7 @@ export function ProjectsPage() {
}
return (
<main className="mx-auto w-full max-w-6xl px-6 py-10 font-heading sm:px-10">
<main className="mx-auto w-full max-w-5xl px-6 pb-10 pt-16 font-heading sm:px-10">
{/* Global import file input */}
<input
ref={globalImportRef}
@ -405,16 +481,22 @@ export function ProjectsPage() {
</div>
{!hasLoaded ? (
<div className="mt-12 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-16">
<div className="mb-1 flex items-center gap-3 px-5 pb-1 text-[13px] font-medium text-muted-foreground">
<span className="flex-1">Name</span>
<span className="w-40 shrink-0">Modified</span>
<span className="w-8 shrink-0" />
</div>
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[172px] rounded-[26px] bg-card p-6 shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] dark:shadow-none"
className="flex items-center gap-3 rounded-xl px-5 py-4"
>
<Skeleton className="size-10 rounded-[14px]" />
<Skeleton className="mt-4 h-5 w-2/3 rounded-[8px]" />
<Skeleton className="mt-2 h-4 w-4/5 rounded-[8px]" />
<Skeleton className="mt-8 h-3 w-24 rounded-[8px]" />
<Skeleton className="mr-1 size-9 shrink-0 rounded-[10px]" />
<Skeleton className="h-4 w-40 rounded-[8px]" />
<span className="flex-1" />
<Skeleton className="h-4 w-16 rounded-[8px]" />
<span className="w-8 shrink-0" />
</div>
))}
</div>
@ -440,9 +522,20 @@ export function ProjectsPage() {
)}
</div>
) : (
<div className="mt-12 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visibleProjects.map((project) => (
<div key={`wrap-${project.id}`} className="contents">
<>
<div className="mt-16">
{/* Column header. Name starts at the folder icon's left edge; the
right-anchored columns keep Modified over its values. */}
<div className="mb-1 flex items-center gap-3 px-5 pb-1 text-[13px] font-medium text-muted-foreground">
<span className="flex-1">Name</span>
<span className="w-40 shrink-0">Modified</span>
<span className="w-8 shrink-0" />
</div>
<div ref={listRef}>
{visibleProjects.map((project) => {
const pinned = pinnedProjectIdSet.has(project.id);
return (
<div key={`wrap-${project.id}`}>
<input
key={`import-${project.id}`}
type="file"
@ -469,23 +562,37 @@ export function ProjectsPage() {
openProject(project.id);
}
}}
className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="group/project-row relative flex cursor-pointer items-center gap-3 rounded-xl px-5 py-4 text-left transition-colors duration-150 hover:bg-muted/70 dark:hover:bg-white/[0.055] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<div className="flex items-start justify-between gap-2">
<span className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-muted text-foreground/70 transition-colors group-hover/project-card:bg-primary/10 group-hover/project-card:text-primary">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-5"
/>
</span>
<span className="mr-1 flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-muted text-foreground/70 transition-colors group-hover/project-row:bg-primary/10 group-hover/project-row:text-primary">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-5"
/>
</span>
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-foreground">
{project.name}
</span>
<span className="w-40 shrink-0 text-sm text-muted-foreground">
{formatModified(project.updatedAt)}
</span>
<div className="relative flex w-8 shrink-0 items-center justify-end">
{/* Pin fades out and the kebab fades in on hover, focus, or
menu open. Absolute + opacity gating keeps them from
overlapping while leaving the button keyboard-focusable. */}
{pinned && (
<span className="text-muted-foreground transition-opacity group-hover/project-row:opacity-0 group-focus-within/project-row:opacity-0 group-has-[[data-state=open]]/project-row:opacity-0">
<HugeiconsIcon icon={PinIcon} strokeWidth={1.75} className="size-4" />
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Project options"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10 focus-visible:opacity-100 group-hover/project-card:opacity-100 data-[state=open]:bg-black/5 data-[state=open]:opacity-100 dark:data-[state=open]:bg-white/10"
className="absolute right-0 flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition hover:bg-black/5 hover:text-foreground focus-visible:opacity-100 group-hover/project-row:opacity-100 data-[state=open]:bg-black/5 data-[state=open]:opacity-100 dark:hover:bg-white/10 dark:data-[state=open]:bg-white/10"
>
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</button>
@ -498,6 +605,16 @@ export function ProjectsPage() {
onKeyDown={(e) => e.stopPropagation()}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem
onSelect={() => togglePinProject(project.id)}
>
<HugeiconsIcon
icon={pinned ? PinOffIcon : PinIcon}
strokeWidth={1.75}
className="size-icon"
/>
<span>{pinned ? "Unpin project" : "Pin project"}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setRenameDraft(project.name);
@ -546,21 +663,15 @@ export function ProjectsPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<h2 className="mt-4 truncate text-[16px] font-semibold text-foreground">
{project.name}
</h2>
{project.instructions ? (
<p className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{project.instructions}
</p>
) : null}
<span className="mt-auto pt-4 text-xs text-muted-foreground/80">
Updated {formatUpdatedAgo(project.updatedAt)}
</span>
</div>
</div>
))}
);
})}
{/* Loads the next page-step when scrolled into view. */}
{hasMore && <div ref={sentinelRef} className="h-px w-full" />}
</div>
</div>
</>
)}
{/* Create project */}
@ -684,8 +795,8 @@ export function ProjectsPage() {
<DialogTitle>Delete project</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete <em>{deleting?.name}</em>? Chats in this
project will be moved back to Recents.
Are you sure you want to delete <em>{deleting?.name}</em>? Its chats will
be permanently deleted.
</p>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setDeleting(null)}>

View file

@ -98,6 +98,7 @@ import {
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { useIsMobile } from "@/hooks/use-mobile";
import {
PLUS_MENU_ORDER,
type PlusMenuItemId,
@ -813,15 +814,17 @@ export function SharedComposer({
const ragDisabled = modelLoaded && (isExternalModel || !supportsTools);
const showRagPill = !isExternalModel;
// Above 4 pills, collapse to icons only. Compare, Search, Code, and
// permissions always show; the rest are conditional.
const pillsCompact =
// permissions always show; the rest are conditional. Narrow viewports
// collapse too: the labelled row is wider than a phone-width composer.
const isMobile = useIsMobile();
const pillCount =
4 +
(showImagePill ? 1 : 0) +
(showRagPill && ragEnabled ? 1 : 0) +
(showWebFetchPill ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) >
4;
(showImagePill ? 1 : 0) +
(showRagPill && ragEnabled ? 1 : 0) +
(showWebFetchPill ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0);
const pillsCompact = isMobile || pillCount > 4;
// Backwards-compatible alias for call sites still referencing
// `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
@ -1781,7 +1784,7 @@ export function SharedComposer({
/>
<div className="composer-action-wrapper">
<div
className="flex items-center gap-0.5"
className="flex min-w-0 flex-wrap items-center gap-0.5"
data-pill-compact={pillsCompact ? "true" : undefined}
>
<input

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Client-side pin state for projects, keyed by project id. Kept in
// localStorage. Pinned projects drive the sidebar "Projects" section; new pins
// are prepended so the most recently pinned project sorts first.
export interface PinnedProjectsState {
pinnedIds: string[];
togglePin: (id: string) => void;
unpin: (id: string) => void;
}
export const usePinnedProjectsStore = create<PinnedProjectsState>()(
persist(
(set) => ({
pinnedIds: [],
togglePin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.includes(id)
? state.pinnedIds.filter((x) => x !== id)
: [id, ...state.pinnedIds],
})),
unpin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.filter((x) => x !== id),
})),
}),
{
name: "unsloth_pinned_projects",
merge: (persisted, current) => {
const saved = persisted as Partial<PinnedProjectsState> | undefined;
return {
...current,
pinnedIds: Array.isArray(saved?.pinnedIds) ? saved.pinnedIds : [],
};
},
},
),
);

View file

@ -1606,7 +1606,7 @@ html[data-chat-font] .aui-root {
}
.unsloth-composer-left {
@apply flex shrink-0 items-center gap-0.5;
@apply flex min-w-0 flex-wrap items-center gap-0.5;
order: 1;
/* Pull the plus button closer to the composer edge. */
margin-left: -0.25rem;

View file

@ -5717,6 +5717,50 @@ def _wsl_system_rocm_lib_dirs() -> list[str]:
return out
def _bundled_hip_present(binary_dir: str) -> bool:
if not binary_dir:
return False
try:
# Glob the version suffix (libggml-hip.so, .so.0, .so.0.11.1) the same
# way the installer's runtime health check matches libggml-hip.so*.
return any(Path(str(binary_dir)).glob("libggml-hip.so*"))
except OSError:
return False
def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> list[str]:
# UNSLOTH_LLAMA_NO_SYSTEM_ROCM=1 keeps the bundled runtime (opt-out).
if os.environ.get("UNSLOTH_LLAMA_NO_SYSTEM_ROCM") == "1":
return []
if sys.platform != "linux" or os.path.exists("/dev/dxg"):
return []
if not os.path.exists("/dev/kfd"):
return []
if not _bundled_hip_present(binary_dir):
return []
# Env-configured ROCm root first; /opt/rocm only as a fallback so a stale
# /opt/rocm doesn't shadow the driver-matching install these vars point at.
candidates = []
for var in ("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"):
val = os.environ.get(var)
if val:
candidates.append(val)
candidates.append("/opt/rocm")
out: list[str] = []
seen: set[str] = set()
for base in candidates:
for lib_sub in ("lib", "lib64"):
d = os.path.join(base, lib_sub)
if d in seen:
continue
seen.add(d)
if os.path.exists(os.path.join(d, "libhsa-runtime64.so")) or os.path.exists(
os.path.join(d, "libhsa-runtime64.so.1")
):
out.append(d)
return out
# Secrets a downloaded llama.cpp binary never needs; keep them out of binary_env().
# The installer's own API calls read os.environ directly, so auth is unaffected.
_SECRET_ENV_EXACT_NAMES = frozenset(
@ -5877,6 +5921,11 @@ def binary_env(
if _wsl_rocm:
ld_dirs = [*_wsl_rocm, *ld_dirs]
env.setdefault("HSA_ENABLE_DXG_DETECTION", "1")
# Native Linux AMD: system ROCm libs before the bundle's bundled HIP
# runtime, which can be incompatible with the host amdkfd driver.
_native_rocm = _native_linux_system_rocm_lib_dirs(str(binary_path.parent))
if _native_rocm:
ld_dirs = [*_native_rocm, *ld_dirs]
existing = [part for part in env.get("LD_LIBRARY_PATH", "").split(os.pathsep) if part]
env["LD_LIBRARY_PATH"] = os.pathsep.join(dedupe_existing_dirs([*ld_dirs, *existing]))
elif host.is_macos:

View file

@ -719,8 +719,8 @@ def _detect_windows_gfx_arch() -> str | None:
_WIN_GPU_NAME_ARCH_TABLE: "list[tuple[str, str]]" = [
(r"9070 XT|9080", "gfx1201"), # RDNA 4 (Radeon RX 9070 XT / 9080)
(r"9070|9060", "gfx1200"), # RDNA 4 (Radeon RX 9070 / 9060)
# RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
(r"8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max", "gfx1151"),
# RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
(r"8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max", "gfx1151"),
# RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
(
r"890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]"

View file

@ -1498,7 +1498,7 @@ if (-not $HasNvidiaSmi) {
$nameArchTable = @(
@{ P = "9070 XT|9080"; A = "gfx1201" } # RDNA 4 (Radeon RX 9070 XT / 9080)
@{ P = "9070|9060"; A = "gfx1200" } # RDNA 4 (Radeon RX 9070 / 9060)
@{ P = "8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
@{ P = "8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
@{ P = "890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1150" } # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
@{ P = "RX 7900|RX 7800|RX 7700(?!S)|PRO W7900|PRO W7800|PRO W7700"; A = "gfx1100" } # RDNA 3 desktop / workstation (Navi 31)
@{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500|PRO V710"; A = "gfx1102" } # RDNA 3 (Navi 33)

View file

@ -1136,7 +1136,7 @@ elif [ "$_setup_amd_detected" = true ]; then
case "$_setup_mkt" in
*"9070 XT"*|*9080*) _setup_gfx="gfx1201" ;; # RDNA 4
*9070*|*9060*) _setup_gfx="gfx1200" ;; # RDNA 4
*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _setup_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
*"8065S"*|*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _setup_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
*"890M"*|*"880M"*|*"860M"*|*"840M"*|*"Strix Point"*|*"Krackan"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) _setup_gfx="gfx1150" ;; # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
*"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*|*"PRO V710"*) _setup_gfx="gfx1102" ;; # RDNA 3 (Navi 33)
*"RX 7900"*|*"RX 7800"*|*"RX 7700"*|*"PRO W7900"*|*"PRO W7800"*|*"PRO W7700"*) _setup_gfx="gfx1100" ;; # RDNA 3 desktop / workstation (Navi 31)

View file

@ -98,7 +98,7 @@ assert_eq "probe before venv replacement" "yes" "$([ -n "$_probe_line" ] && [ -n
# The pin must be evaluated AFTER the last index/constraint decision (the Strix
# reroute raises the floor), so a raised floor rejects an older kept release.
_pin_line=$(grep -n '_prev_pin=\$(_previous_torch_pin' "$INSTALL_SH" | head -1 | cut -d: -f1)
_strix_line=$(grep -n 'Strix Halo / Strix Point: force rocm7.2 wheels' "$INSTALL_SH" | head -1 | cut -d: -f1)
_strix_line=$(grep -n 'Strix Halo / Strix Point:' "$INSTALL_SH" | head -1 | cut -d: -f1)
assert_eq "pin evaluated after the Strix reroute" "yes" "$([ -n "$_pin_line" ] && [ -n "$_strix_line" ] && [ "$_pin_line" -gt "$_strix_line" ] && echo yes)"
# A kept release that vanished from the index must fall back to the supported range.
assert_eq "resolve-failure fallback wired" "yes" "$(grep -q 'TORCH_CONSTRAINT="\$_PREV_FALLBACK_CONSTRAINT"' "$INSTALL_SH" && echo yes)"

View file

@ -3,8 +3,11 @@
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch, PropertyMock
@ -3191,13 +3194,64 @@ class TestStrixRocm71Override:
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
assert "moe_utils" in source or "_grouped_mm" in source
def test_strix_override_only_fires_on_rocm71(self):
"""install.sh must scope the Strix override to rocm7.1 only (not rocm7.2+)."""
def test_strix_override_scoped_below_arch_floor(self):
"""Strix reroute must fire for rocm leaves BELOW the arch floor (7.13) and
NOT at/above it. Executed via _rocm_leaf_below so it verifies the actual
version comparison, not a text match that a comment could satisfy."""
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
strix_idx = source.find("_strix_gfx")
assert strix_idx != -1
context_before = source[max(0, strix_idx - 2400) : strix_idx]
assert "rocm7.1" in context_before
# Selector + gate must switch on the index LEAF, not the whole URL (a mirror
# base path with its own rocm token would false-positive otherwise).
assert 'case "$_torch_index_leaf" in' in source
assert '_rocm_leaf_below "$_torch_index_leaf" 7 13' in source
shell = shutil.which("sh") or shutil.which("bash")
if not shell:
pytest.skip("no POSIX shell to execute _rocm_leaf_below")
match = re.search(r"^_rocm_leaf_below\(\) \{.*?^\}", source, re.S | re.M)
assert match, "could not extract _rocm_leaf_below from install.sh"
fn = match.group(0)
def below(leaf):
return (
subprocess.run(
[shell, "-c", f'{fn}\n_rocm_leaf_below "$1" 7 13', "_", leaf]
).returncode
== 0
)
for leaf in ("rocm6.0", "rocm7.0", "rocm7.1", "rocm7.2", "rocm7.12"):
assert below(leaf), f"{leaf} must reroute (below arch floor 7.13)"
for leaf in ("rocm7.13", "rocm7.14", "rocm8.0", "gfx1151", "cu128", "cpu"):
assert not below(leaf), f"{leaf} must NOT reroute (>= floor or non-rocm)"
def test_gfx_probe_survives_no_match_under_set_e(self):
"""A gfx probe whose grep finds no match must not abort install.sh under
set -euo pipefail before the amd-smi fallback runs. The reroute case now
matches every rocm* index, so this would break ordinary 6.x/7.2 installs
with a flaky rocminfo. Executed with shimmed tools, not a text match."""
shell = shutil.which("bash")
if not shell:
pytest.skip("bash needed to execute the probe block")
source = _INSTALL_SH_PATH.read_text(encoding = "utf-8")
block = re.search(
r'^ _gfx_all=""\n.*?(?=^ _strix_gfx="")', source, re.S | re.M
)
assert block, "could not extract the gfx-detection block"
with tempfile.TemporaryDirectory() as d:
# rocminfo emits no gfx token; amd-smi supplies gfx1151 (the fallback)
for name, out in (("rocminfo", "no gpu here"), ("amd-smi", "GPU: gfx1151")):
p = os.path.join(d, name)
with open(p, "w", encoding = "utf-8") as f:
f.write(f'#!/bin/sh\ncat <<"EOT"\n{out}\nEOT\n')
os.chmod(p, 0o755)
script = (
'set -euo pipefail\nHIP_VISIBLE_DEVICES=""\nROCR_VISIBLE_DEVICES=""\n'
+ block.group(0)
+ '\nprintf "OK:%s\\n" "$_gfx_all"\n'
)
env = dict(os.environ, PATH = d + os.pathsep + os.environ.get("PATH", ""))
r = subprocess.run([shell, "-c", script], env = env, capture_output = True, text = True)
assert r.returncode == 0, f"probe aborted under set -e: {r.stderr}"
assert "OK:gfx1151" in r.stdout, f"amd-smi fallback not reached: {r.stdout!r}"
def test_torch_constraint_updated_for_strix_amd_index(self):
"""install.sh must set TORCH_CONSTRAINT>=2.11 when routing Strix to AMD index."""