diff --git a/install.ps1 b/install.ps1 index cceefd2647..36e03ca51d 100644 --- a/install.ps1 +++ b/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_}" diff --git a/install.sh b/install.sh index 445bab616a..e0f57c198b 100755 --- a/install.sh +++ b/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/, +# 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" diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 2c7433f7a4..8651ed9ea8 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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. diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 2df4fa58c6..5ded18ea45 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -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 diff --git a/studio/backend/tests/test_rocm_oom_guard.py b/studio/backend/tests/test_rocm_oom_guard.py index 699d0b74f5..ad46f6ee41 100644 --- a/studio/backend/tests/test_rocm_oom_guard.py +++ b/studio/backend/tests/test_rocm_oom_guard.py @@ -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", diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 8dd7bcdd9a..a13828b06b 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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(); + 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>( + () => new Set(), + ); + const [expandedChatProjectIds, setExpandedChatProjectIds] = useState< + Set + >(() => 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" && ( + + )} {pendingRename?.id === item.id ? pendingRename.title : item.title} + {variant === "project" && ( + + )} + {variant === "recent" && isPinned && ( + + )} + {/* Project options */} + + + + + + openProject(project.id)}> + + Project home + + openNewChat(project.id)}> + + New chat + + { + // 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, + }); + }} + > + + Rename project + + unpinProject(project.id)}> + + Unpin project + + + { + // Start each delete with the file toggle off: + // Cancel closes programmatically and skips the + // dialog onOpenChange reset. + setDeleteProjectFiles(false); + setConfirmingDelete({ kind: "project", project }); + }} + > + + Delete project + + + + + {expanded && + visibleChats.map((chat) => + renderChatSidebarItem(chat, "project"), + )} + {expanded && + projectChats.length > PINNED_PROJECT_CHAT_LIMIT && ( + + 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!" + > + + {showAll ? "Show less" : "Show more"} + + + + )} + + ); + })} + {pinnedChatItems.map((item) => + renderChatSidebarItem(item, "recent"), + )} + + + + + + )} {!isStudioRoute && !showTrainingRecents && ( diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 996aa0f29c..240ebb709c 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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 ( - + {/* 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. */} + ); }; @@ -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 (
- +
); @@ -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; diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 44387f2480..1165705888 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -22,7 +22,7 @@ export function Navbar() { ); } return ( -
+
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 501a065fb5..c7c10b2c6e 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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 { + 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 { + 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(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 @@ -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 { + 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 { + 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 { + 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( + 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. */} -
+ {/* Slightly narrower than the composer max; every block shares this. */} +
-

+

{projectName}

+ + + + + + { + setProjectNameDraft(projectName); + setRenamingProject(true); + }} + > + + Rename project + + togglePinProject(projectId)}> + + {projectPinned ? "Unpin project" : "Pin project"} + + + + + Export + + + {PROJECT_CHAT_EXPORT_OPTIONS.map(({ label, format }) => ( + void handleProjectExport(format)} + > + {label} + + ))} + + + + setDeletingProject(true)} + > + + Delete project + + +
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 - - New -
@@ -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 ? ( -
- {preview.snippet} -
- ) : null}
); @@ -1242,11 +1508,6 @@ function ProjectLanding({
{displayTitle}
- {preview?.snippet ? ( -
- {preview.snippet} -
- ) : null} {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" > openRename(item)}> Rename + togglePinnedChat(item.id)} + > + + + {pinnedChatIdSet.has(item.id) + ? "Unpin chat" + : "Pin chat"} + + + + + + Move to project + + + + void handleMoveToProject(item, null) + } + > + Recents + + {projects.map((p) => ( + + void handleMoveToProject(item, p.id) + } + > + + {p.name} + + ))} + + + + + + Export + + + {PROJECT_CHAT_EXPORT_OPTIONS.map( + ({ label, format }) => ( + + void handleExport(item, format) + } + > + {label} + + ), + )} + + + + void handleArchive(item)} + > + + Archive + + handleDelete(item)} + > + + Delete + @@ -1292,6 +1653,96 @@ function ProjectLanding({ )} + { + if (!open) setConfirmingDelete(null); + }} + > + + + Delete chat + + This permanently deletes "{confirmingDelete?.title}". This cannot + be undone. + + + + Cancel + { + const target = confirmingDelete; + setConfirmingDelete(null); + if (target) void runDelete(target); + }} + > + Delete + + + + + { + if (!open) setRenamingProject(false); + }} + > + + + Rename project + + 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" + /> + + + + + + + { + if (!open) setDeletingProject(false); + }} + > + + + Delete project + + Delete "{projectName}"? Its chats will be permanently deleted. + + + + Cancel + void commitProjectDelete()}> + Delete + + + + ); } diff --git a/studio/frontend/src/features/chat/components/project-switcher.tsx b/studio/frontend/src/features/chat/components/project-switcher.tsx index 13360ccf7e..8f923a8c80 100644 --- a/studio/frontend/src/features/chat/components/project-switcher.tsx +++ b/studio/frontend/src/features/chat/components/project-switcher.tsx @@ -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. */} +
{showLoadingRow ? ( Loading… @@ -117,6 +120,7 @@ export function ProjectSwitcher({ View all projects +
); diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index e762e480a7..95e3830cb2 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -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, diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx index ee94a9537f..c9960ffaca 100644 --- a/studio/frontend/src/features/chat/projects-page.tsx +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -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("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(null); + const sentinelRef = useRef(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 ( -
+
{/* Global import file input */} {!hasLoaded ? ( -
+
+
+ Name + Modified + +
{Array.from({ length: 6 }).map((_, index) => (
- - - - + + + + +
))}
@@ -440,9 +522,20 @@ export function ProjectsPage() { )}
) : ( -
- {visibleProjects.map((project) => ( -
+ <> +
+ {/* Column header. Name starts at the folder icon's left edge; the + right-anchored columns keep Modified over its values. */} +
+ Name + Modified + +
+
+ {visibleProjects.map((project) => { + const pinned = pinnedProjectIdSet.has(project.id); + return ( +
-
- - - + + + + + {project.name} + + + {formatModified(project.updatedAt)} + +
+ {/* 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 && ( + + + + )} @@ -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" > + togglePinProject(project.id)} + > + + {pinned ? "Unpin project" : "Pin project"} + { setRenameDraft(project.name); @@ -546,21 +663,15 @@ export function ProjectsPage() {
-

- {project.name} -

- {project.instructions ? ( -

- {project.instructions} -

- ) : null} - - Updated {formatUpdatedAgo(project.updatedAt)} -
- ))} + ); + })} + {/* Loads the next page-step when scrolled into view. */} + {hasMore &&
} +
+ )} {/* Create project */} @@ -684,8 +795,8 @@ export function ProjectsPage() { Delete project

- Are you sure you want to delete {deleting?.name}? Chats in this - project will be moved back to Recents. + Are you sure you want to delete {deleting?.name}? Its chats will + be permanently deleted.