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:
commit
db9336fab8
21 changed files with 1373 additions and 242 deletions
10
install.ps1
10
install.ps1
|
|
@ -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_}"
|
||||
|
|
|
|||
81
install.sh
81
install.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue