diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ea7fd0fc..73ff109b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -13,6 +13,7 @@ assignees: '' - [ ] 我已经升级到最新版本后复现过这个问题 - [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题 - [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息 +- [ ] 我已在分享前检查自动生成的诊断报告或诊断包,确认其中没有不应公开的私密元数据 ## 问题描述 @@ -56,6 +57,16 @@ assignees: '' 在此粘贴错误信息和日志 ``` +## 诊断信息(可选) + + +- 相关 Event IDs: +- 已粘贴到 Issue 正文的诊断报告: 是 / 否 +- 诊断包文件附件: + ## 截图或录屏 @@ -65,5 +76,3 @@ assignees: '' --- - - diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 2db4ac86..68811f63 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -95,6 +95,11 @@ jobs: working-directory: adapters run: bun install + - name: Verify Windows legacy data recovery + if: matrix.smoke_platform == 'windows' + working-directory: desktop + run: bun run test:windows-storage-recovery + - name: Build sidecars working-directory: desktop env: diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6fbf2135..9c126263 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -168,6 +168,11 @@ jobs: working-directory: adapters run: bun install + - name: Verify Windows legacy data recovery + if: matrix.smoke_platform == 'windows' + working-directory: desktop + run: bun run test:windows-storage-recovery + - name: Build sidecars working-directory: desktop env: diff --git a/bin/claude-haha b/bin/claude-haha index e488897f..35191424 100755 --- a/bin/claude-haha +++ b/bin/claude-haha @@ -23,8 +23,8 @@ fi # Force recovery CLI (simple readline REPL, no Ink TUI) if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then - exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@" + exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@" fi # Default: full CLI with Ink TUI -exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@" +exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@" diff --git a/desktop/build/installer.nsh b/desktop/build/installer.nsh new file mode 100644 index 00000000..f4f23523 --- /dev/null +++ b/desktop/build/installer.nsh @@ -0,0 +1,200 @@ +!include "LogicLib.nsh" +!include "getProcessInfo.nsh" +!define /ifndef INSTALL_REGISTRY_KEY "Software\${APP_GUID}" +!define /ifndef UNINSTALL_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" +Var pid + +!ifndef BUILD_UNINSTALLER +Var ccHahaRecoveryDone + +Function CcHahaUninstallerParent + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + + StrCpy $R2 0 + + cc_haha_uninstall_parent_find_first_quote: + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "" cc_haha_uninstall_parent_invalid + StrCmp $R1 '"' cc_haha_uninstall_parent_after_first_quote + IntOp $R2 $R2 + 1 + Goto cc_haha_uninstall_parent_find_first_quote + + cc_haha_uninstall_parent_after_first_quote: + IntOp $R2 $R2 + 1 + StrCpy $R0 $R0 "" $R2 + StrCpy $R2 0 + + cc_haha_uninstall_parent_find_second_quote: + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "" cc_haha_uninstall_parent_invalid + StrCmp $R1 '"' cc_haha_uninstall_parent_have_file + IntOp $R2 $R2 + 1 + Goto cc_haha_uninstall_parent_find_second_quote + + cc_haha_uninstall_parent_have_file: + StrCpy $R0 $R0 $R2 + StrLen $R2 $R0 + + cc_haha_uninstall_parent_find_slash: + IntOp $R2 $R2 - 1 + IntCmp $R2 0 cc_haha_uninstall_parent_invalid 0 0 + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "\" cc_haha_uninstall_parent_done + Goto cc_haha_uninstall_parent_find_slash + + cc_haha_uninstall_parent_invalid: + StrCpy $R0 "" + Goto cc_haha_uninstall_parent_done + + cc_haha_uninstall_parent_done: + StrCpy $R0 $R0 $R2 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +FunctionEnd + +Function CcHahaFinalInstallDir + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + Push $R4 + Push $R5 + + StrCpy $R1 "${APP_FILENAME}" + StrLen $R2 $R1 + StrLen $R3 $R0 + StrCpy $R4 0 + + cc_haha_final_install_find_name: + IntCmp $R4 $R3 cc_haha_final_install_append 0 cc_haha_final_install_append + StrCpy $R5 $R0 $R2 $R4 + StrCmp $R5 $R1 cc_haha_final_install_done + IntOp $R4 $R4 + 1 + Goto cc_haha_final_install_find_name + + cc_haha_final_install_append: + StrCpy $R0 "$R0\${APP_FILENAME}" + + cc_haha_final_install_done: + Pop $R5 + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +FunctionEnd + +Function CcHahaRecoverLegacy + InitPluginsDir + File /oname=$PLUGINSDIR\recover-legacy-install-data.ps1 "${BUILD_RESOURCES_DIR}\recover-legacy-install-data.ps1" + + ReadRegStr $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation + ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${If} $R0 == "" + !ifdef UNINSTALL_REGISTRY_KEY_2 + ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY_2}" UninstallString + !endif + ${EndIf} + ${If} $4 == "" + ${AndIf} $R0 != "" + Push $R0 + Call CcHahaUninstallerParent + Pop $4 + ${EndIf} + ReadRegStr $R1 HKLM "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${If} $R1 == "" + !ifdef UNINSTALL_REGISTRY_KEY_2 + ReadRegStr $R1 HKLM "${UNINSTALL_REGISTRY_KEY_2}" UninstallString + !endif + ${EndIf} + ${If} $5 == "" + ${AndIf} $R1 != "" + Push $R1 + Call CcHahaUninstallerParent + Pop $5 + ${EndIf} + ReadEnvStr $2 APPDATA + ReadEnvStr $3 USERPROFILE + ReadEnvStr $6 CLAUDE_CONFIG_DIR + ReadEnvStr $7 CC_HAHA_APP_PORTABLE_DIR + ${If} $2 == "" + StrCpy $0 "21" + StrCpy $1 "missing current-user APPDATA" + Return + ${EndIf} + ${If} $3 == "" + StrCpy $0 "21" + StrCpy $1 "missing current-user USERPROFILE" + Return + ${EndIf} + + Push "$INSTDIR" + Call CcHahaFinalInstallDir + Pop $9 + + DetailPrint "Checking registered installations for legacy Claude Code Haha data..." + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\recover-legacy-install-data.ps1" -PerUserInstallDir "$4" -PerMachineInstallDir "$5" -CandidateInstallDir "$9" -UserDataDir "$2\Claude Code Haha" -RecoveryRoot "$3\Claude Code Haha Data\Recovered" -ProcessName "${PRODUCT_FILENAME}.exe" -ActiveConfigDir "$6" -ActiveConfigManaged "$7" -InstallerIdentitySafety "$8"' + Pop $0 + Pop $1 +FunctionEnd + +!macro CcHahaRunLegacyRecovery + ${If} $ccHahaRecoveryDone != "1" + StrCpy $8 "trusted-user" + ${If} ${UAC_IsAdmin} + ${AndIfNot} ${UAC_IsInnerInstance} + StrCpy $8 "untrusted-elevated" + ${EndIf} + + ${If} ${UAC_IsInnerInstance} + StrCpy $8 "trusted-uac-outer" + !insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR} + ${Else} + Call CcHahaRecoverLegacy + ${EndIf} + + ${If} $0 != "0" + DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)" + MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha cannot safely recover data stored inside the old application directory. Close the running app and run this installer normally, not as Administrator. The old version and its data have not been removed.$\r$\n$\r$\n无法安全恢复旧安装目录中的数据。请关闭旧程序,并以普通方式(不要使用“以管理员身份运行”)重新运行安装程序。旧版本和原数据尚未删除。" + SetErrorLevel 20 + Quit + ${EndIf} + StrCpy $ccHahaRecoveryDone "1" + DetailPrint "Legacy Claude Code Haha data safety check completed" + ${EndIf} +!macroend +!endif + +!macro customCheckAppRunning + !insertmacro IS_POWERSHELL_AVAILABLE + !insertmacro _CHECK_APP_RUNNING + !ifndef BUILD_UNINSTALLER + !insertmacro CcHahaRunLegacyRecovery + !endif +!macroend + +!ifndef BUILD_UNINSTALLER +!macro customPageAfterChangeDir + Function CcHahaRecoveryBeforeInstall + ${If} ${UAC_IsInnerInstance} + !insertmacro CcHahaRunLegacyRecovery + ${EndIf} + Abort + FunctionEnd + Page custom CcHahaRecoveryBeforeInstall +!macroend + +!macro customInit + StrCpy $ccHahaRecoveryDone "0" + ${If} ${UAC_IsInnerInstance} + ${AndIf} ${Silent} + !insertmacro CcHahaRunLegacyRecovery + ${EndIf} +!macroend +!endif diff --git a/desktop/build/recover-legacy-install-data.ps1 b/desktop/build/recover-legacy-install-data.ps1 new file mode 100644 index 00000000..a443a37a --- /dev/null +++ b/desktop/build/recover-legacy-install-data.ps1 @@ -0,0 +1,972 @@ +[CmdletBinding()] +param( + [string]$PerUserInstallDir = '', + [string]$PerMachineInstallDir = '', + [string]$CandidateInstallDir = '', + [string]$UserDataDir = '', + [string]$RecoveryRoot = '', + [string]$ProcessName = 'Claude Code Haha.exe', + [string]$ActiveConfigDir = $env:CLAUDE_CONFIG_DIR, + [string]$ActiveConfigManaged = $env:CC_HAHA_APP_PORTABLE_DIR, + [ValidateSet('trusted-user', 'trusted-uac-outer', 'untrusted-elevated')] + [string]$InstallerIdentitySafety = 'trusted-user', + [switch]$SkipProcessCheck, + [switch]$SelfTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not ('CcHahaRecoveryNativePath' -as [type])) { + Add-Type @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class CcHahaRecoveryNativePath +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder path, + uint pathLength, + uint flags); + + public static string Resolve(string path) + { + const uint shareReadWriteDelete = 0x00000007; + const uint openExisting = 3; + const uint backupSemantics = 0x02000000; + using (SafeFileHandle handle = CreateFile( + path, + 0, + shareReadWriteDelete, + IntPtr.Zero, + openExisting, + backupSemantics, + IntPtr.Zero)) + { + if (handle.IsInvalid) { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Cannot resolve the final path for " + path); + } + StringBuilder result = new StringBuilder(32768); + uint length = GetFinalPathNameByHandle(handle, result, (uint)result.Capacity, 0); + if (length == 0) { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Cannot resolve the final path for " + path); + } + if (length >= (uint)result.Capacity) { + throw new InvalidOperationException("Resolved path is too long: " + path); + } + string value = result.ToString(); + if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) { + return @"\\" + value.Substring(8); + } + return value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase) + ? value.Substring(4) + : value; + } + } +} +'@ +} + +function Resolve-CanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $existingPath = $fullPath + $missingSegments = New-Object 'System.Collections.Generic.List[string]' + while (-not (Test-Path -LiteralPath $existingPath)) { + $parent = [IO.Path]::GetDirectoryName($existingPath) + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $existingPath) { + throw "Cannot resolve an existing ancestor for $Path" + } + $missingSegments.Insert(0, [IO.Path]::GetFileName($existingPath)) + $existingPath = $parent + } + + $resolved = [CcHahaRecoveryNativePath]::Resolve($existingPath) + foreach ($segment in $missingSegments) { + $resolved = Join-Path $resolved $segment + } + return [IO.Path]::GetFullPath($resolved) +} + +function Test-PathAtOrBelow { + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $resolvedParent = (Resolve-CanonicalPath $Parent).TrimEnd('\', '/') + $resolvedCandidate = (Resolve-CanonicalPath $Candidate).TrimEnd('\', '/') + if ($resolvedCandidate.Equals($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + return $resolvedCandidate.StartsWith( + $resolvedParent + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-LexicalPathAtOrBelow { + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') + $fullCandidate = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + if ($fullCandidate.Equals($fullParent, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + return $fullCandidate.StartsWith( + $fullParent + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-PathMayBeDeleted { + param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + return (Test-LexicalPathAtOrBelow -Parent $InstallDir -Candidate $Candidate) -or + (Test-PathAtOrBelow -Parent $InstallDir -Candidate $Candidate) +} + +function Test-SamePath { + param( + [Parameter(Mandatory = $true)][string]$Left, + [Parameter(Mandatory = $true)][string]$Right + ) + return (Resolve-CanonicalPath $Left).TrimEnd('\', '/').Equals( + (Resolve-CanonicalPath $Right).TrimEnd('\', '/'), + [StringComparison]::OrdinalIgnoreCase) +} + +function Read-AppMode { + param([Parameter(Mandatory = $true)][string]$ConfigDir) + + $modeFile = Join-Path $ConfigDir 'app-mode.json' + if (-not (Test-Path -LiteralPath $modeFile -PathType Leaf)) { + return $null + } + try { + $parsed = Get-Content -LiteralPath $modeFile -Raw | ConvertFrom-Json + } catch { + throw "Existing app mode metadata cannot be read safely ($modeFile): $($_.Exception.Message)" + } + if ($null -eq $parsed -or $parsed -isnot [pscustomobject]) { + throw "Existing app mode metadata is not a JSON object: $modeFile" + } + + $modeProperty = $parsed.PSObject.Properties['mode'] + if ($null -ne $modeProperty -and $modeProperty.Value -isnot [string]) { + throw "Existing app mode metadata has an invalid mode value: $modeFile" + } + $mode = if ($null -ne $modeProperty) { + ([string]$modeProperty.Value).ToLowerInvariant() + } else { + 'default' + } + if ($mode -notin @('default', 'portable')) { + throw "Existing app mode metadata has an unsupported mode '$mode': $modeFile" + } + + $portableProperty = $parsed.PSObject.Properties['portable_dir'] + if ($null -ne $portableProperty -and + $null -ne $portableProperty.Value -and + $portableProperty.Value -isnot [string]) { + throw "Existing app mode metadata has an invalid portable_dir value: $modeFile" + } + $portableDir = if ($null -ne $portableProperty -and $portableProperty.Value -is [string]) { + ([string]$portableProperty.Value).Trim() + } else { + $null + } + return [pscustomobject]@{ + Mode = $mode + PortableDir = $portableDir + } +} + +function Test-LegacyPortableData { + param([Parameter(Mandatory = $true)][string]$Dir) + + if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { + return $false + } + foreach ($file in @('settings.json', '.claude.json', '.mcp.json', 'window-state.json', 'terminal-config.json')) { + if (Test-Path -LiteralPath (Join-Path $Dir $file) -PathType Leaf) { + return $true + } + } + foreach ($childDir in @('Cache', 'EBWebView', 'projects', 'skills', 'plugins', 'cowork_plugins', 'cc-haha')) { + if (Test-Path -LiteralPath (Join-Path $Dir $childDir) -PathType Container) { + return $true + } + } + return $false +} + +function Resolve-LegacyConfiguredPath { + param( + [Parameter(Mandatory = $true)][string]$Value, + [Parameter(Mandatory = $true)][string]$Source + ) + + if (-not [IO.Path]::IsPathRooted($Value)) { + throw "Legacy custom data path is relative and cannot be recovered safely ($Source): $Value" + } + return [IO.Path]::GetFullPath($Value) +} + +function Get-LegacyActiveSource { + param( + [Parameter(Mandatory = $true)][string]$InstallDir, + $SystemMode + ) + + $legacyDir = Join-Path $InstallDir 'CLAUDE_CONFIG_DIR' + $legacyMode = Read-AppMode -ConfigDir $legacyDir + if ($null -ne $legacyMode) { + if ($legacyMode.Mode -ne 'portable') { + return $null + } + if (Test-LegacyPortableData -Dir $legacyDir) { + return $legacyDir + } + if ([string]::IsNullOrWhiteSpace([string]$legacyMode.PortableDir)) { + return $legacyDir + } + return Resolve-LegacyConfiguredPath -Value $legacyMode.PortableDir -Source (Join-Path $legacyDir 'app-mode.json') + } + + if ($null -ne $SystemMode) { + if ($SystemMode.Mode -ne 'portable') { + return $null + } + if ([string]::IsNullOrWhiteSpace([string]$SystemMode.PortableDir)) { + return $legacyDir + } + return Resolve-LegacyConfiguredPath -Value $SystemMode.PortableDir -Source 'system app-mode.json' + } + + if (Test-LegacyPortableData -Dir $legacyDir) { + return $legacyDir + } + return $null +} + +function Get-ExistingInstallDirs { + param([string[]]$InstallDirs) + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $result = New-Object 'System.Collections.Generic.List[string]' + foreach ($installDir in $InstallDirs) { + if ([string]::IsNullOrWhiteSpace($installDir) -or -not (Test-Path -LiteralPath $installDir -PathType Container)) { + continue + } + $canonical = Resolve-CanonicalPath $installDir + if ($seen.Add($canonical)) { + $result.Add($canonical) + } + } + return $result.ToArray() +} + +function Get-PotentialInstallDirs { + param([string[]]$InstallDirs) + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $result = New-Object 'System.Collections.Generic.List[string]' + foreach ($installDir in $InstallDirs) { + if ([string]::IsNullOrWhiteSpace($installDir)) { + continue + } + if (-not [IO.Path]::IsPathRooted($installDir)) { + throw "Application install directory is relative and cannot be checked safely: $installDir" + } + $fullPath = [IO.Path]::GetFullPath($installDir) + if ($seen.Add($fullPath)) { + $result.Add($fullPath) + } + } + return $result.ToArray() +} + +function Get-UnsafeLegacySource { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$UserDataDir, + [AllowEmptyString()][string]$ActiveConfigDir, + [AllowEmptyString()][string]$ActiveConfigManaged + ) + + $sources = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase) + $activeInsideInstall = $false + $activeOutsideInstall = $false + $active = $null + if (-not [string]::IsNullOrWhiteSpace($ActiveConfigDir)) { + if (-not [IO.Path]::IsPathRooted($ActiveConfigDir)) { + throw "Active CLAUDE_CONFIG_DIR is relative and cannot be recovered safely: $ActiveConfigDir" + } + $active = [IO.Path]::GetFullPath($ActiveConfigDir) + foreach ($installDir in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $active) { + $activeInsideInstall = $true + if ($ActiveConfigManaged -ne '1') { + throw "Active CLAUDE_CONFIG_DIR is managed outside Claude Code Haha and points inside an application install directory. Move or remove that environment variable before upgrading: $active" + } + if (Test-SamePath -Left $installDir -Right $active) { + throw "The active data directory is the application install root itself: $active" + } + if (Test-Path -LiteralPath $active -PathType Container) { + $canonicalActive = Resolve-CanonicalPath $active + if (-not $sources.ContainsKey($canonicalActive)) { + $sources.Add($canonicalActive, $active) + } + } + break + } + } + if (-not $activeInsideInstall) { + $activeOutsideInstall = $true + } + } + + if ($activeOutsideInstall -and $ActiveConfigManaged -eq '1') { + $systemMode = Read-AppMode -ConfigDir $UserDataDir + if ($null -eq $systemMode -or + $systemMode.Mode -ne 'portable' -or + [string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) { + throw 'App-managed CLAUDE_CONFIG_DIR has no matching persisted custom mode. Restart the old app before upgrading so its active and saved data directories agree.' + } + $persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json' + if (-not (Test-SamePath -Left $active -Right $persistedActive)) { + throw "App-managed CLAUDE_CONFIG_DIR does not match persisted custom mode. Active: $active; persisted: $persistedActive" + } + } elseif ($activeOutsideInstall) { + $systemMode = $null + } else { + $systemMode = Read-AppMode -ConfigDir $UserDataDir + } + foreach ($installDir in $InstallDirs) { + $source = Get-LegacyActiveSource -InstallDir $installDir -SystemMode $systemMode + if ([string]::IsNullOrWhiteSpace([string]$source) -or + -not (Test-Path -LiteralPath $source -PathType Container)) { + continue + } + foreach ($possiblyDeletedRoot in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $source) { + if (Test-SamePath -Left $possiblyDeletedRoot -Right $source) { + throw "The active data directory is the application install root itself: $source" + } + $canonicalSource = Resolve-CanonicalPath $source + if (-not $sources.ContainsKey($canonicalSource)) { + $sources.Add($canonicalSource, $source) + } + break + } + } + } + + if ($activeOutsideInstall) { + if ($sources.Count -gt 0) { + throw "External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists. Refusing to remove data that may belong to another Windows user: $($sources.Values -join ', ')" + } + return $null + } + if ($sources.Count -gt 1) { + throw "Multiple distinct legacy data sources may be removed; refusing to guess which one is active: $($sources.Values -join ', ')" + } + if ($sources.Count -eq 1) { + return @($sources.Values)[0] + } + return $null +} + +function Assert-NoUndiscoveredLegacySources { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [AllowNull()][AllowEmptyString()][string]$ActiveSource + ) + + foreach ($installDir in $InstallDirs) { + $pending = New-Object 'System.Collections.Generic.Stack[string]' + $pending.Push($installDir) + while ($pending.Count -gt 0) { + $currentDir = $pending.Pop() + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Application install tree contains a reparse point that prevents a complete legacy data scan: $entry" + } + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { + $pending.Push($entry) + continue + } + if (-not ([IO.Path]::GetFileName($entry)).Equals('app-mode.json', [StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $modeDir = [IO.Path]::GetDirectoryName($entry) + $mode = Read-AppMode -ConfigDir $modeDir + if ($null -eq $mode -or $mode.Mode -ne 'portable') { + continue + } + $candidate = if (Test-LegacyPortableData -Dir $modeDir) { + $modeDir + } elseif ([string]::IsNullOrWhiteSpace([string]$mode.PortableDir)) { + $modeDir + } else { + Resolve-LegacyConfiguredPath -Value $mode.PortableDir -Source $entry + } + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + continue + } + + $willBeRemoved = $false + foreach ($possiblyDeletedRoot in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $candidate) { + $willBeRemoved = $true + break + } + } + if (-not $willBeRemoved) { + continue + } + if (-not [string]::IsNullOrWhiteSpace($ActiveSource) -and + (Test-SamePath -Left $ActiveSource -Right $candidate)) { + continue + } + throw "Install-contained custom data was configured by another Windows user or an undiscovered legacy mode. Refusing to remove it: $candidate (metadata: $entry)" + } + } + } +} + +function Assert-NoRunningApplication { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$ProcessName + ) + + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop) + } catch { + throw "Cannot verify whether the old application or one of its sidecars is still running: $($_.Exception.Message)" + } + + $matching = New-Object 'System.Collections.Generic.List[string]' + $hasUnknownAppPath = $false + foreach ($process in $processes) { + $executablePath = [string]$process.ExecutablePath + if ([string]::IsNullOrWhiteSpace($executablePath)) { + if (([string]$process.Name).Equals($ProcessName, [StringComparison]::OrdinalIgnoreCase)) { + $hasUnknownAppPath = $true + } + continue + } + foreach ($installDir in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $executablePath) { + $matching.Add($executablePath) + break + } + } + } + if ($matching.Count -eq 0 -and -not $hasUnknownAppPath) { + return + } + Start-Sleep -Milliseconds 500 + } while ([DateTime]::UtcNow -lt $deadline) + + if ($hasUnknownAppPath) { + throw "Cannot verify the executable path of a running $ProcessName process. Close the app and run the installer again." + } + throw "An application process is still running from an installation that may contain legacy data: $($matching -join ', '). Close the app and run the installer again." +} + +function Get-FileSha256 { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '') + } finally { + $sha.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Get-TreeManifest { + param([Parameter(Mandatory = $true)][string]$Root) + + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $rootAttributes = [IO.File]::GetAttributes($rootPath) + if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Legacy data root is a reparse point and cannot be copied without ambiguity: $rootPath" + } + + $entries = New-Object 'System.Collections.Generic.List[string]' + $pending = New-Object 'System.Collections.Generic.Stack[string]' + $pending.Push($rootPath) + while ($pending.Count -gt 0) { + $currentDir = $pending.Pop() + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Legacy data contains a reparse point and recovery must stop: $entry" + } + $relative = [IO.Path]::GetFullPath($entry).Substring($rootPath.Length).TrimStart('\', '/') + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { + $entries.Add("D|$relative") + $pending.Push($entry) + } else { + $file = Get-Item -LiteralPath $entry -Force + $entries.Add("F|$relative|$($file.Length)|$(Get-FileSha256 -Path $entry)") + } + } + } + return @($entries.ToArray() | Sort-Object) +} + +function Assert-TreeManifestsEqual { + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Expected, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Actual, + [Parameter(Mandatory = $true)][string]$Message + ) + + if ($Expected.Count -ne $Actual.Count -or + $null -ne (Compare-Object -ReferenceObject @($Expected) -DifferenceObject @($Actual) -CaseSensitive)) { + throw $Message + } +} + +function Copy-VerifiedTree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$RecoveryRoot + ) + + New-Item -ItemType Directory -Path $RecoveryRoot -Force | Out-Null + $id = [Guid]::NewGuid().ToString('N') + $timestamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $staging = Join-Path $RecoveryRoot ".$id.partial" + $destination = Join-Path $RecoveryRoot "Recovered-$timestamp-$id" + New-Item -ItemType Directory -Path $staging | Out-Null + + try { + $before = @(Get-TreeManifest -Root $Source) + & "$env:SystemRoot\System32\robocopy.exe" ` + $Source $staging /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NFL /NDL /NJH /NJS /NP | Out-Null + $robocopyExitCode = $LASTEXITCODE + if ($robocopyExitCode -ge 8) { + throw "robocopy failed with exit code $robocopyExitCode" + } + + $after = @(Get-TreeManifest -Root $Source) + $copied = @(Get-TreeManifest -Root $staging) + Assert-TreeManifestsEqual -Expected $before -Actual $after -Message 'Legacy data changed while it was being copied; close every app using it and retry.' + Assert-TreeManifestsEqual -Expected $after -Actual $copied -Message 'The recovered copy does not exactly match the legacy data source.' + + [IO.Directory]::Move($staging, $destination) + return $destination + } finally { + if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force + } + } +} + +function Write-AppModeAtomically { + param( + [Parameter(Mandatory = $true)][string]$UserDataDir, + [Parameter(Mandatory = $true)][string]$CustomDir + ) + + New-Item -ItemType Directory -Path $UserDataDir -Force | Out-Null + $target = Join-Path $UserDataDir 'app-mode.json' + $id = [Guid]::NewGuid().ToString('N') + $temporary = Join-Path $UserDataDir ".app-mode.$id.tmp" + $backup = Join-Path $UserDataDir "app-mode.pre-recovery.$id.bak" + $json = [ordered]@{ + mode = 'portable' + portable_dir = $CustomDir + } | ConvertTo-Json + + try { + [IO.File]::WriteAllText($temporary, $json, (New-Object Text.UTF8Encoding($false))) + if (Test-Path -LiteralPath $target -PathType Leaf) { + [IO.File]::Replace($temporary, $target, $backup, $true) + } else { + [IO.File]::Move($temporary, $target) + } + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force + } + } +} + +function Invoke-LegacyRecovery { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$UserDataDir, + [Parameter(Mandatory = $true)][string]$RecoveryRoot, + [Parameter(Mandatory = $true)][string]$ProcessName, + [AllowEmptyString()][string]$ActiveConfigDir, + [AllowEmptyString()][string]$ActiveConfigManaged = '', + [string]$InstallerIdentitySafety = 'trusted-user', + [string[]]$RegisteredInstallDirs = @(), + [switch]$SkipProcessCheck + ) + + $potentialInstallDirs = @(Get-PotentialInstallDirs -InstallDirs $InstallDirs) + $existingInstallDirs = @(Get-ExistingInstallDirs -InstallDirs $potentialInstallDirs) + if ($existingInstallDirs.Count -eq 0) { + return $null + } + if ($InstallerIdentitySafety -eq 'untrusted-elevated' -and + @(Get-ExistingInstallDirs -InstallDirs $RegisteredInstallDirs).Count -gt 0) { + throw 'An existing registered installation cannot be upgraded from an elevated installer without the original user process. Run the installer normally (not as Administrator).' + } + if (-not $SkipProcessCheck) { + Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName + } + + $source = Get-UnsafeLegacySource ` + -InstallDirs $existingInstallDirs ` + -UserDataDir $UserDataDir ` + -ActiveConfigDir $ActiveConfigDir ` + -ActiveConfigManaged $ActiveConfigManaged + Assert-NoUndiscoveredLegacySources -InstallDirs $existingInstallDirs -ActiveSource $source + if ([string]::IsNullOrWhiteSpace([string]$source)) { + return $null + } + if ($InstallerIdentitySafety -eq 'untrusted-elevated') { + throw 'Legacy data recovery was requested from an elevated installer without the original user process. Run the installer normally (not as Administrator) so recovery is written to the correct Windows user profile.' + } + + foreach ($installDir in $potentialInstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $UserDataDir) { + throw "The mode metadata directory is inside an application install directory: $UserDataDir" + } + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $RecoveryRoot) { + throw "The recovery destination is inside an application install directory: $RecoveryRoot" + } + } + + $destination = Copy-VerifiedTree -Source $source -RecoveryRoot $RecoveryRoot + $finalSource = @(Get-TreeManifest -Root $source) + $finalDestination = @(Get-TreeManifest -Root $destination) + Assert-TreeManifestsEqual ` + -Expected $finalSource ` + -Actual $finalDestination ` + -Message 'Legacy data changed after the verified recovery copy was finalized; the installer stopped before removing the old version.' + if (-not $SkipProcessCheck) { + Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName + } + Write-AppModeAtomically -UserDataDir $UserDataDir -CustomDir $destination + return $destination +} + +function Assert-SelfTest { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { + throw "Self-test failed: $Message" + } +} + +function Write-TestMode { + param( + [Parameter(Mandatory = $true)][string]$Dir, + [Parameter(Mandatory = $true)]$Value + ) + New-Item -ItemType Directory -Path $Dir -Force | Out-Null + $Value | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Dir 'app-mode.json') -Encoding UTF8 +} + +function Run-SelfTest { + $testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-storage-recovery-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $testRoot | Out-Null + try { + $install = Join-Path $testRoot 'old install' + $legacy = Join-Path $install 'CLAUDE_CONFIG_DIR' + $userData = Join-Path $testRoot 'app data' + $recovery = Join-Path $testRoot 'recovery' + New-Item -ItemType Directory -Path $legacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v1' -NoNewline + Write-TestMode -Dir $legacy -Value @{ mode = 'portable'; portable_dir = $null } + Write-TestMode -Dir $userData -Value @{ mode = 'default'; portable_dir = $null } + + $first = Invoke-LegacyRecovery ` + -InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $first 'settings.json') -PathType Leaf) -Message 'legacy default was not recovered' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'recovered content differs' + Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $legacy 'settings.json') -PathType Leaf) -Message 'source was modified' + $firstMode = Get-Content -LiteralPath (Join-Path $userData 'app-mode.json') -Raw | ConvertFrom-Json + Assert-SelfTest -Condition ($firstMode.mode -eq 'portable' -and $firstMode.portable_dir -eq $first) -Message 'recovery was not persisted as explicit custom mode' + + Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v2' -NoNewline + $second = Invoke-LegacyRecovery ` + -InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ($second -ne $first) -Message 'retry reused an existing destination' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $second 'settings.json') -Raw) -eq 'legacy-v2') -Message 'retry did not capture the latest source' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'retry overwrote the first recovery' + + $pointerInstall = Join-Path $testRoot 'pointer install' + $pointerLegacy = Join-Path $pointerInstall 'CLAUDE_CONFIG_DIR' + $pointerData = Join-Path $pointerInstall 'custom data' + $pointerUserData = Join-Path $testRoot 'pointer app data' + New-Item -ItemType Directory -Path $pointerData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $pointerData '.claude.json') -Value 'pointer-data' -NoNewline + Write-TestMode -Dir $pointerLegacy -Value @{ mode = 'portable'; portable_dir = $pointerData } + $pointerRecovered = Invoke-LegacyRecovery ` + -InstallDirs @($pointerInstall) -UserDataDir $pointerUserData -RecoveryRoot (Join-Path $testRoot 'pointer recovery') ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $pointerRecovered '.claude.json') -Raw) -eq 'pointer-data') -Message 'legacy custom pointer was not recovered' + + $managedInstall = Join-Path $testRoot 'managed env install' + $managedLegacy = Join-Path $managedInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $managedLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $managedLegacy 'settings.json') -Value 'managed-env' -NoNewline + $unmanagedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'unmanaged app data') ` + -RecoveryRoot (Join-Path $testRoot 'unmanaged recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '' -SkipProcessCheck | Out-Null + } catch { + $unmanagedFailed = $_.Exception.Message.Contains('managed outside Claude Code Haha') + } + Assert-SelfTest -Condition $unmanagedFailed -Message 'unsafe external CLAUDE_CONFIG_DIR did not fail closed' + + $managedRecovered = Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'managed app data') ` + -RecoveryRoot (Join-Path $testRoot 'managed recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $managedRecovered 'settings.json') -Raw) -eq 'managed-env') -Message 'app-managed active environment was not recovered' + + $elevatedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'elevated app data') ` + -RecoveryRoot (Join-Path $testRoot 'elevated recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' ` + -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null + } catch { + $elevatedFailed = $_.Exception.Message.Contains('original user process') + } + Assert-SelfTest -Condition $elevatedFailed -Message 'untrusted elevated recovery did not fail closed' + + $wrongIdentityInstall = Join-Path $testRoot 'registered shared install' + New-Item -ItemType Directory -Path (Join-Path $wrongIdentityInstall 'custom data') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $wrongIdentityInstall 'custom data\settings.json') -Value 'other-user-data' -NoNewline + $wrongIdentityFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($wrongIdentityInstall) -RegisteredInstallDirs @($wrongIdentityInstall) ` + -UserDataDir (Join-Path $testRoot 'wrong identity app data') ` + -RecoveryRoot (Join-Path $testRoot 'wrong identity recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null + } catch { + $wrongIdentityFailed = $_.Exception.Message.Contains('existing registered installation') + } + Assert-SelfTest -Condition $wrongIdentityFailed -Message 'untrusted elevated installer treated invisible user metadata as safe' + + $futureInstall = Join-Path $testRoot 'future install' + $futureRecovery = Join-Path $futureInstall 'Recovered' + $futureFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall, $futureInstall) ` + -UserDataDir (Join-Path $testRoot 'future app data') -RecoveryRoot $futureRecovery ` + -ProcessName $ProcessName -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' ` + -SkipProcessCheck | Out-Null + } catch { + $futureFailed = $_.Exception.Message.Contains('recovery destination is inside an application install directory') + } + Assert-SelfTest -Condition $futureFailed -Message 'missing future install root did not protect its recovery subtree' + + $sharedInstall = Join-Path $testRoot 'shared install' + $sharedLegacy = Join-Path $sharedInstall 'CLAUDE_CONFIG_DIR' + $activeExternal = Join-Path $testRoot 'active external' + New-Item -ItemType Directory -Path $sharedLegacy -Force | Out-Null + New-Item -ItemType Directory -Path $activeExternal -Force | Out-Null + Set-Content -LiteralPath (Join-Path $sharedLegacy 'settings.json') -Value 'shared-user-data' -NoNewline + $sharedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($sharedInstall) -UserDataDir (Join-Path $testRoot 'shared app data') ` + -RecoveryRoot (Join-Path $testRoot 'shared recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $activeExternal -ActiveConfigManaged '' -SkipProcessCheck | Out-Null + } catch { + $sharedFailed = $_.Exception.Message.Contains('another Windows user') + } + Assert-SelfTest -Condition $sharedFailed -Message 'external active config hid shared install-contained data' + + $otherUserInstall = Join-Path $testRoot 'other user shared install' + $otherUserData = Join-Path $otherUserInstall 'B-data' + $currentUserMode = Join-Path $testRoot 'current user shared mode' + New-Item -ItemType Directory -Path $otherUserData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $otherUserData 'settings.json') -Value 'other-windows-user' -NoNewline + Write-TestMode -Dir $otherUserData -Value @{ mode = 'portable'; portable_dir = $otherUserData } + Write-TestMode -Dir $currentUserMode -Value @{ mode = 'default'; portable_dir = $null } + $otherUserFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($otherUserInstall) -UserDataDir $currentUserMode ` + -RecoveryRoot (Join-Path $testRoot 'other user recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $otherUserFailed = $_.Exception.Message.Contains('another Windows user') + } + Assert-SelfTest -Condition $otherUserFailed -Message 'another Windows user custom directory was not protected' + + $managedExternalInstall = Join-Path $testRoot 'managed external install' + $managedExternalData = Join-Path $testRoot 'managed external data' + $managedExternalUserData = Join-Path $testRoot 'managed external app data' + New-Item -ItemType Directory -Path $managedExternalInstall -Force | Out-Null + New-Item -ItemType Directory -Path $managedExternalData -Force | Out-Null + Write-TestMode -Dir $managedExternalUserData -Value @{ mode = 'portable'; portable_dir = $managedExternalData } + $managedExternalResult = Invoke-LegacyRecovery ` + -InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData ` + -RecoveryRoot (Join-Path $testRoot 'managed external recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $managedExternalResult) -Message 'matching app-managed external custom mode was not accepted' + + Set-Content -LiteralPath (Join-Path $managedExternalUserData 'app-mode.json') -Value '{broken-json' -NoNewline + $managedExternalInvalidFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData ` + -RecoveryRoot (Join-Path $testRoot 'managed external invalid recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null + } catch { + $managedExternalInvalidFailed = $_.Exception.Message.Contains('cannot be read safely') + } + Assert-SelfTest -Condition $managedExternalInvalidFailed -Message 'invalid metadata bypassed app-managed external mode validation' + + $externalInstall = Join-Path $testRoot 'external install' + $externalLegacy = Join-Path $externalInstall 'CLAUDE_CONFIG_DIR' + $externalUserData = Join-Path $testRoot 'external app data' + $externalDir = Join-Path $testRoot 'external custom' + New-Item -ItemType Directory -Path $externalLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $externalLegacy 'settings.json') -Value 'inactive-legacy' -NoNewline + New-Item -ItemType Directory -Path $externalDir -Force | Out-Null + Write-TestMode -Dir $externalUserData -Value @{ mode = 'portable'; portable_dir = $externalDir } + $externalResult = Invoke-LegacyRecovery ` + -InstallDirs @($externalInstall) -UserDataDir $externalUserData -RecoveryRoot (Join-Path $testRoot 'external recovery') ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $externalResult) -Message 'external custom data was unnecessarily migrated' + + $secondInstall = Join-Path $testRoot 'second install' + $secondLegacy = Join-Path $secondInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $secondLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $secondLegacy 'settings.json') -Value 'second-source' -NoNewline + Write-TestMode -Dir $secondLegacy -Value @{ mode = 'portable'; portable_dir = $null } + $multipleFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($install, $secondInstall) -UserDataDir (Join-Path $testRoot 'multiple app data') ` + -RecoveryRoot (Join-Path $testRoot 'multiple recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $legacy -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null + } catch { + $multipleFailed = $_.Exception.Message.Contains('Multiple distinct legacy data sources') + } + Assert-SelfTest -Condition $multipleFailed -Message 'active environment hid an ambiguous dual-install source' + + $invalidInstall = Join-Path $testRoot 'invalid mode install' + $invalidUserData = Join-Path $testRoot 'invalid mode app data' + New-Item -ItemType Directory -Path $invalidInstall -Force | Out-Null + New-Item -ItemType Directory -Path $invalidUserData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $invalidUserData 'app-mode.json') -Value '{broken-json' -NoNewline + $invalidFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($invalidInstall) -UserDataDir $invalidUserData ` + -RecoveryRoot (Join-Path $testRoot 'invalid recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $invalidFailed = $_.Exception.Message.Contains('cannot be read safely') + } + Assert-SelfTest -Condition $invalidFailed -Message 'invalid existing mode metadata was treated as absent' + + $junctionInstall = Join-Path $testRoot 'junction install' + $junctionTarget = Join-Path $testRoot 'junction external target' + $junctionLegacy = Join-Path $junctionInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $junctionInstall -Force | Out-Null + New-Item -ItemType Directory -Path $junctionTarget -Force | Out-Null + Set-Content -LiteralPath (Join-Path $junctionTarget 'settings.json') -Value 'junction-data' -NoNewline + New-Item -ItemType Junction -Path $junctionLegacy -Target $junctionTarget | Out-Null + $junctionFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($junctionInstall) -UserDataDir (Join-Path $testRoot 'junction app data') ` + -RecoveryRoot (Join-Path $testRoot 'junction recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $junctionFailed = $_.Exception.Message.Contains('reparse point') + } + Assert-SelfTest -Condition $junctionFailed -Message 'install-contained junction source was silently skipped' + + [Console]::Out.WriteLine('Legacy install data recovery self-test passed.') + } finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +try { + if ($SelfTest) { + Run-SelfTest + exit 0 + } + + $result = Invoke-LegacyRecovery ` + -InstallDirs @($PerUserInstallDir, $PerMachineInstallDir, $CandidateInstallDir) ` + -UserDataDir $UserDataDir ` + -RecoveryRoot $RecoveryRoot ` + -ProcessName $ProcessName ` + -ActiveConfigDir $ActiveConfigDir ` + -ActiveConfigManaged $ActiveConfigManaged ` + -InstallerIdentitySafety $InstallerIdentitySafety ` + -RegisteredInstallDirs @($PerUserInstallDir, $PerMachineInstallDir) ` + -SkipProcessCheck:$SkipProcessCheck + if (-not [string]::IsNullOrWhiteSpace([string]$result)) { + [Console]::Out.WriteLine("Recovered legacy data to $result") + } + exit 0 +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 20 +} diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index bc5c4fae..b1bf8842 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -112,7 +112,6 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.previewMessage]: () => true, [ELECTRON_IPC_CHANNELS.appModeGet]: noPayload, [ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord, - [ELECTRON_IPC_CHANNELS.appModeDetectPortableDir]: noPayload, [ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload, [ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload, [ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 4fb5a31a..1ea23b8d 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -41,7 +41,6 @@ export const ELECTRON_IPC_CHANNELS = { previewMessage: 'desktop:preview:message', appModeGet: 'desktop:app-mode:get', appModeSet: 'desktop:app-mode:set', - appModeDetectPortableDir: 'desktop:app-mode:detect-portable-dir', appModePrepareRestart: 'desktop:app-mode:prepare-restart', appModeRestart: 'desktop:app-mode:restart', adaptersRestartSidecar: 'desktop:adapters:restart-sidecar', diff --git a/desktop/electron/main.security.test.ts b/desktop/electron/main.security.test.ts new file mode 100644 index 00000000..38367610 --- /dev/null +++ b/desktop/electron/main.security.test.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + configurePreviewSessionPermissions, + PREVIEW_SESSION_PARTITION, +} from './services/previewSession' + +const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts')) + ? process.cwd() + : path.resolve(process.cwd(), 'desktop') +const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8') + +describe('Electron preview security boundary', () => { + it('uses a dedicated in-memory session partition for remote previews', () => { + expect(PREVIEW_SESSION_PARTITION).toBe('cc-haha-preview') + expect(PREVIEW_SESSION_PARTITION.startsWith('persist:')).toBe(false) + expect(mainSource).toContain('partition: PREVIEW_SESSION_PARTITION') + }) + + it('denies preview permission checks and requests by default', () => { + const handlers: { + check?: (...args: unknown[]) => boolean + request?: (...args: unknown[]) => void + } = {} + const session = { + setPermissionCheckHandler(handler: (...args: unknown[]) => boolean) { + handlers.check = handler + }, + setPermissionRequestHandler(handler: (...args: unknown[]) => void) { + handlers.request = handler + }, + } + + configurePreviewSessionPermissions(session as never) + + expect(handlers.check?.()).toBe(false) + const callback = (allowed: boolean) => expect(allowed).toBe(false) + handlers.request?.(null, 'media', callback) + expect(mainSource).toContain('configurePreviewSessionPermissions(view.webContents.session)') + }) +}) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 362b98e9..92e67814 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels' import { isElectronIpcChannel, validateElectronIpcPayload } from './ipc/capabilities' import { ElectronServerRuntime } from './services/serverRuntime' +import { electronHostDiagnosticsFile } from './services/sidecarManager' import { openDialog, saveDialog } from './services/dialogs' import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell' import { @@ -18,12 +19,14 @@ import { ElectronUpdaterService, updaterSessionProxyConfig } from './services/up import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke' import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal' import { ElectronPreviewService, type PreviewBounds } from './services/preview' +import { + configurePreviewSessionPermissions, + PREVIEW_SESSION_PARTITION, +} from './services/previewSession' import { applyStartupPortableMode, - detectPortableDir, getAppMode, setAppMode, - type PortableDetection, } from './services/appMode' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' import { applyWindowsAppUserModelId } from './services/appIdentity' @@ -141,6 +144,7 @@ function getServerRuntime() { desktopRoot: unpackedRoot(), appRoot: appRoot(), h5DistDir: path.join(unpackedRoot(), 'dist'), + diagnosticsFile: electronHostDiagnosticsFile(process.env), resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url), }) return serverRuntime @@ -185,11 +189,13 @@ function getPreviewService() { const view = new WebContentsView({ webPreferences: { preload: previewPreloadPath(), + partition: PREVIEW_SESSION_PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) + configurePreviewSessionPermissions(view.webContents.session) installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl }) return view }, @@ -331,8 +337,7 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender)) registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app)) registerHandler(ELECTRON_IPC_CHANNELS.appModeSet, (_event, payload) => setAppMode(app, payload as Parameters[1])) - registerHandler(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir, () => detectPortableDir(app) as PortableDetection) - registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll()) + registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll(true)) registerHandler(ELECTRON_IPC_CHANNELS.appModeRestart, () => { isQuitting = true app.relaunch() diff --git a/desktop/electron/services/appMode.test.ts b/desktop/electron/services/appMode.test.ts index 9658da9f..01bab042 100644 --- a/desktop/electron/services/appMode.test.ts +++ b/desktop/electron/services/appMode.test.ts @@ -1,15 +1,13 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { applyStartupPortableMode, - defaultPortableDir, - detectPortableDir, determineStartupPortableDir, - dirHasPortableData, getAppMode, setAppMode, + systemClaudeConfigDir, type AppModeAppLike, } from './appMode' @@ -22,121 +20,255 @@ function tempDir() { } function app(root = tempDir()): AppModeAppLike & { root: string } { - const exe = path.join(root, 'Claude Code Haha.app', 'Contents', 'MacOS', 'Claude Code Haha') + const exe = path.join(root, 'install', 'Claude Code Haha') + const home = path.join(root, 'home') const userData = path.join(root, 'user-data') fs.mkdirSync(path.dirname(exe), { recursive: true }) fs.writeFileSync(exe, '') return { root, getPath(name) { - return name === 'exe' ? exe : userData + if (name === 'exe') return exe + if (name === 'home') return home + return userData }, } } +function writeMode(fakeApp: AppModeAppLike, value: unknown) { + const userData = fakeApp.getPath('userData') + fs.mkdirSync(userData, { recursive: true }) + fs.writeFileSync(path.join(userData, 'app-mode.json'), JSON.stringify(value)) +} + afterEach(() => { + vi.restoreAllMocks() for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }) } }) describe('Electron app mode service', () => { - it('detects portable data using the same sentinel files and directories as Tauri', () => { - const root = tempDir() - expect(dirHasPortableData(root)).toBe(false) - fs.writeFileSync(path.join(root, 'settings.json'), '{}') - expect(dirHasPortableData(root)).toBe(true) - fs.rmSync(path.join(root, 'settings.json')) - fs.mkdirSync(path.join(root, 'projects')) - expect(dirHasPortableData(root)).toBe(true) - }) - - it('resolves startup portable mode from default portable data or app-mode config', () => { + it('always uses ~/.claude in system mode and ignores app-adjacent legacy data at runtime', () => { const fakeApp = app() - const defaultDir = defaultPortableDir(fakeApp) - fs.mkdirSync(defaultDir, { recursive: true }) - fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}') + const legacyDir = path.join(path.dirname(fakeApp.getPath('exe')), 'CLAUDE_CONFIG_DIR') + fs.mkdirSync(legacyDir, { recursive: true }) + fs.writeFileSync(path.join(legacyDir, 'settings.json'), '{"legacy":true}') - expect(determineStartupPortableDir(fakeApp, {})).toBe(defaultDir) - expect(determineStartupPortableDir(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toBeNull() - - fs.writeFileSync(path.join(defaultDir, 'app-mode.json'), JSON.stringify({ mode: 'default' })) + expect(systemClaudeConfigDir(fakeApp)).toBe(path.join(fakeApp.root, 'home', '.claude')) expect(determineStartupPortableDir(fakeApp, {})).toBeNull() - }) - - it('sets portable environment variables before sidecars start', () => { - const fakeApp = app() - const env: NodeJS.ProcessEnv = {} - const defaultDir = defaultPortableDir(fakeApp) - fs.mkdirSync(defaultDir, { recursive: true }) - fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}') - - expect(applyStartupPortableMode(fakeApp, env)).toBe(defaultDir) - expect(env.CLAUDE_CONFIG_DIR).toBe(defaultDir) - expect(env.CC_HAHA_APP_PORTABLE_DIR).toBe('1') - expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(defaultDir, 'EBWebView')) - }) - - it('returns the active app mode shape expected by settingsStore', () => { - const fakeApp = app() - + expect(applyStartupPortableMode(fakeApp, {})).toBeNull() expect(getAppMode(fakeApp, {})).toEqual({ mode: 'default', - portableDir: defaultPortableDir(fakeApp), - defaultPortableDir: defaultPortableDir(fakeApp), - activeConfigDir: fakeApp.getPath('userData'), + portableDir: null, + activeConfigDir: path.join(fakeApp.root, 'home', '.claude'), configDirSource: 'system', }) - expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/portable', CC_HAHA_APP_PORTABLE_DIR: '1' })).toMatchObject({ - mode: 'portable', - portableDir: '/portable', - activeConfigDir: '/portable', - configDirSource: 'portable', - }) - expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toMatchObject({ - configDirSource: 'environment', - }) }) - it('writes app-mode.json to active, target portable, and system config dirs', () => { + it('activates only an explicit absolute custom directory persisted in userData', () => { const fakeApp = app() - const active = tempDir() - const selected = path.join(tempDir(), 'portable') + const customDir = path.join(fakeApp.root, 'custom-data') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + const env: NodeJS.ProcessEnv = {} - setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active }) + expect(determineStartupPortableDir(fakeApp, env)).toBe(customDir) + expect(applyStartupPortableMode(fakeApp, env)).toBe(customDir) + expect(env).toMatchObject({ + CLAUDE_CONFIG_DIR: customDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'), + }) + expect(getAppMode(fakeApp, env)).toEqual({ + mode: 'portable', + portableDir: customDir, + activeConfigDir: customDir, + configDirSource: 'portable', + }) + }) - const expected = { mode: 'portable', portable_dir: selected } - expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual(expected) - expect(JSON.parse(fs.readFileSync(path.join(selected, 'app-mode.json'), 'utf8'))).toEqual(expected) - expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual(expected) + it('treats an externally supplied CLAUDE_CONFIG_DIR as a read-only override', () => { + const fakeApp = app() + const externalDir = path.join(fakeApp.root, 'external-data') + const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: externalDir } - setAppMode(fakeApp, { mode: 'default', portableDir: null }, { CLAUDE_CONFIG_DIR: active }) - expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual({ + expect(determineStartupPortableDir(fakeApp, env)).toBeNull() + expect(applyStartupPortableMode(fakeApp, env)).toBeNull() + expect(env).toEqual({ CLAUDE_CONFIG_DIR: externalDir }) + expect(getAppMode(fakeApp, env)).toEqual({ + mode: 'portable', + portableDir: externalDir, + activeConfigDir: externalDir, + configDirSource: 'environment', + }) + expect(() => setAppMode(fakeApp, { mode: 'default', portableDir: null }, env)) + .toThrow('CLAUDE_CONFIG_DIR is controlled by the launch environment') + }) + + it('rejects relative or install-contained external custom directories', () => { + const fakeApp = app() + const installData = path.join(path.dirname(fakeApp.getPath('exe')), 'external-data') + + expect(() => applyStartupPortableMode(fakeApp, { + CLAUDE_CONFIG_DIR: 'relative-data', + })).toThrow('absolute path') + expect(() => getAppMode(fakeApp, { + CLAUDE_CONFIG_DIR: 'relative-data', + })).toThrow('absolute path') + expect(() => applyStartupPortableMode(fakeApp, { + CLAUDE_CONFIG_DIR: installData, + })).toThrow('outside the application install directory') + }) + + it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => { + const fakeApp = app() + writeMode(fakeApp, { mode: 'default', portable_dir: null }) + const oldCustomDir = path.join(fakeApp.root, 'old-custom') + const env: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: oldCustomDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(oldCustomDir, 'EBWebView'), + } + + expect(applyStartupPortableMode(fakeApp, env)).toBeNull() + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined() + expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined() + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined() + expect(getAppMode(fakeApp, env)).toMatchObject({ + mode: 'default', + activeConfigDir: systemClaudeConfigDir(fakeApp), + }) + }) + + it('replaces an inherited app-managed env with the newly persisted custom directory', () => { + const fakeApp = app() + const newCustomDir = path.join(fakeApp.root, 'new-custom') + writeMode(fakeApp, { mode: 'portable', portable_dir: newCustomDir }) + const env: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: path.join(fakeApp.root, 'old-custom'), + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(fakeApp.root, 'old-custom', 'EBWebView'), + } + + expect(applyStartupPortableMode(fakeApp, env)).toBe(newCustomDir) + expect(env.CLAUDE_CONFIG_DIR).toBe(newCustomDir) + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(newCustomDir, 'EBWebView')) + }) + + it.each([ + { mode: 'portable', portable_dir: null }, + { mode: 'portable', portable_dir: '' }, + { mode: 'portable', portable_dir: 'relative-data' }, + { mode: 'unknown', portable_dir: '/tmp/custom' }, + ])('falls back to system mode for invalid custom metadata: %o', value => { + const fakeApp = app() + writeMode(fakeApp, value) + + expect(determineStartupPortableDir(fakeApp, {})).toBeNull() + expect(getAppMode(fakeApp, {})).toMatchObject({ + mode: 'default', + portableDir: null, + activeConfigDir: systemClaudeConfigDir(fakeApp), + configDirSource: 'system', + }) + }) + + it('persists one atomic system-owned mode record for a custom directory', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + const previousActive = path.join(fakeApp.root, 'previous-custom') + + setAppMode(fakeApp, { mode: 'portable', portableDir: customDir }, { + CLAUDE_CONFIG_DIR: previousActive, + CC_HAHA_APP_PORTABLE_DIR: '1', + }) + + expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({ + mode: 'portable', + portable_dir: customDir, + }) + expect(fs.existsSync(path.join(customDir, 'app-mode.json'))).toBe(false) + expect(fs.existsSync(path.join(previousActive, 'app-mode.json'))).toBe(false) + expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json']) + }) + + it('switches back to system mode without touching the custom directory', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + fs.mkdirSync(customDir, { recursive: true }) + fs.writeFileSync(path.join(customDir, 'settings.json'), '{"keep":true}') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + + setAppMode(fakeApp, { mode: 'default', portableDir: null }, { + CLAUDE_CONFIG_DIR: customDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + }) + + expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({ mode: 'default', portable_dir: null, }) + expect(fs.readFileSync(path.join(customDir, 'settings.json'), 'utf8')).toBe('{"keep":true}') }) - it('does not point existing config at a portable dir that cannot persist app-mode.json', () => { + it.each([ + { label: 'missing', value: null }, + { label: 'empty', value: ' ' }, + { label: 'relative', value: 'relative-data' }, + ])('rejects a $label custom directory', ({ value }) => { const fakeApp = app() - const active = tempDir() - const selected = path.join(tempDir(), 'portable') - fs.mkdirSync(path.join(selected, 'app-mode.json'), { recursive: true }) - expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active })) - .toThrow() - expect(fs.existsSync(path.join(active, 'app-mode.json'))).toBe(false) + expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: value }, {})).toThrow() expect(fs.existsSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'))).toBe(false) }) - it('reports whether the default portable dir already has data', () => { + it('rejects custom directories inside the application install tree, including symlink aliases', () => { const fakeApp = app() - expect(detectPortableDir(fakeApp)).toEqual({ - defaultPortableDir: defaultPortableDir(fakeApp), - hasData: false, + const installDir = path.dirname(fakeApp.getPath('exe')) + const aliasedInstallDir = path.join(fakeApp.root, 'install-alias') + fs.symlinkSync(installDir, aliasedInstallDir, 'dir') + + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(installDir, 'data'), + }, {})).toThrow('outside the application install directory') + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(aliasedInstallDir, 'data'), + }, {})).toThrow('outside the application install directory') + }) + + it('does not partially mutate process.env when custom startup preparation fails', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + const env: NodeJS.ProcessEnv = {} + vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { + throw new Error('mkdir failed') }) - fs.mkdirSync(defaultPortableDir(fakeApp), { recursive: true }) - fs.writeFileSync(path.join(defaultPortableDir(fakeApp), '.mcp.json'), '{}') - expect(detectPortableDir(fakeApp).hasData).toBe(true) + + expect(() => applyStartupPortableMode(fakeApp, env)).toThrow('mkdir failed') + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined() + expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined() + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined() + }) + + it('keeps the previous mode record if the atomic replacement fails', () => { + const fakeApp = app() + const modeFile = path.join(fakeApp.getPath('userData'), 'app-mode.json') + writeMode(fakeApp, { mode: 'default', portable_dir: null }) + vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw new Error('rename failed') + }) + + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(fakeApp.root, 'custom-data'), + }, {})).toThrow('rename failed') + expect(JSON.parse(fs.readFileSync(modeFile, 'utf8'))).toEqual({ + mode: 'default', + portable_dir: null, + }) + expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json']) }) }) diff --git a/desktop/electron/services/appMode.ts b/desktop/electron/services/appMode.ts index e02b97d8..e06856b5 100644 --- a/desktop/electron/services/appMode.ts +++ b/desktop/electron/services/appMode.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -6,7 +7,7 @@ import type { AppModeConfig, AppModeSetInput } from '../../src/lib/desktopHost/t const APP_MODE_FILE = 'app-mode.json' export type AppModeAppLike = { - getPath(name: 'exe' | 'userData'): string + getPath(name: 'exe' | 'home' | 'userData'): string } type PersistedAppModeConfig = { @@ -14,50 +15,32 @@ type PersistedAppModeConfig = { portable_dir?: string | null } -export type PortableDetection = { - defaultPortableDir: string | null - hasData: boolean +export function systemClaudeConfigDir(app: AppModeAppLike): string { + return path.join(app.getPath('home'), '.claude') } -export function defaultPortableDir(app: AppModeAppLike): string { - return path.join(path.dirname(app.getPath('exe')), 'CLAUDE_CONFIG_DIR') -} - -export function dirHasPortableData(dir: string): boolean { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false - return [ - 'settings.json', - '.claude.json', - '.mcp.json', - 'window-state.json', - 'terminal-config.json', - ].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isFile()) - || [ - 'Cache', - 'EBWebView', - 'projects', - 'skills', - 'plugins', - 'cowork_plugins', - 'cc-haha', - ].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isDirectory()) -} - -export function readAppModeConfig(configDir: string): PersistedAppModeConfig | null { +function readAppModeConfig(configDir: string): PersistedAppModeConfig | null { try { const parsed = JSON.parse(fs.readFileSync(path.join(configDir, APP_MODE_FILE), 'utf8')) as PersistedAppModeConfig return { mode: typeof parsed.mode === 'string' ? parsed.mode.toLowerCase() : 'default', - portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir : null, + portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir.trim() : null, } } catch { return null } } -export function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void { +function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void { fs.mkdirSync(configDir, { recursive: true }) - fs.writeFileSync(path.join(configDir, APP_MODE_FILE), JSON.stringify(config, null, 2)) + const target = path.join(configDir, APP_MODE_FILE) + const temporary = path.join(configDir, `.${APP_MODE_FILE}.${randomUUID()}.tmp`) + try { + fs.writeFileSync(temporary, JSON.stringify(config, null, 2)) + fs.renameSync(temporary, target) + } finally { + fs.rmSync(temporary, { force: true }) + } } function assertWritableDataDir(configDir: string): void { @@ -74,58 +57,109 @@ function assertWritableDataDir(configDir: string): void { } } +function resolveWithExistingAncestor(inputPath: string): string { + let existingPath = path.resolve(inputPath) + const missingSegments: string[] = [] + while (!fs.existsSync(existingPath)) { + const parent = path.dirname(existingPath) + if (parent === existingPath) return path.resolve(inputPath) + missingSegments.unshift(path.basename(existingPath)) + existingPath = parent + } + return path.join(fs.realpathSync.native(existingPath), ...missingSegments) +} + +function isPathAtOrBelow(parentDir: string, candidateDir: string): boolean { + const relative = path.relative( + resolveWithExistingAncestor(parentDir), + resolveWithExistingAncestor(candidateDir), + ) + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) +} + +function normalizedCustomDir(app: AppModeAppLike, value: string | null | undefined): string { + const selectedDir = value?.trim() + if (!selectedDir) throw new Error('Choose an absolute custom data directory') + if (!path.isAbsolute(selectedDir)) throw new Error('Custom data storage must use an absolute path') + + const normalized = path.resolve(selectedDir) + if (isPathAtOrBelow(path.dirname(app.getPath('exe')), normalized)) { + throw new Error('Custom data storage must stay outside the application install directory') + } + return normalized +} + +function externallyControlled(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.CLAUDE_CONFIG_DIR && env.CC_HAHA_APP_PORTABLE_DIR !== '1') +} + export function determineStartupPortableDir( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): string | null { if (env.CLAUDE_CONFIG_DIR) return null - const defaultDir = defaultPortableDir(app) - const defaultMode = readAppModeConfig(defaultDir) - if (defaultMode) { - if (defaultMode.mode === 'portable') { - return dirHasPortableData(defaultDir) ? defaultDir : defaultMode.portable_dir ?? defaultDir - } + const config = readAppModeConfig(app.getPath('userData')) + if (config?.mode !== 'portable' || !config.portable_dir || !path.isAbsolute(config.portable_dir)) return null + + try { + return normalizedCustomDir(app, config.portable_dir) + } catch { return null } - - const systemMode = readAppModeConfig(app.getPath('userData')) - if (systemMode) { - if (systemMode.mode === 'portable') return systemMode.portable_dir ?? defaultDir - return null - } - - return dirHasPortableData(defaultDir) ? defaultDir : null } export function applyStartupPortableMode( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): string | null { - const portableDir = determineStartupPortableDir(app, env) - if (!portableDir) return null - env.CLAUDE_CONFIG_DIR = portableDir + // app.relaunch() inherits process.env. Discard the previous app-managed + // selection so the persisted two-mode record remains authoritative. + if (env.CC_HAHA_APP_PORTABLE_DIR === '1') { + delete env.CLAUDE_CONFIG_DIR + delete env.CC_HAHA_APP_PORTABLE_DIR + delete env.WEBVIEW2_USER_DATA_FOLDER + } + if (env.CLAUDE_CONFIG_DIR) { + env.CLAUDE_CONFIG_DIR = normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR) + return null + } + const customDir = determineStartupPortableDir(app, env) + if (!customDir) return null + + const webViewDataDir = path.join(customDir, 'EBWebView') + fs.mkdirSync(webViewDataDir, { recursive: true }) + env.CLAUDE_CONFIG_DIR = customDir env.CC_HAHA_APP_PORTABLE_DIR = '1' - env.WEBVIEW2_USER_DATA_FOLDER = path.join(portableDir, 'EBWebView') - fs.mkdirSync(env.WEBVIEW2_USER_DATA_FOLDER, { recursive: true }) - return portableDir + env.WEBVIEW2_USER_DATA_FOLDER = webViewDataDir + return customDir } export function getAppMode( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): AppModeConfig { - const envConfigDir = env.CLAUDE_CONFIG_DIR || null - const activeConfigDir = envConfigDir || app.getPath('userData') - const portableDir = envConfigDir || defaultPortableDir(app) + const envConfigDir = env.CLAUDE_CONFIG_DIR + ? normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR) + : null + const persistedCustomDir = envConfigDir ? null : determineStartupPortableDir(app, env) + const customDir = envConfigDir || persistedCustomDir + if (customDir) { + return { + mode: 'portable', + portableDir: customDir, + activeConfigDir: customDir, + configDirSource: envConfigDir && env.CC_HAHA_APP_PORTABLE_DIR !== '1' + ? 'environment' + : 'portable', + } + } + return { - mode: envConfigDir ? 'portable' : 'default', - portableDir, - defaultPortableDir: defaultPortableDir(app), - activeConfigDir, - configDirSource: envConfigDir - ? env.CC_HAHA_APP_PORTABLE_DIR ? 'portable' : 'environment' - : 'system', + mode: 'default', + portableDir: null, + activeConfigDir: systemClaudeConfigDir(app), + configDirSource: 'system', } } @@ -134,39 +168,23 @@ export function setAppMode( input: AppModeSetInput, env: NodeJS.ProcessEnv = process.env, ): void { - const activeConfigDir = env.CLAUDE_CONFIG_DIR || app.getPath('userData') - let config: PersistedAppModeConfig = { mode: 'default', portable_dir: null } - let targetPortableDir: string | null = null - - if (input.mode === 'portable') { - const selectedDir = input.portableDir?.trim() || defaultPortableDir(app) - if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) { - throw new Error(`portable config path is not a directory: ${selectedDir}`) - } - assertWritableDataDir(selectedDir) - targetPortableDir = selectedDir - config = { - mode: 'portable', - portable_dir: selectedDir === defaultPortableDir(app) ? null : selectedDir, - } + if (externallyControlled(env)) { + throw new Error('CLAUDE_CONFIG_DIR is controlled by the launch environment') } - const systemConfigDir = app.getPath('userData') - const configDirs = [ - targetPortableDir, - activeConfigDir, - systemConfigDir, - ].filter((dir): dir is string => Boolean(dir)) - - for (const configDir of [...new Set(configDirs)]) { - writeAppModeConfig(configDir, config) - } -} - -export function detectPortableDir(app: AppModeAppLike): PortableDetection { - const portableDir = defaultPortableDir(app) - return { - defaultPortableDir: portableDir, - hasData: dirHasPortableData(portableDir), + if (input.mode === 'default') { + writeAppModeConfig(app.getPath('userData'), { mode: 'default', portable_dir: null }) + return } + if (input.mode !== 'portable') throw new Error(`Unsupported app mode: ${String(input.mode)}`) + + const selectedDir = normalizedCustomDir(app, input.portableDir) + if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) { + throw new Error(`Custom data storage path is not a directory: ${selectedDir}`) + } + assertWritableDataDir(selectedDir) + writeAppModeConfig(app.getPath('userData'), { + mode: 'portable', + portable_dir: selectedDir, + }) } diff --git a/desktop/electron/services/appModeLifecycle.test.ts b/desktop/electron/services/appModeLifecycle.test.ts new file mode 100644 index 00000000..660c8b20 --- /dev/null +++ b/desktop/electron/services/appModeLifecycle.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('app mode restart lifecycle', () => { + it('synchronously stops Windows sidecars before relaunching with another data directory', () => { + const desktopDir = path.basename(process.cwd()) === 'desktop' + ? process.cwd() + : path.join(process.cwd(), 'desktop') + const source = readFileSync(path.join(desktopDir, 'electron', 'main.ts'), 'utf8') + const handler = source.match( + /registerHandler\(ELECTRON_IPC_CHANNELS\.appModePrepareRestart,[\s\S]*?\n\s*registerHandler/, + )?.[0] + + expect(handler).toContain('getServerRuntime().stopAll(true)') + }) +}) diff --git a/desktop/electron/services/preview.test.ts b/desktop/electron/services/preview.test.ts index 82c703fa..d7e1c7e8 100644 --- a/desktop/electron/services/preview.test.ts +++ b/desktop/electron/services/preview.test.ts @@ -20,8 +20,28 @@ class FakeWebContents implements PreviewWebContentsLike { scripts: string[] = [] zoomFactors: number[] = [] sent: Array<{ channel: string, payload: unknown }> = [] + documentSize = { width: 1280, height: 3200 } + debuggerAttached = false + debugger = { + isAttached: vi.fn(() => this.debuggerAttached), + attach: vi.fn(() => { + this.debuggerAttached = true + }), + detach: vi.fn(() => { + this.debuggerAttached = false + }), + sendCommand: vi.fn(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, ...this.documentSize } } + } + if (method === 'Page.captureScreenshot') return { data: 'FULL' } + throw new Error(`unexpected debugger command: ${method}`) + }), + } close = vi.fn() - capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' })) + capturePage = vi.fn(async (_rect?: { x: number, y: number, width: number, height: number }) => ({ + toDataURL: () => 'data:image/png;base64,NATIVE', + })) private loadHandler: (() => void) | null = null async loadURL(url: string) { @@ -246,16 +266,269 @@ describe('Electron preview service', () => { await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) - expect(view.webContents.capturePage).toHaveBeenCalledTimes(1) - expect(view.webContents.scripts.at(-1)).toBe('window.__previewInjected = true') + expect(view.webContents.capturePage).not.toHaveBeenCalled() + expect(view.webContents.scripts).not.toContain(expect.stringContaining('html2canvas')) expect(renderer.sent).toEqual([ { channel: ELECTRON_EVENT_CHANNELS.previewEvent, - payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' }, + payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, }, ]) }) + it('captures the full document through CDP with bounded document dimensions', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = { width: 1280, height: 3200 } + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.attach).toHaveBeenCalledWith('1.3') + expect(view.webContents.debugger.sendCommand.mock.calls).toEqual([ + ['Page.getLayoutMetrics'], + ['Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: true, + clip: { x: 0, y: 0, width: 1280, height: 3200, scale: 1 }, + }], + ]) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(view.webContents.capturePage).not.toHaveBeenCalled() + }) + + it('keeps viewport capture limited to the visible page', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = { width: 1280, height: 3200 } + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'viewport' }, renderer) + + expect(view.webContents.capturePage).toHaveBeenCalledWith() + expect(view.webContents.debugger.sendCommand).not.toHaveBeenCalled() + }) + + it('detaches the debugger when a full capture fails', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('layout failed')) + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(renderer.sent.at(-1)).toMatchObject({ + channel: ELECTRON_EVENT_CHANNELS.previewEvent, + payload: { v: 1, type: 'error', message: 'Error: layout failed' }, + }) + }) + + it('preserves a completed capture if debugger state lookup fails during cleanup', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debugger.isAttached + .mockReturnValueOnce(false) + .mockImplementationOnce(() => { + throw new Error('view closed') + }) + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(renderer.sent.at(-1)).toMatchObject({ + payload: { type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, + }) + }) + + it('does not detach a debugger session it did not attach', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debuggerAttached = true + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.attach).not.toHaveBeenCalled() + expect(view.webContents.debugger.detach).not.toHaveBeenCalled() + }) + + it('shares one debugger capture across concurrent full-page requests', async () => { + const view = new FakeView() + const firstRenderer = new FakeWebContents() + const secondRenderer = new FakeWebContents() + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await Promise.all([ + service.message({ v: 1, type: 'capture', kind: 'full' }, firstRenderer), + service.message({ v: 1, type: 'capture', kind: 'full' }, secondRenderer), + ]) + + expect(view.webContents.debugger.attach).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.sendCommand).toHaveBeenCalledTimes(2) + expect(firstRenderer.sent.at(-1)).toEqual(secondRenderer.sent.at(-1)) + }) + + it('starts a new full-page capture after the preview is closed and reopened', async () => { + const firstView = new FakeView() + const secondView = new FakeView() + const firstRenderer = new FakeWebContents() + const secondRenderer = new FakeWebContents() + const coalescedRenderer = new FakeWebContents() + let resolveFirstCapture!: (value: { data: string }) => void + let resolveSecondCapture!: (value: { data: string }) => void + const firstCapture = new Promise<{ data: string }>((resolve) => { + resolveFirstCapture = resolve + }) + const secondCapture = new Promise<{ data: string }>((resolve) => { + resolveSecondCapture = resolve + }) + + firstView.webContents.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, width: 800, height: 1200 } } + } + if (method === 'Page.captureScreenshot') return await firstCapture + throw new Error(`unexpected debugger command: ${method}`) + }) + secondView.webContents.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, width: 900, height: 1400 } } + } + if (method === 'Page.captureScreenshot') return await secondCapture + throw new Error(`unexpected debugger command: ${method}`) + }) + + const views = [firstView, secondView] + const parent = { contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } } + const service = new ElectronPreviewService({ + createView: () => views.shift()!, + previewScriptPath: previewScript(), + }) + await service.open(parent, 'https://first.example.com', { x: 0, y: 0, width: 800, height: 600 }) + + const firstRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, firstRenderer) + let secondRequest: Promise | null = null + let coalescedRequest: Promise | null = null + try { + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(firstView.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', expect.anything()) + + service.close() + await service.open(parent, 'https://second.example.com', { x: 0, y: 0, width: 900, height: 700 }) + secondRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, secondRenderer) + + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(secondView.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', expect.anything()) + + resolveFirstCapture({ data: 'FIRST' }) + await firstRequest + + coalescedRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, coalescedRenderer) + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(secondView.webContents.debugger.sendCommand).toHaveBeenCalledTimes(2) + + resolveSecondCapture({ data: 'SECOND' }) + await Promise.all([secondRequest, coalescedRequest]) + + expect(secondRenderer.sent.at(-1)).toMatchObject({ + payload: { type: 'screenshot', dataUrl: 'data:image/png;base64,SECOND', kind: 'full' }, + }) + expect(coalescedRenderer.sent.at(-1)).toEqual(secondRenderer.sent.at(-1)) + } finally { + resolveFirstCapture({ data: 'FIRST' }) + resolveSecondCapture({ data: 'SECOND' }) + await Promise.allSettled([ + firstRequest, + ...(secondRequest ? [secondRequest] : []), + ...(coalescedRequest ? [coalescedRequest] : []), + ]) + } + }) + + it.each([ + ['edge', { width: 16_385, height: 100 }], + ['pixel count', { width: 8_001, height: 4_000 }], + ])('rejects full captures that exceed the %s safety limit', async (_limit, documentSize) => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = documentSize + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.sendCommand).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(renderer.sent.at(-1)).toMatchObject({ + payload: { type: 'error', message: expect.stringContaining('exceeds safety limit') }, + }) + }) + it('applies preview zoom to the native WebContentsView before screenshot capture', async () => { const view = new FakeView() const renderer = new FakeWebContents() @@ -275,10 +548,10 @@ describe('Electron preview service', () => { expect(view.webContents.zoomFactors.at(-1)).toBe(0.8) expect(view.bounds).toHaveLength(1) - expect(view.webContents.capturePage).toHaveBeenCalledTimes(1) + expect(view.webContents.capturePage).not.toHaveBeenCalled() expect(renderer.sent.at(-1)).toEqual({ channel: ELECTRON_EVENT_CHANNELS.previewEvent, - payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' }, + payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, }) }) diff --git a/desktop/electron/services/preview.ts b/desktop/electron/services/preview.ts index cde4a17d..dcd3c007 100644 --- a/desktop/electron/services/preview.ts +++ b/desktop/electron/services/preview.ts @@ -11,13 +11,26 @@ export type PreviewBounds = { height: number } +type PreviewCaptureRect = PreviewBounds + +type PreviewDebuggerLike = { + isAttached(): boolean + attach(protocolVersion?: string): void + detach(): void + sendCommand(method: string, commandParams?: Record): Promise +} + +const FULL_CAPTURE_MAX_EDGE = 16_384 +const FULL_CAPTURE_MAX_PIXELS = 32_000_000 + export type PreviewWebContentsLike = { loadURL(url: string): Promise executeJavaScript(script: string): Promise on(event: 'did-finish-load', handler: () => void): unknown close?(): void isDestroyed?(): boolean - capturePage?(): Promise<{ toDataURL(): string }> + capturePage?(rect?: PreviewCaptureRect): Promise<{ toDataURL(): string }> + debugger?: PreviewDebuggerLike setZoomFactor?(factor: number): void send(channel: string, payload: unknown): void } @@ -121,6 +134,10 @@ export class ElectronPreviewService { private parent: PreviewParentWindowLike | null = null private requestedBounds: PreviewBounds | null = null private zoomFactor = 1 + private fullCapture: { + webContents: PreviewWebContentsLike + promise: Promise + } | null = null constructor(options: ElectronPreviewServiceOptions) { this.createView = options.createView @@ -217,13 +234,81 @@ export class ElectronPreviewService { await view.webContents.executeJavaScript(script) } - private async captureNativeDataUrl(): Promise { + private async captureNativeDataUrl(kind: PreviewHostCaptureMessage['kind'] = 'viewport'): Promise { const webContents = this.requireView().webContents + if (kind === 'full') return this.captureFullPageDataUrl(webContents) if (!webContents.capturePage) throw new Error('native preview capture unavailable') const image = await webContents.capturePage() return image.toDataURL() } + private async captureFullPageDataUrl(webContents: PreviewWebContentsLike): Promise { + if (this.fullCapture?.webContents === webContents) { + return await this.fullCapture.promise + } + + const promise = this.captureFullPageDataUrlOnce(webContents) + const capture = { webContents, promise } + this.fullCapture = capture + try { + return await promise + } finally { + if (this.fullCapture === capture) this.fullCapture = null + } + } + + private async captureFullPageDataUrlOnce(webContents: PreviewWebContentsLike): Promise { + const debuggerApi = webContents.debugger + if (!debuggerApi) throw new Error('full preview capture unavailable') + + let attachedHere = false + try { + if (!debuggerApi.isAttached()) { + debuggerApi.attach('1.3') + attachedHere = true + } + + const metrics = await debuggerApi.sendCommand('Page.getLayoutMetrics') + if (!isPlainRecord(metrics)) throw new Error('invalid full preview layout metrics') + const contentSize = isPlainRecord(metrics.cssContentSize) + ? metrics.cssContentSize + : metrics.contentSize + if (!isPlainRecord(contentSize)) throw new Error('invalid full preview layout metrics') + + const width = Math.ceil(Number(contentSize.width)) + const height = Math.ceil(Number(contentSize.height)) + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error('invalid full preview dimensions') + } + if ( + width > FULL_CAPTURE_MAX_EDGE || + height > FULL_CAPTURE_MAX_EDGE || + width * height > FULL_CAPTURE_MAX_PIXELS + ) { + throw new Error(`full preview capture exceeds safety limit: ${width}x${height}`) + } + + const screenshot = await debuggerApi.sendCommand('Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: true, + clip: { x: 0, y: 0, width, height, scale: 1 }, + }) + if (!isPlainRecord(screenshot) || typeof screenshot.data !== 'string' || !screenshot.data) { + throw new Error('invalid full preview screenshot data') + } + return `data:image/png;base64,${screenshot.data}` + } finally { + if (attachedHere) { + try { + if (debuggerApi.isAttached()) debuggerApi.detach() + } catch { + // The page may close while a full-page capture is in flight. + } + } + } + } + private applyZoomFactor(view: PreviewViewLike | null): void { view?.webContents.setZoomFactor?.(this.zoomFactor) } @@ -239,7 +324,7 @@ export class ElectronPreviewService { renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, { v: 1, type: 'screenshot', - dataUrl: await this.captureNativeDataUrl(), + dataUrl: await this.captureNativeDataUrl(kind), kind, }) } catch (error) { @@ -262,7 +347,7 @@ export class ElectronPreviewService { screenshot: { ...screenshot, kind: screenshot.kind ?? 'region', - dataUrl: await this.captureNativeDataUrl(), + dataUrl: await this.captureNativeDataUrl('viewport'), }, }, } diff --git a/desktop/electron/services/previewSession.ts b/desktop/electron/services/previewSession.ts new file mode 100644 index 00000000..2803f813 --- /dev/null +++ b/desktop/electron/services/previewSession.ts @@ -0,0 +1,12 @@ +import type { Session } from 'electron' + +export const PREVIEW_SESSION_PARTITION = 'cc-haha-preview' + +export function configurePreviewSessionPermissions( + session: Pick, +): void { + session.setPermissionCheckHandler(() => false) + session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false) + }) +} diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts new file mode 100644 index 00000000..9ebf72af --- /dev/null +++ b/desktop/electron/services/serverRuntime.test.ts @@ -0,0 +1,334 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SidecarChild, SidecarPlan } from './sidecarManager' +import { ElectronServerRuntime } from './serverRuntime' + +const sidecarMocks = { + nextPort: 49321, + spawnError: null as Error | null, + serverChildren: [] as FakeSidecarChild[], + adapterChildren: [] as FakeSidecarChild[], + serverPlans: [] as SidecarPlan[], + appendHostDiagnostic: vi.fn(), + waitForServerImpl: () => Promise.resolve(), + onAdapterSpawn: null as (() => void) | null, + spawnSidecar: vi.fn((plan: SidecarPlan) => { + if (plan.args[0] === 'server' && sidecarMocks.spawnError) throw sidecarMocks.spawnError + const child = new FakeSidecarChild() + if (plan.args[0] === 'server') { + sidecarMocks.serverChildren.push(child) + sidecarMocks.serverPlans.push(plan) + } else { + sidecarMocks.adapterChildren.push(child) + sidecarMocks.onAdapterSpawn?.() + } + return child as unknown as SidecarChild + }), +} + +let isolatedConfigDir = '' + +class FakeSidecarChild extends EventEmitter { + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + readonly kill = vi.fn() +} + +function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } = {}) { + return new ElectronServerRuntime({ + desktopRoot: '/isolated/desktop', + appRoot: options.appRoot, + diagnosticsFile: options.diagnosticsFile, + env: { CLAUDE_CONFIG_DIR: isolatedConfigDir }, + deps: { + appendHostDiagnostic: sidecarMocks.appendHostDiagnostic, + preferredServerPorts: () => [], + reserveServerPort: async () => sidecarMocks.nextPort++, + spawnSidecar: sidecarMocks.spawnSidecar, + waitForServer: async () => await sidecarMocks.waitForServerImpl(), + writeLastServerPort: () => undefined, + }, + }) +} + +async function waitForServerChildren(count: number): Promise { + for (let attempt = 0; attempt < 20 && sidecarMocks.serverChildren.length !== count; attempt++) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + expect(sidecarMocks.serverChildren).toHaveLength(count) +} + +describe('ElectronServerRuntime', () => { + beforeEach(() => { + isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-')) + sidecarMocks.nextPort = 49321 + sidecarMocks.spawnError = null + sidecarMocks.serverChildren.length = 0 + sidecarMocks.adapterChildren.length = 0 + sidecarMocks.serverPlans.length = 0 + sidecarMocks.appendHostDiagnostic.mockClear() + sidecarMocks.waitForServerImpl = () => Promise.resolve() + sidecarMocks.onAdapterSpawn = null + sidecarMocks.spawnSidecar.mockClear() + }) + + afterEach(() => { + rmSync(isolatedConfigDir, { recursive: true, force: true }) + }) + + it('restarts after the active healthy server exits and ignores its late exit', async () => { + const runtime = createRuntime({ + appRoot: '/isolated/app', + }) + + const firstUrl = await runtime.getServerUrl() + const firstChild = sidecarMocks.serverChildren[0]! + const firstAdapters = [...sidecarMocks.adapterChildren] + expect(firstAdapters).toHaveLength(5) + firstChild.emit('exit', 7, null) + + const [secondUrl, coalescedUrl] = await Promise.all([ + runtime.getServerUrl(), + runtime.getServerUrl(), + ]) + const secondChild = sidecarMocks.serverChildren[1]! + firstChild.emit('exit', 9, 'SIGTERM') + + expect(firstUrl).toBe('http://127.0.0.1:49321') + expect(secondUrl).toBe('http://127.0.0.1:49322') + expect(coalescedUrl).toBe(secondUrl) + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(10) + for (const adapter of firstAdapters) expect(adapter.kill).toHaveBeenCalledTimes(1) + for (const adapter of sidecarMocks.adapterChildren.slice(5)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + expect(await runtime.getServerUrl()).toBe(secondUrl) + expect(secondChild).toBeDefined() + }) + + it('passes the isolated Electron host diagnostics file to the server sidecar', async () => { + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + await runtime.startServer() + + expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_ELECTRON_DIAGNOSTICS_FILE) + .toBe('/isolated/user-data/diagnostics/electron-host.log') + expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR).toBe(isolatedConfigDir) + expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR) + .not.toBe(path.join(homedir(), '.claude')) + }) + + it('persists a server startup failure through the sanitized host-log boundary', async () => { + sidecarMocks.spawnError = new Error('spawn failed') + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + await expect(runtime.startServer()).rejects.toThrow('spawn failed') + + expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith( + '/isolated/user-data/diagnostics/electron-host.log', + expect.stringContaining('[startup-error] spawn failed'), + ) + }) + + it('rejects an in-flight start when the child exits before health publication', async () => { + sidecarMocks.waitForServerImpl = () => new Promise(() => undefined) + const runtime = createRuntime() + + const starting = runtime.startServer() + await waitForServerChildren(1) + sidecarMocks.serverChildren[0]!.emit('exit', 17, null) + + await expect(starting).rejects.toThrow('code=17, signal=null') + sidecarMocks.waitForServerImpl = () => Promise.resolve() + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + }) + + it('kills the attempted server child when the health wait rejects', async () => { + sidecarMocks.waitForServerImpl = () => Promise.reject(new Error('health wait timed out')) + const runtime = createRuntime() + + await expect(runtime.startServer()).rejects.toThrow('health wait timed out') + + expect(sidecarMocks.serverChildren).toHaveLength(1) + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + expect(sidecarMocks.adapterChildren).toHaveLength(0) + }) + + it('kills an unpublished server exactly once when stopAll runs during health wait', async () => { + let releaseHealth!: () => void + sidecarMocks.waitForServerImpl = () => new Promise(resolve => { + releaseHealth = resolve + }) + const runtime = createRuntime() + + const starting = runtime.startServer() + await waitForServerChildren(1) + runtime.stopAll(true) + + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + await expect(starting).rejects.toThrow('stopped') + releaseHealth() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(sidecarMocks.serverChildren).toHaveLength(1) + expect(sidecarMocks.adapterChildren).toHaveLength(0) + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + }) + + it('stops active adapters immediately when the server exits without restart demand', async () => { + const runtime = createRuntime() + await runtime.startServer() + const activeAdapters = [...sidecarMocks.adapterChildren] + + sidecarMocks.serverChildren[0]!.emit('exit', 19, null) + + for (const adapter of activeAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + expect(sidecarMocks.serverChildren).toHaveLength(1) + }) + + it('stops active adapters immediately when the server emits a process error', async () => { + const runtime = createRuntime() + await runtime.startServer() + const activeAdapters = [...sidecarMocks.adapterChildren] + + sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed')) + + for (const adapter of activeAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + }) + + it('does not let a stale server exit stop replacement adapters', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstServer = sidecarMocks.serverChildren[0]! + firstServer.emit('exit', 20, null) + await runtime.getServerUrl() + const replacementAdapters = sidecarMocks.adapterChildren.slice(5) + + firstServer.emit('exit', 21, 'SIGTERM') + + expect(replacementAdapters).toHaveLength(5) + for (const adapter of replacementAdapters) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('stops the current adapter generation after an explicit adapter restart', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstAdapters = [...sidecarMocks.adapterChildren] + + await runtime.restartAdaptersSidecars() + const restartedAdapters = sidecarMocks.adapterChildren.slice(5) + sidecarMocks.serverChildren[0]!.emit('exit', 22, null) + + for (const adapter of firstAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + for (const adapter of restartedAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + }) + + it('coalesces overlapping manual adapter restarts into one live generation', async () => { + const runtime = createRuntime() + await runtime.startServer() + const originalAdapters = [...sidecarMocks.adapterChildren] + + const firstRestart = runtime.restartAdaptersSidecars() + const secondRestart = runtime.restartAdaptersSidecars() + + expect(secondRestart).toBe(firstRestart) + await Promise.all([firstRestart, secondRestart]) + expect(sidecarMocks.adapterChildren).toHaveLength(10) + for (const adapter of originalAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + for (const adapter of sidecarMocks.adapterChildren.slice(5)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('cancels a manual adapter restart when its server exits after the first spawn', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstServer = sidecarMocks.serverChildren[0]! + const originalAdapters = [...sidecarMocks.adapterChildren] + sidecarMocks.onAdapterSpawn = () => { + sidecarMocks.onAdapterSpawn = null + firstServer.emit('exit', 23, null) + } + + await runtime.restartAdaptersSidecars() + + expect(sidecarMocks.adapterChildren).toHaveLength(6) + for (const adapter of originalAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + expect(sidecarMocks.adapterChildren[5]!.kill).toHaveBeenCalledTimes(1) + + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(11) + for (const adapter of sidecarMocks.adapterChildren.slice(6)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('rejects when the published child exits during adapter startup', async () => { + const runtime = createRuntime() + sidecarMocks.onAdapterSpawn = () => { + sidecarMocks.onAdapterSpawn = null + sidecarMocks.serverChildren[0]!.emit('exit', 18, 'SIGTERM') + } + + await expect(runtime.startServer()).rejects.toThrow('code=18, signal=SIGTERM') + + expect(sidecarMocks.adapterChildren).toHaveLength(1) + expect(sidecarMocks.adapterChildren[0]!.kill).toHaveBeenCalledTimes(1) + + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(6) + for (const adapter of sidecarMocks.adapterChildren.slice(1)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('handles an asynchronous child process error without crashing Electron', async () => { + sidecarMocks.waitForServerImpl = () => new Promise(() => undefined) + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + const starting = runtime.startServer() + await waitForServerChildren(1) + expect(() => sidecarMocks.serverChildren[0]!.emit( + 'error', + new Error('spawn error OPENAI_API_KEY=unsafe-value'), + )).not.toThrow() + + const rejection = await starting.then( + () => null, + error => error as Error, + ) + expect(rejection?.message).toContain('spawn error') + expect(rejection?.message).not.toContain('unsafe-value') + expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith( + '/isolated/user-data/diagnostics/electron-host.log', + expect.stringContaining('[process-error] sidecar process error: spawn error'), + ) + }) +}) diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index ba4af210..db2dc793 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -1,7 +1,9 @@ import path from 'node:path' import { + appendHostDiagnostic, createAdapterPlan, createServerPlan, + ELECTRON_DIAGNOSTICS_FILE_ENV, formatStartupError, killSidecar, mergeProxyEnv, @@ -10,6 +12,7 @@ import { proxyUrlFromElectronProxyRules, pushStartupLog, reserveServerPort, + sanitizeHostDiagnostic, SERVER_BIND_HOST, SERVER_CONTROL_HOST, SERVER_STARTUP_TIMEOUT_MS, @@ -25,24 +28,91 @@ type ServerRuntimeOptions = { desktopRoot: string appRoot?: string h5DistDir?: string + diagnosticsFile?: string + env?: NodeJS.ProcessEnv + deps?: Partial resolveSystemProxy?: (url: string) => Promise } +type ServerRuntimeDeps = { + appendHostDiagnostic: typeof appendHostDiagnostic + preferredServerPorts: typeof preferredServerPorts + reserveServerPort: typeof reserveServerPort + spawnSidecar: typeof spawnSidecar + waitForServer: typeof waitForServer + writeLastServerPort: typeof writeLastServerPort +} + +const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = { + appendHostDiagnostic, + preferredServerPorts, + reserveServerPort, + spawnSidecar, + waitForServer, + writeLastServerPort, +} + +type ServerStartState = { + child: SidecarChild + adapterChildren: SidecarChild[] + childStopped: boolean + readonly failure: Error | null + failurePromise: Promise + fail: (error: Error) => void +} + +type ActiveServer = { + url: string + child: SidecarChild + adapterChildren: SidecarChild[] +} + +function createServerStartState(child: SidecarChild): ServerStartState { + let failure: Error | null = null + let rejectFailure!: (error: Error) => void + const failurePromise = new Promise((_resolve, reject) => { + rejectFailure = reject + }) + return { + child, + adapterChildren: [], + childStopped: false, + get failure() { + return failure + }, + failurePromise, + fail(error) { + if (failure) return + failure = error + rejectFailure(error) + }, + } +} + export class ElectronServerRuntime { private readonly desktopRoot: string private readonly appRoot: string private readonly h5DistDir: string + private readonly diagnosticsFile?: string + private readonly baseEnv: NodeJS.ProcessEnv + private readonly deps: ServerRuntimeDeps private readonly resolveSystemProxy?: (url: string) => Promise private sidecarEnvPromise: Promise | null = null - private server: { url: string, child: SidecarChild } | null = null + private server: ActiveServer | null = null private adapters: SidecarChild[] = [] private startupError: string | null = null + private restartAfterExit = false private startPromise: Promise | null = null + private startingServer: ServerStartState | null = null + private adapterRestartPromise: Promise | null = null constructor(options: ServerRuntimeOptions) { this.desktopRoot = options.desktopRoot this.appRoot = options.appRoot ?? options.desktopRoot this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist') + this.diagnosticsFile = options.diagnosticsFile + this.baseEnv = options.env ?? process.env + this.deps = { ...DEFAULT_SERVER_RUNTIME_DEPS, ...options.deps } this.resolveSystemProxy = options.resolveSystemProxy } @@ -50,6 +120,7 @@ export class ElectronServerRuntime { if (this.server) return this.server.url if (this.startPromise) return this.startPromise + this.restartAfterExit = false this.startPromise = this.startServerOnce() try { return await this.startPromise @@ -60,17 +131,41 @@ export class ElectronServerRuntime { async getServerUrl(): Promise { if (this.server) return this.server.url - if (this.startupError) throw new Error(this.startupError) + if (this.startPromise) return await this.startServer() + if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError) return await this.startServer() } - async restartAdaptersSidecars(): Promise { - this.stopAdaptersSidecars() + restartAdaptersSidecars(): Promise { + if (this.adapterRestartPromise) return this.adapterRestartPromise + const operation = this.restartAdaptersSidecarsOnce() + const tracked = operation.finally(() => { + if (this.adapterRestartPromise === tracked) this.adapterRestartPromise = null + }) + this.adapterRestartPromise = tracked + return tracked + } + + private async restartAdaptersSidecarsOnce(): Promise { const serverUrl = await this.getServerUrl() - await this.startAdaptersSidecars(serverUrl) + const server = this.server + if (!server || server.url !== serverUrl) return + this.stopAdapterChildren(server.adapterChildren) + await this.startAdaptersSidecars(serverUrl, undefined, server) } stopAll(sync = false) { + const starting = this.startingServer + if (starting) { + this.startingServer = null + this.stopAdaptersForStart(starting, sync) + if (this.server?.child === starting.child) this.server = null + starting.fail(new Error('server startup stopped')) + if (!starting.childStopped) { + starting.childStopped = true + killSidecar(starting.child, sync) + } + } this.stopAdaptersSidecars(sync) if (this.server) { killSidecar(this.server.child, sync) @@ -81,36 +176,84 @@ export class ElectronServerRuntime { private async startServerOnce(): Promise { // Prefer the configured fixed port, then the previous run's port, so // phone bookmarks / QR codes / reverse proxies survive restarts (#767). - const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts()) + const port = await this.deps.reserveServerPort( + SERVER_BIND_HOST, + this.deps.preferredServerPorts(this.baseEnv), + ) const url = `http://${SERVER_CONTROL_HOST}:${port}` const logs: string[] = [] + let startState: ServerStartState | null = null const env = await this.resolveSidecarBaseEnv() const plan = createServerPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, port, h5DistDir: this.h5DistDir, - env, + env: this.diagnosticsFile + ? { ...env, [ELECTRON_DIAGNOSTICS_FILE_ENV]: this.diagnosticsFile } + : env, }) try { - const child = spawnSidecar(plan) - this.captureLogs(child, 'claude-server', logs) - await waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS) - writeLastServerPort(port) - this.server = { url, child } + const child = this.deps.spawnSidecar(plan) + startState = createServerStartState(child) + this.startingServer = startState + this.captureLogs(child, 'claude-server', logs, (code, signal) => { + this.handleServerExit(child, code, signal, logs) + }, error => { + this.handleServerError(child, error, logs) + }) + await Promise.race([ + this.deps.waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS), + startState.failurePromise, + ]) + if (startState.failure) throw startState.failure + this.deps.writeLastServerPort(port, this.baseEnv) + this.server = { url, child, adapterChildren: startState.adapterChildren } + const activeServer = this.server this.startupError = null - await this.startAdaptersSidecars(url) + this.stopAdaptersSidecars() + await Promise.race([ + this.startAdaptersSidecars(url, startState, activeServer), + startState.failurePromise, + ]) + if (startState.failure) throw startState.failure return url } catch (error) { + if (startState) { + this.stopAdaptersForStart(startState) + if (this.server?.child === startState.child) this.server = null + if (!startState.childStopped) { + startState.childStopped = true + killSidecar(startState.child) + } + } + if (startState?.failure) { + throw new Error(this.startupError ?? startState.failure.message) + } const message = error instanceof Error ? error.message : String(error) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[claude-server] [startup-error] ${message}`) this.startupError = formatStartupError(message, logs) throw new Error(this.startupError) + } finally { + if (this.startingServer === startState) this.startingServer = null } } - private async startAdaptersSidecars(serverUrl: string): Promise { + private async startAdaptersSidecars( + serverUrl: string, + startState?: ServerStartState, + activeServer?: ActiveServer, + ): Promise { const env = await this.resolveSidecarBaseEnv() + const isCurrentGeneration = () => { + if (startState?.failure) return false + if (activeServer && this.server !== activeServer) return false + return true + } + if (!isCurrentGeneration()) return + const ownedAdapters = startState?.adapterChildren + ?? activeServer?.adapterChildren for (const [label, flag] of [ ['feishu', '--feishu'], ['telegram', '--telegram'], @@ -118,8 +261,9 @@ export class ElectronServerRuntime { ['dingtalk', '--dingtalk'], ['whatsapp', '--whatsapp'], ] as const) { + if (!isCurrentGeneration()) break try { - const child = spawnSidecar(createAdapterPlan({ + const child = this.deps.spawnSidecar(createAdapterPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, h5DistDir: this.h5DistDir, @@ -127,8 +271,13 @@ export class ElectronServerRuntime { flag, env, })) + if (!isCurrentGeneration()) { + killSidecar(child) + break + } this.captureLogs(child, `claude-adapters:${label}`) this.adapters.push(child) + ownedAdapters?.push(child) } catch (error) { console.error(`[desktop] failed to start ${label} adapter sidecar`, error) } @@ -136,29 +285,104 @@ export class ElectronServerRuntime { } private stopAdaptersSidecars(sync = false) { - for (const child of this.adapters.splice(0)) { + const children = this.adapters.splice(0) + this.removeOwnedAdapters(this.server?.adapterChildren, children) + this.removeOwnedAdapters(this.startingServer?.adapterChildren, children) + for (const child of children) { killSidecar(child, sync) } } - private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) { + private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) { + if (!owned?.length || !removed.length) return + const removedSet = new Set(removed) + const retained = owned.filter(child => !removedSet.has(child)) + owned.splice(0, owned.length, ...retained) + } + + private stopAdaptersForStart(startState: ServerStartState, sync = false) { + this.stopAdapterChildren(startState.adapterChildren, sync) + } + + private captureLogs( + child: SidecarChild, + label: string, + startupLogs?: string[], + onExit?: (code: number | null, signal: NodeJS.Signals | null) => void, + onError?: (error: Error) => void, + ) { child.stdout.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.log(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stdout] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`) }) child.stderr.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.error(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stderr] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`) }) child.on('exit', (code, signal) => { const line = `sidecar exited (code=${code}, signal=${signal})` console.log(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [exit] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`) + onExit?.(code, signal) }) + child.on('error', error => { + const message = error instanceof Error ? error.message : String(error) + const line = `sidecar process error: ${message}` + console.error(`[${label}] ${sanitizeHostDiagnostic(line)}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [process-error] ${line}`) + if (startupLogs) pushStartupLog(startupLogs, `[process-error] ${line}`) + onError?.(error instanceof Error ? error : new Error(message)) + }) + } + + private handleServerExit( + child: SidecarChild, + code: number | null, + signal: NodeJS.Signals | null, + logs: string[], + ) { + this.handleServerFailure( + child, + `server sidecar exited after spawn (code=${code}, signal=${signal})`, + logs, + ) + } + + private handleServerError(child: SidecarChild, error: Error, logs: string[]) { + this.handleServerFailure( + child, + `server sidecar process error after spawn: ${sanitizeHostDiagnostic(error.message)}`, + logs, + ) + } + + private handleServerFailure(child: SidecarChild, message: string, logs: string[]) { + const active = this.server?.child === child + const starting = this.startingServer?.child === child + if (!active && !starting) return + if (active) { + const adapterChildren = this.server!.adapterChildren + this.server = null + this.stopAdapterChildren(adapterChildren) + } + this.restartAfterExit = true + this.startupError = formatStartupError(message, logs) + if (starting) this.startingServer?.fail(new Error(message)) + } + + private stopAdapterChildren(children: SidecarChild[], sync = false) { + for (const child of children.splice(0)) { + const index = this.adapters.indexOf(child) + if (index >= 0) this.adapters.splice(index, 1) + killSidecar(child, sync) + } } private async resolveSidecarBaseEnv(): Promise { @@ -167,17 +391,17 @@ export class ElectronServerRuntime { } private async resolveSidecarBaseEnvOnce(): Promise { - if (!this.resolveSystemProxy) return this.applyPowerShellOverride(process.env) + if (!this.resolveSystemProxy) return this.applyPowerShellOverride(this.baseEnv) try { const rules = await this.resolveSystemProxy('https://auth.openai.com/') return this.applyPowerShellOverride(mergeProxyEnv( - process.env, + this.baseEnv, proxyUrlFromElectronProxyRules(rules), )) } catch (error) { console.error('[desktop] failed to resolve system proxy for sidecars', error) - return this.applyPowerShellOverride(process.env) + return this.applyPowerShellOverride(this.baseEnv) } } diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index f763ee1f..ba86a6bd 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -2,13 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import net from 'node:net' import http from 'node:http' import path from 'node:path' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' import { + appendHostDiagnostic, buildSidecarEnv, createAdapterPlan, createServerPlan, + electronHostDiagnosticsFile, httpToWebSocketUrl, + HOST_DIAGNOSTICS_BYTE_LIMIT, + HOST_DIAGNOSTICS_LINE_LIMIT, killSidecar, mergeProxyEnv, parseH5FixedPort, @@ -20,6 +24,7 @@ import { reserveLocalPort, reserveServerPort, resolveHostTriple, + SERVER_STATE_FILE, spawnSidecar, waitForServer, windowsPowerShellOverride, @@ -50,6 +55,23 @@ function close(server: http.Server): Promise { } describe('Electron sidecar manager', () => { + it('places the Electron host log in the active server diagnostics directory', () => { + const portableDir = path.join(tmpdir(), 'cc-haha-portable-diagnostics') + + expect(electronHostDiagnosticsFile( + { CLAUDE_CONFIG_DIR: portableDir }, + path.join(tmpdir(), 'unused-home'), + )).toBe(path.join(portableDir, 'cc-haha', 'diagnostics', 'electron-host.log')) + }) + + it('resolves the default Electron host log without consulting real user state', () => { + const isolatedHome = path.resolve(path.sep, '__cc_haha_injected_test_home__') + + expect(electronHostDiagnosticsFile({}, isolatedHome)).toBe( + path.join(isolatedHome, '.claude', 'cc-haha', 'diagnostics', 'electron-host.log'), + ) + }) + it('maps host platform to existing sidecar target triples', () => { expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin') expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin') @@ -159,6 +181,95 @@ describe('Electron sidecar manager', () => { expect(logs[0]).toBe('line 5') }) + it('sanitizes the bounded startup tail before it reaches an error surface', () => { + const logs: string[] = [] + pushStartupLog( + logs, + `Bearer startup.secret sk-proj-STARTUPSECRETVALUE https://alice:password@example.com ${homedir()}/project`, + ) + + expect(logs[0]).toContain('Bearer [REDACTED]') + expect(logs[0]).toContain('https://[REDACTED]@example.com/') + expect(logs[0]).toContain('[HOME]/project') + expect(logs[0]).not.toContain('startup.secret') + expect(logs[0]).not.toContain('sk-proj-STARTUPSECRETVALUE') + expect(logs[0]).not.toContain(homedir()) + }) + + it('appends only a bounded sanitized Electron host-log tail', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-')) + const logPath = path.join(dir, 'electron-host.log') + const homeDir = path.join(dir, 'private-home') + try { + for (let index = 0; index < HOST_DIAGNOSTICS_LINE_LIMIT + 5; index++) { + appendHostDiagnostic(logPath, `line ${index}`, { homeDir }) + } + appendHostDiagnostic( + logPath, + `Authorization: Bearer bearer.secret api_key=sk-ant-api03-PRIVATE ANTHROPIC_API_KEY=anthropic-secret OPENAI_API_KEY="openai-secret" MINIMAX_AUTH_TOKEN='minimax-secret' https://alice:password@example.com/private ${homeDir}/project`, + { homeDir }, + ) + + const contents = readFileSync(logPath, 'utf-8') + const lines = contents.trimEnd().split('\n') + expect(contents).toContain('Bearer [REDACTED]') + expect(contents).toContain('api_key=[REDACTED]') + expect(contents).toContain('ANTHROPIC_API_KEY=[REDACTED]') + expect(contents).toContain('OPENAI_API_KEY=[REDACTED]') + expect(contents).toContain('MINIMAX_AUTH_TOKEN=[REDACTED]') + expect(contents).toContain('https://[REDACTED]@example.com/private') + expect(contents).toContain('[HOME]/project') + expect(contents).not.toContain('bearer.secret') + expect(contents).not.toContain('sk-ant-api03-PRIVATE') + expect(contents).not.toContain('anthropic-secret') + expect(contents).not.toContain('openai-secret') + expect(contents).not.toContain('minimax-secret') + expect(contents).not.toContain('alice:password') + expect(contents).not.toContain(homeDir) + expect(lines).toHaveLength(HOST_DIAGNOSTICS_LINE_LIMIT) + expect(lines[0]).toBe('line 6') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('bounds and re-sanitizes an oversized pre-existing host diagnostics file', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-existing-')) + const logPath = path.join(dir, 'electron-host.log') + const homeDir = path.join(dir, 'private-home') + try { + writeFileSync( + logPath, + `${'oversized-old-data '.repeat(HOST_DIAGNOSTICS_BYTE_LIMIT)}\nOPENAI_API_KEY=old-secret ${homeDir}/private\n`, + 'utf-8', + ) + + appendHostDiagnostic(logPath, 'latest safe diagnostic', { homeDir }) + + const contents = readFileSync(logPath, 'utf-8') + expect(statSync(logPath).size).toBeLessThanOrEqual(HOST_DIAGNOSTICS_BYTE_LIMIT) + expect(contents.trimEnd().split('\n').length).toBeLessThanOrEqual(HOST_DIAGNOSTICS_LINE_LIMIT) + expect(contents).toContain('latest safe diagnostic') + expect(contents).toContain('OPENAI_API_KEY=[REDACTED]') + expect(contents).not.toContain('old-secret') + expect(contents).not.toContain(homeDir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not crash Electron when the host diagnostics destination cannot be written', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-failure-')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + expect(() => appendHostDiagnostic(dir, 'sidecar failed')).not.toThrow() + expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics') + } finally { + errorSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('maps http urls to adapter websocket urls', () => { expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456') expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com') @@ -225,8 +336,15 @@ describe('Electron sidecar manager', () => { expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull() }) - it('parses only in-range integer h5Access.fixedPort values', () => { + it('parses only browser-safe in-range integer h5Access.fixedPort values', () => { expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670) + for (const port of [ + 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, + 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, + ]) { + expect(parseH5FixedPort(`{"h5Access":{"fixedPort":${port}}}`)).toBeNull() + } + expect(parseH5FixedPort('{"h5Access":{"fixedPort":5062}}')).toBe(5062) expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull() expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull() @@ -243,6 +361,15 @@ describe('Electron sidecar manager', () => { // Nothing stored yet: no preferred ports. expect(preferredServerPorts(env)).toEqual([]) + // A browser-blocked port persisted by an older build is ignored. + writeFileSync( + path.join(configDir, SERVER_STATE_FILE), + JSON.stringify({ lastPort: 5061 }), + 'utf-8', + ) + expect(readLastServerPort(env)).toBeNull() + expect(preferredServerPorts(env)).toEqual([]) + // Sticky port from the previous run. writeLastServerPort(50123, env) expect(readLastServerPort(env)).toBe(50123) @@ -288,6 +415,41 @@ describe('Electron sidecar manager', () => { await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0) }) + it('skips preferred ports blocked by browser fetch', async () => { + const port = await reserveServerPort('127.0.0.1', [5061]) + expect(port).not.toBe(5061) + }) + + it('retries when the OS assigns a browser-blocked random port', async () => { + const reserveCandidate = vi.fn() + .mockResolvedValueOnce(5061) + .mockResolvedValueOnce(5062) + + await expect(reserveLocalPort('127.0.0.1', { reserveCandidate })).resolves.toBe(5062) + expect(reserveCandidate).toHaveBeenCalledTimes(2) + }) + + it('stops retrying after repeated browser-blocked random ports', async () => { + const reserveCandidate = vi.fn().mockResolvedValue(5061) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + try { + await expect(reserveLocalPort('127.0.0.1', { reserveCandidate })) + .rejects.toThrow('Could not reserve a browser-safe local port') + expect(reserveCandidate).toHaveBeenCalledTimes(128) + } finally { + errorSpy.mockRestore() + } + }) + + it('propagates random port reservation errors', async () => { + const reserveCandidate = vi.fn().mockRejectedValue(new Error('bind failed')) + + await expect(reserveLocalPort('127.0.0.1', { reserveCandidate })) + .rejects.toThrow('bind failed') + expect(reserveCandidate).toHaveBeenCalledTimes(1) + }) + it('does not treat a raw TCP accept as server readiness without healthy /health', async () => { const server = http.createServer((_request, response) => { response.writeHead(503, { 'content-type': 'application/json' }) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 4bcc3afe..dd5cb9d5 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -1,20 +1,37 @@ import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process' -import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' import type { Readable } from 'node:stream' import net from 'node:net' import os from 'node:os' import path from 'node:path' +import { isBrowserSafePort } from '../../src/lib/browserSafePort' export const SERVER_BIND_HOST = '0.0.0.0' export const SERVER_CONTROL_HOST = '127.0.0.1' export const SERVER_STARTUP_TIMEOUT_MS = 30_000 export const SERVER_STARTUP_LOG_LIMIT = 80 +export const HOST_DIAGNOSTICS_LINE_LIMIT = 80 +export const HOST_DIAGNOSTICS_BYTE_LIMIT = 256 * 1024 +export const ELECTRON_DIAGNOSTICS_FILE_ENV = 'CC_HAHA_ELECTRON_DIAGNOSTICS_FILE' +const HOST_DIAGNOSTICS_LINE_BYTE_LIMIT = 4096 // Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds // reuse the same sticky port across restarts (issue #767). export const SERVER_STATE_FILE = 'desktop-server-state.json' // Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT). const MIN_FIXED_PORT = 1024 const MAX_FIXED_PORT = 65535 +const MAX_PORT_RESERVATION_ATTEMPTS = 128 export type SidecarChild = ChildProcessByStdio @@ -58,7 +75,11 @@ export function httpToWebSocketUrl(serverHttpUrl: string): string { return serverHttpUrl } -export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise { +export type ReserveLocalPortDeps = { + reserveCandidate?: (bindHost: string) => Promise +} + +async function reserveLocalPortCandidate(bindHost: string): Promise { return await new Promise((resolve, reject) => { const server = net.createServer() server.once('error', error => reject(error)) @@ -75,6 +96,19 @@ export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise { + const reserveCandidate = deps.reserveCandidate ?? reserveLocalPortCandidate + for (let attempt = 0; attempt < MAX_PORT_RESERVATION_ATTEMPTS; attempt++) { + const port = await reserveCandidate(bindHost) + if (isBrowserSafePort(port)) return port + console.error(`[desktop] OS assigned browser-blocked server port ${port}; retrying`) + } + throw new Error('Could not reserve a browser-safe local port') +} + function canBindPort(bindHost: string, port: number): Promise { return new Promise(resolve => { const server = net.createServer() @@ -95,15 +129,32 @@ export async function reserveServerPort( preferred: number[], ): Promise { for (const port of preferred) { - if (!Number.isInteger(port) || port <= 0 || port > 65535) continue + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error(`[desktop] preferred server port ${port} is invalid; skipping`) + continue + } + if (!isBrowserSafePort(port)) { + console.error(`[desktop] preferred server port ${port} is blocked by browser fetch; skipping`) + continue + } if (await canBindPort(bindHost, port)) return port console.error(`[desktop] preferred server port ${port} unavailable`) } return await reserveLocalPort(bindHost) } -export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string { - return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') +export function claudeConfigDir( + env: NodeJS.ProcessEnv = process.env, + homeDir = os.homedir(), +): string { + return env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude') +} + +export function electronHostDiagnosticsFile( + env: NodeJS.ProcessEnv = process.env, + homeDir = os.homedir(), +): string { + return path.join(claudeConfigDir(env, homeDir), 'cc-haha', 'diagnostics', 'electron-host.log') } /** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */ @@ -119,7 +170,7 @@ export function parseH5FixedPort(contents: string): number | null { if (!h5Access || typeof h5Access !== 'object') return null const port = (h5Access as Record).fixedPort if (typeof port !== 'number' || !Number.isInteger(port)) return null - return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT ? port : null + return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT && isBrowserSafePort(port) ? port : null } export function readH5FixedPort(env: NodeJS.ProcessEnv = process.env): number | null { @@ -138,7 +189,7 @@ export function readLastServerPort(env: NodeJS.ProcessEnv = process.env): number if (!state || typeof state !== 'object') return null const port = (state as Record).lastPort if (typeof port !== 'number' || !Number.isInteger(port)) return null - return port > 0 && port <= 65535 ? port : null + return isBrowserSafePort(port) ? port : null } catch { return null } @@ -212,12 +263,101 @@ function sleep(ms: number): Promise { } export function pushStartupLog(logs: string[], line: string) { - const trimmed = line.trimEnd() + const trimmed = sanitizeHostDiagnostic(line, os.homedir()) if (!trimmed) return if (logs.length >= SERVER_STARTUP_LOG_LIMIT) logs.shift() logs.push(trimmed) } +export function appendHostDiagnostic( + filePath: string | undefined, + line: string, + { homeDir = os.homedir() }: { homeDir?: string } = {}, +): void { + if (!filePath) return + const tempPath = `${filePath}.${process.pid}.tmp` + try { + const sanitized = sanitizeHostDiagnostic(line, homeDir) + if (!sanitized) return + const existing = readHostDiagnosticsTail(filePath) + const lines = existing.trimEnd() + ? existing.trimEnd().split('\n').map(entry => sanitizeHostDiagnostic(entry, homeDir)).filter(Boolean) + : [] + lines.push(sanitized) + const boundedLines: string[] = [] + let retainedBytes = 0 + for (const entry of lines.slice(-HOST_DIAGNOSTICS_LINE_LIMIT).reverse()) { + const entryBytes = Buffer.byteLength(entry, 'utf-8') + 1 + if (retainedBytes + entryBytes > HOST_DIAGNOSTICS_BYTE_LIMIT) break + boundedLines.unshift(entry) + retainedBytes += entryBytes + } + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(tempPath, `${boundedLines.join('\n')}\n`, { + encoding: 'utf-8', + mode: 0o600, + }) + renameSync(tempPath, filePath) + } catch { + try { + rmSync(tempPath, { force: true }) + } catch { + // Best-effort cleanup must not mask the original diagnostics failure. + } + console.error('[desktop] failed to persist Electron host diagnostics') + } +} + +function readHostDiagnosticsTail(filePath: string): string { + let descriptor: number | undefined + try { + descriptor = openSync(filePath, 'r') + const size = fstatSync(descriptor).size + const length = Math.min(size, HOST_DIAGNOSTICS_BYTE_LIMIT) + const buffer = Buffer.alloc(length) + const bytesRead = readSync(descriptor, buffer, 0, length, size - length) + const tail = buffer.subarray(0, bytesRead).toString('utf-8') + if (size <= length) return tail + const firstNewline = tail.indexOf('\n') + return firstNewline >= 0 ? tail.slice(firstNewline + 1) : '' + } catch { + return '' + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } +} + +export function sanitizeHostDiagnostic(line: string, homeDir = os.homedir()): string { + let sanitized = line + .replace(/[\r\n]+/g, ' ') + .replace(/https?:\/\/[^\s<>"')\]}]+/gi, candidate => sanitizeUrlUserinfo(candidate)) + .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]') + .replace( + /\b((?:(?:[a-z0-9]+_)*(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|password|secret))\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, + '$1[REDACTED]', + ) + .replace(/\b(?:sk-(?:ant-api03-|proj-)?|ghp_)[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]') + .trimEnd() + if (homeDir) sanitized = sanitized.replaceAll(homeDir, '[HOME]') + return truncateUtf8(sanitized, HOST_DIAGNOSTICS_LINE_BYTE_LIMIT) +} + +function truncateUtf8(value: string, maxBytes: number): string { + const buffer = Buffer.from(value, 'utf-8') + if (buffer.byteLength <= maxBytes) return value + return buffer.subarray(0, maxBytes).toString('utf-8').replace(/\uFFFD$/, '') +} + +function sanitizeUrlUserinfo(candidate: string): string { + try { + const url = new URL(candidate) + if (!url.username && !url.password) return candidate + return `${url.protocol}//[REDACTED]@${url.host}${url.pathname}${url.search}${url.hash}` + } catch { + return '[REDACTED_URL]' + } +} + export function formatStartupError(message: string, logs: string[]): string { const logText = logs.length > 0 ? logs.join('\n') diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts index ce385944..1350a096 100644 --- a/desktop/electron/services/terminal.test.ts +++ b/desktop/electron/services/terminal.test.ts @@ -71,11 +71,37 @@ afterEach(() => { }) describe('Electron terminal service', () => { - it('uses the portable terminal config path before app userData', () => { - const app = { getPath: vi.fn(() => '/app/user-data') } + it('uses the custom terminal config path before the standard ~/.claude path', () => { + const app = { getPath: vi.fn(() => '/Users/test') } expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json') - expect(terminalConfigPath(app, {})).toBe('/app/user-data/terminal-config.json') + expect(terminalConfigPath(app, {})).toBe('/Users/test/.claude/terminal-config.json') + }) + + it('reads an old userData terminal config but writes future changes to ~/.claude', () => { + const root = tempDir() + const home = path.join(root, 'home') + const userData = path.join(root, 'user-data') + const legacyBash = path.join(root, 'legacy-bash.exe') + const newBash = path.join(root, 'new-bash.exe') + fs.mkdirSync(userData, { recursive: true }) + fs.writeFileSync(legacyBash, '') + fs.writeFileSync(newBash, '') + fs.writeFileSync(path.join(userData, 'terminal-config.json'), JSON.stringify({ bash_path: legacyBash })) + const service = new ElectronTerminalService({ + app: { getPath: name => name === 'home' ? home : userData }, + env: {}, + isFile: filePath => filePath === legacyBash || filePath === newBash, + }) + + expect(service.getBashPath()).toBe(legacyBash) + service.setBashPath(newBash) + expect(JSON.parse(fs.readFileSync(path.join(home, '.claude', 'terminal-config.json'), 'utf8'))).toEqual({ + bash_path: newBash, + }) + expect(JSON.parse(fs.readFileSync(path.join(userData, 'terminal-config.json'), 'utf8'))).toEqual({ + bash_path: legacyBash, + }) }) it('persists the legacy bash path config and validates saved paths', () => { @@ -121,12 +147,16 @@ describe('Electron terminal service', () => { JSON.stringify({ desktopTerminal: { startupShell: 'cmd' } }), ) + const ignoredHome = tempDir() const service = new ElectronTerminalService({ - env: { HOME: dir, COMSPEC: 'powershell.exe' }, + env: { HOME: ignoredHome, USERPROFILE: dir, COMSPEC: 'powershell.exe' }, platform: 'win32', }) - expect(desktopTerminalSettingsPath({ HOME: dir })).toBe(path.join(dir, '.claude', 'settings.json')) + expect(desktopTerminalSettingsPath({ HOME: ignoredHome, USERPROFILE: dir }, 'win32')) + .toBe(path.join(dir, '.claude', 'settings.json')) + expect(desktopTerminalSettingsPath({ HOME: dir }, 'darwin')) + .toBe(path.join(dir, '.claude', 'settings.json')) expect(service.resolveShell()).toBe('cmd.exe') }) diff --git a/desktop/electron/services/terminal.ts b/desktop/electron/services/terminal.ts index 699fcdae..d4cc3522 100644 --- a/desktop/electron/services/terminal.ts +++ b/desktop/electron/services/terminal.ts @@ -60,7 +60,7 @@ export type TerminalPtyFactory = { } export type TerminalAppLike = { - getPath(name: 'userData'): string + getPath(name: 'home' | 'userData'): string } export type TerminalWebContentsLike = { @@ -104,18 +104,26 @@ export function terminalConfigPath(app: TerminalAppLike | undefined, env: NodeJS return path.join(portableDir, TERMINAL_CONFIG_FILE) } if (!app) return null - return path.join(app.getPath('userData'), TERMINAL_CONFIG_FILE) + return path.join(app.getPath('home'), '.claude', TERMINAL_CONFIG_FILE) } -export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string | null { +export function claudeConfigDir( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { const portableDir = env.CLAUDE_CONFIG_DIR?.trim() if (portableDir) return portableDir - const home = env.HOME || env.USERPROFILE || os.homedir() + const home = platform === 'win32' + ? env.USERPROFILE || os.homedir() + : env.HOME || os.homedir() return home ? path.join(home, '.claude') : null } -export function desktopTerminalSettingsPath(env: NodeJS.ProcessEnv = process.env): string | null { - const dir = claudeConfigDir(env) +export function desktopTerminalSettingsPath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { + const dir = claudeConfigDir(env, platform) return dir ? path.join(dir, 'settings.json') : null } @@ -228,8 +236,11 @@ export function terminalEnvironment( return ensureUtf8Locale(merged, platform) } -export function readDesktopTerminalConfig(env: NodeJS.ProcessEnv = process.env): DesktopTerminalConfig | null { - const settingsPath = desktopTerminalSettingsPath(env) +export function readDesktopTerminalConfig( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): DesktopTerminalConfig | null { + const settingsPath = desktopTerminalSettingsPath(env, platform) if (!settingsPath) return null try { const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as DesktopTerminalSettingsFile @@ -242,11 +253,18 @@ export function readDesktopTerminalConfig(env: NodeJS.ProcessEnv = process.env): function loadTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv): TerminalConfig { const configPath = terminalConfigPath(app, env) if (!configPath) return {} - try { - return JSON.parse(fs.readFileSync(configPath, 'utf8')) as TerminalConfig - } catch { - return {} + const candidates = [configPath] + if (app && !env.CLAUDE_CONFIG_DIR) { + candidates.push(path.join(app.getPath('userData'), TERMINAL_CONFIG_FILE)) } + for (const candidate of candidates) { + try { + return JSON.parse(fs.readFileSync(candidate, 'utf8')) as TerminalConfig + } catch { + // Try the old Electron userData location before using defaults. + } + } + return {} } function saveTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv, config: TerminalConfig) { @@ -520,7 +538,7 @@ export class ElectronTerminalService { terminalConfig.bash_path ?? null, this.fileExists, ) - return resolveDesktopTerminalShell(this.platform, readDesktopTerminalConfig(this.env)) ?? systemDefault + return resolveDesktopTerminalShell(this.platform, readDesktopTerminalConfig(this.env, this.platform)) ?? systemDefault } async spawn(input: TerminalSpawnInput, webContents: TerminalWebContentsLike): Promise { diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index 09a9ae5b..3eeb4078 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' @@ -21,11 +21,21 @@ import { writeWindowState, } from './windows' -const fakeApp = (userData: string) => ({ - getPath: vi.fn(() => userData), +const fakeApp = (home: string, userData = path.join(home, 'user-data')) => ({ + getPath: vi.fn((name: string) => name === 'home' ? home : userData), }) describe('Electron window service', () => { + it('stores system-mode window state under ~/.claude', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-system-')) + try { + const app = fakeApp(tmp) + expect(windowStatePath(app as never, {})).toBe(path.join(tmp, '.claude', 'window-state.json')) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('persists window state in CLAUDE_CONFIG_DIR when portable config is active', () => { const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-')) try { @@ -97,6 +107,26 @@ describe('Electron window service', () => { } }) + it('reads the old Electron userData window state as a forward-migration fallback', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-legacy-')) + try { + const userData = path.join(tmp, 'user-data') + const app = fakeApp(tmp, userData) + const state = { x: 50, y: 60, width: 1280, height: 820, maximized: true } + mkdirSync(userData, { recursive: true }) + writeFileSync(path.join(userData, 'window-state.json'), JSON.stringify(state)) + + expect(readWindowState( + app as never, + [{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }], + {}, + 'win32', + )).toEqual(state) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('clamps restored macOS windows below the menu bar work area', () => { const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-clamp-')) try { diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index aec23c46..8e6d9d75 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -28,7 +28,7 @@ export type WindowChromeOptions = Pick< > export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string { - return path.join(env.CLAUDE_CONFIG_DIR || app.getPath('userData'), WINDOW_STATE_FILE) + return path.join(env.CLAUDE_CONFIG_DIR || path.join(app.getPath('home'), '.claude'), WINDOW_STATE_FILE) } export function isPersistableWindowState(state: StoredWindowState): boolean { @@ -93,7 +93,11 @@ export function readWindowState( env: NodeJS.ProcessEnv = process.env, platform = process.platform, ): StoredWindowState | null { - const statePath = windowStatePath(app, env) + let statePath = windowStatePath(app, env) + if (!existsSync(statePath) && !env.CLAUDE_CONFIG_DIR) { + const legacyStatePath = path.join(app.getPath('userData'), WINDOW_STATE_FILE) + if (existsSync(legacyStatePath)) statePath = legacyStatePath + } if (!existsSync(statePath)) return null try { diff --git a/desktop/package.json b/desktop/package.json index 78097d73..6b6e57a5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -56,7 +56,8 @@ }, "nsis": { "oneClick": false, - "allowToChangeInstallationDirectory": true + "allowToChangeInstallationDirectory": true, + "include": "build/installer.nsh" }, "linux": { "target": [ @@ -85,6 +86,7 @@ "clean:electron-output": "bun run ./scripts/clean-electron-output.ts", "build:macos-arm64": "bash ./scripts/build-macos-arm64.sh", "build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1", + "test:windows-storage-recovery": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ./build/recover-legacy-install-data.ps1 -SelfTest", "build:linux-x64": "bash ./scripts/build-linux.sh", "build:linux-arm64": "LINUX_ARCH=arm64 bash ./scripts/build-linux.sh", "electron:dev": "bun run build:electron && bun run ./scripts/electron-dev.ts", diff --git a/desktop/scripts/build-sidecars.test.ts b/desktop/scripts/build-sidecars.test.ts index 60630e2a..1c1b44e2 100644 --- a/desktop/scripts/build-sidecars.test.ts +++ b/desktop/scripts/build-sidecars.test.ts @@ -7,6 +7,13 @@ function readBuildScript() { return readFileSync(path.resolve(import.meta.dirname, 'build-sidecars.ts'), 'utf8') } +function readCliLauncher() { + return readFileSync( + path.resolve(import.meta.dirname, '../../bin/claude-haha'), + 'utf8', + ) +} + function extractWindowsX64BunTarget(source: string) { const match = source.match(/case 'x86_64-pc-windows-msvc':[\s\S]*?return '([^']+)'/) return match?.[1] ?? null @@ -16,4 +23,12 @@ describe('build-sidecars Windows x64 target mapping', () => { it('uses the baseline Bun runtime so older CPUs do not crash with Illegal Instruction', () => { expect(extractWindowsX64BunTarget(readBuildScript())).toBe('bun-windows-x64-baseline') }) + + it('compiles the sidecar with the transcript classifier feature', () => { + expect(readBuildScript()).toContain("features: ['TRANSCRIPT_CLASSIFIER']") + }) + + it('starts the development CLI with the transcript classifier feature', () => { + expect(readCliLauncher()).toContain('--feature=TRANSCRIPT_CLASSIFIER') + }) }) diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index 82f477a4..ab8a039c 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -99,6 +99,7 @@ async function compileExecutable({ }) { const result = await Bun.build({ entrypoints: [entrypoint], + features: ['TRANSCRIPT_CLASSIFIER'], // minify whitespace + identifiers + dead-code 大概能省 5-15% 的二进制大小, // 代价是 stack trace 里的函数名变成短名 —— 终端用户场景可接受。 minify: { whitespace: true, identifiers: true, syntax: true }, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 424a771b..9aa7f94c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -230,6 +230,7 @@ const WINDOW_STATE_FILE: &str = "window-state.json"; const TERMINAL_CONFIG_FILE: &str = "terminal-config.json"; const APP_MODE_FILE: &str = "app-mode.json"; const SERVER_STATE_FILE: &str = "desktop-server-state.json"; +const MAX_PORT_RESERVATION_ATTEMPTS: usize = 128; const MIN_WINDOW_WIDTH: u32 = 960; const MIN_WINDOW_HEIGHT: u32 = 640; const MIN_VISIBLE_PIXELS: i64 = 64; @@ -1449,7 +1450,9 @@ fn parse_h5_fixed_port(contents: &str) -> Option { let value: serde_json::Value = serde_json::from_str(contents).ok()?; let port = value.get("h5Access")?.get("fixedPort")?.as_u64()?; if (1024..=65535).contains(&port) { - u16::try_from(port).ok() + u16::try_from(port) + .ok() + .filter(|port| is_browser_safe_port(*port)) } else { None } @@ -1606,21 +1609,57 @@ fn default_shell(_custom_bash: Option<&str>) -> String { } } +// Keep this list aligned with the WHATWG Fetch bad-port table and the +// Electron/renderer predicate. https://fetch.spec.whatwg.org/#bad-port +const FETCH_BLOCKED_PORTS: &[u16] = &[ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, + 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, + 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, + 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, + 6668, 6669, 6679, 6697, 10080, +]; + +fn is_browser_safe_port(port: u16) -> bool { + !FETCH_BLOCKED_PORTS.contains(&port) +} + +fn reserve_browser_safe_port(mut reserve_candidate: F) -> Result +where + F: FnMut() -> Result, +{ + for _ in 0..MAX_PORT_RESERVATION_ATTEMPTS { + let port = reserve_candidate()?; + if is_browser_safe_port(port) { + return Ok(port); + } + eprintln!("[desktop] OS assigned browser-blocked server port {port}; retrying"); + } + Err("could not reserve a browser-safe local port".to_string()) +} + fn reserve_local_port(bind_host: &str) -> Result { - let listener = TcpListener::bind(format!("{bind_host}:0")) - .map_err(|err| format!("bind local port: {err}"))?; - let port = listener - .local_addr() - .map_err(|err| format!("read local port: {err}"))? - .port(); - drop(listener); - Ok(port) + reserve_browser_safe_port(|| { + let listener = TcpListener::bind(format!("{bind_host}:0")) + .map_err(|err| format!("bind local port: {err}"))?; + let port = listener + .local_addr() + .map_err(|err| format!("read local port: {err}"))? + .port(); + drop(listener); + Ok(port) + }) } /// 按优先级尝试给定端口(h5Access.fixedPort > 上次使用的端口), /// 全部被占用时回退到 OS 随机分配。保证 app 总能启动。 fn reserve_local_port_with_preference(bind_host: &str, preferred: &[u16]) -> Result { for &port in preferred { + if !is_browser_safe_port(port) { + eprintln!( + "[desktop] preferred server port {port} is blocked by browser fetch; skipping" + ); + continue; + } match TcpListener::bind(format!("{bind_host}:{port}")) { Ok(listener) => { drop(listener); @@ -2086,8 +2125,9 @@ fn kill_windows_sidecars() { mod tests { use super::{ decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale, - has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path, - parse_env_block, parse_h5_fixed_port, reserve_local_port_with_preference, + has_meaningful_intersection, is_browser_safe_port, is_persistable_window_state, + normalize_terminal_bash_path, parse_env_block, parse_h5_fixed_port, + reserve_browser_safe_port, reserve_local_port_with_preference, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell, resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig, StoredServerState, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, @@ -2406,6 +2446,10 @@ mod tests { parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":28670}}"#), Some(28670) ); + assert_eq!( + parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":5061}}"#), + None + ); // Out of range, wrong type, missing, or null all fall back to None. assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":80}}"#), None); assert_eq!( @@ -2449,6 +2493,59 @@ mod tests { assert!(reserve_local_port_with_preference("127.0.0.1", &[]).is_ok()); } + #[test] + fn fetch_blocked_preferred_port_is_skipped() { + let reserved = reserve_local_port_with_preference("127.0.0.1", &[5061]) + .expect("reserve browser-safe fallback"); + assert_ne!(reserved, 5061); + } + + #[test] + fn fetch_blocked_random_port_is_retried() { + let mut candidates = [5061, 5062].into_iter(); + let reserved = reserve_browser_safe_port(|| { + candidates + .next() + .ok_or_else(|| "ran out of test ports".to_string()) + }) + .expect("reserve browser-safe candidate"); + assert_eq!(reserved, 5062); + } + + #[test] + fn fetch_blocked_random_port_retry_is_bounded() { + let mut attempts = 0; + let error = reserve_browser_safe_port(|| { + attempts += 1; + Ok(5061) + }) + .expect_err("blocked candidates must eventually fail"); + assert_eq!(error, "could not reserve a browser-safe local port"); + assert_eq!(attempts, 128); + } + + #[test] + fn random_port_reservation_errors_are_propagated() { + let error = reserve_browser_safe_port(|| Err("bind failed".to_string())) + .expect_err("reservation errors must propagate"); + assert_eq!(error, "bind failed"); + } + + #[test] + fn browser_safe_port_matches_fetch_blocking_contract() { + for port in [ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, + 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, + 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, + 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, + 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, + ] { + assert!(!is_browser_safe_port(port), "port {port} should be blocked"); + } + assert!(is_browser_safe_port(5062)); + assert!(is_browser_safe_port(28670)); + } + #[test] fn stored_server_state_round_trips_camel_case_json() { let state = StoredServerState { last_port: 28670 }; diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index 592f33e1..df898c7f 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -1,28 +1,64 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' +import { SAFE_DOCTOR_STORAGE_KEYS } from '../lib/doctorRepair' +import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' const diagnosticsApiMock = vi.hoisted(() => ({ getStatus: vi.fn(), getEvents: vi.fn(), + getIssueReport: vi.fn(), exportBundle: vi.fn(), openLogDir: vi.fn(), clear: vi.fn(), })) -const doctorRepairMock = vi.hoisted(() => ({ - runDoctorRepair: vi.fn(), +const doctorApiMock = vi.hoisted(() => ({ + report: vi.fn(), })) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + +function doctorReport(path: string) { + return { + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [{ + id: `finding:${path}`, + label: 'Finding', + kind: 'json' as const, + scope: 'user' as const, + path, + protected: true, + exists: true, + status: 'invalid_schema' as const, + bytes: 42, + }], + protectedSkips: [], + summary: { total: 1, protectedCount: 1, neutralCount: 0, missingCount: 0, invalidCount: 1 }, + }, + } +} + vi.mock('../api/diagnostics', () => ({ diagnosticsApi: diagnosticsApiMock, })) -vi.mock('../lib/doctorRepair', () => doctorRepairMock) +vi.mock('../api/doctor', () => ({ + doctorApi: doctorApiMock, +})) vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ @@ -100,23 +136,35 @@ describe('Settings > Diagnostics tab', () => { retentionDays: 7, maxBytes: 50 * 1024 * 1024, totalBytes: 4096, - eventCount: 2, + eventCount: 600, + physicalLineCount: 601, + corruptLineCount: 1, + storageLimitExceeded: false, recentErrorCount: 1, lastEventAt: '2026-05-02T00:00:00.000Z', }) diagnosticsApiMock.getEvents.mockResolvedValue({ - events: [{ - id: 'event-1', - timestamp: '2026-05-02T00:00:00.000Z', - type: 'cli_start_failed', - severity: 'error', - summary: 'CLI exited during startup with code 1', - sessionId: 'session-1', - details: { - exitCode: 1, - capturedOutput: 'stderr:\nprovider rejected request', + events: [ + { + id: 'event-1', + timestamp: '2026-05-02T00:00:00.000Z', + type: 'cli_start_failed', + severity: 'error', + summary: 'CLI exited during startup with code 1', + sessionId: 'session-1', + details: { + exitCode: 1, + capturedOutput: 'stderr:\nprovider rejected request', + }, }, - }], + ...Array.from({ length: 99 }, (_, index) => ({ + id: `event-${index + 2}`, + timestamp: '2026-05-02T00:00:00.000Z', + type: `runtime_event_${index + 2}`, + severity: 'info' as const, + summary: `Runtime event ${index + 2}`, + })), + ], }) diagnosticsApiMock.exportBundle.mockResolvedValue({ bundle: { @@ -125,22 +173,60 @@ describe('Settings > Diagnostics tab', () => { bytes: 1024, }, }) + diagnosticsApiMock.getIssueReport.mockResolvedValue({ + report: '## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing', + }) diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true }) diagnosticsApiMock.clear.mockResolvedValue({ ok: true }) - doctorRepairMock.runDoctorRepair.mockResolvedValue({ - local: { - removedKeys: ['cc-haha-open-tabs', 'cc-haha-session-runtime'], - missingKeys: ['cc-haha-theme', 'cc-haha-locale', 'cc-haha.persistence.schemaVersion'], - failedKeys: [], + doctorApiMock.report.mockResolvedValue({ + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [ + { + id: 'cc-haha-providers', + label: 'Managed providers', + kind: 'json', + scope: 'user', + path: '~/.claude/cc-haha/providers.json', + protected: true, + exists: true, + status: 'invalid_schema', + bytes: 42, + error: 'providers.0.presetId: expected string', + }, + { + id: 'project-skills', + label: 'Project skills', + kind: 'directory', + scope: 'project', + path: '/.claude/skills', + protected: true, + exists: true, + status: 'ok', + bytes: 0, + }, + ], + protectedSkips: [], + summary: { total: 2, protectedCount: 2, neutralCount: 0, missingCount: 0, invalidCount: 1 }, }, - server: { - ok: true, - }, - serverError: null, }) useSettingsStore.setState({ locale: 'en' }) useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] }) + useSessionStore.setState({ + sessions: [{ + id: 'session-1', + title: 'Session', + createdAt: '2026-07-11T00:00:00.000Z', + modifiedAt: '2026-07-11T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + projectRoot: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: 'session-1', + }) }) it('shows diagnostics status, actions, and recent events', async () => { @@ -155,6 +241,80 @@ describe('Settings > Diagnostics tab', () => { expect(screen.getByText('cli_start_failed')).toBeInTheDocument() expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument() expect(screen.getByText('Details')).toBeInTheDocument() + expect(screen.getByText('600 complete events')).toBeInTheDocument() + expect(screen.getByText(/evidence of 1 corrupt diagnostic record/)).toBeInTheDocument() + expect(screen.getByText('100 visible events')).toBeInTheDocument() + expect(screen.getAllByText('Event ID:')).toHaveLength(100) + expect(screen.getByText('event-1')).toBeInTheDocument() + expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i) + + const eventRow = screen.getByText('cli_start_failed').closest('.grid') + expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]') + }) + + it('describes persisted corruption evidence accurately when current logs have no physical lines', async () => { + diagnosticsApiMock.getStatus.mockResolvedValueOnce({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-diagnostics.jsonl', + runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log', + exportDir: '/tmp/claude/cc-haha/diagnostics/exports', + retentionDays: 7, + maxBytes: 50 * 1024 * 1024, + totalBytes: 0, + eventCount: 0, + physicalLineCount: 0, + corruptLineCount: 2, + storageLimitExceeded: false, + recentErrorCount: 0, + lastEventAt: null, + }) + + render() + fireEvent.click(screen.getByText('Diagnostics')) + + const warning = await screen.findByRole('alert') + expect(warning).toHaveTextContent('Detected or retained evidence of 2 corrupt diagnostic records.') + expect(warning).toHaveTextContent('Current diagnostic files contain 0 physical lines.') + expect(warning).not.toHaveTextContent(/among 0 physical lines/i) + }) + + it('explains temporary target overflow while active diagnostic segments are still open', async () => { + diagnosticsApiMock.getStatus.mockResolvedValueOnce({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-diagnostics.jsonl', + runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log', + exportDir: '/tmp/claude/cc-haha/diagnostics/exports', + retentionDays: 7, + maxBytes: 50 * 1024 * 1024, + totalBytes: 52 * 1024 * 1024, + eventCount: 10, + physicalLineCount: 10, + corruptLineCount: 0, + storageLimitExceeded: true, + recentErrorCount: 0, + lastEventAt: '2026-07-11T00:00:00.000Z', + }) + + render() + fireEvent.click(screen.getByText('Diagnostics')) + + const warning = await screen.findByRole('alert') + expect(warning).toHaveTextContent('One or more diagnostic surfaces or active writers temporarily exceed their own retention target.') + expect(warning).toHaveTextContent('Cleanup will occur as their segments close or age out.') + expect(warning).not.toHaveTextContent(/50 MB|strict|hard cap/i) + }) + + it('marks the active settings tab and its decorative icon accessibly', async () => { + render() + + const diagnosticsTab = screen.getByRole('button', { name: 'Diagnostics' }) + fireEvent.click(diagnosticsTab) + await screen.findByText('Log directory') + + expect(diagnosticsTab).toHaveAttribute('aria-current', 'page') + expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true') }) it('exports a diagnostics bundle from the settings page', async () => { @@ -239,9 +399,111 @@ describe('Settings > Diagnostics tab', () => { } }) - it('runs Doctor from Diagnostics without clearing unrelated desktop state', async () => { - window.localStorage.setItem('cc-haha-open-tabs', '{"activeTabId":"__settings__"}') - window.localStorage.setItem('cc-haha-theme', 'dark') + it('copies the share-safe issue Markdown with the legacy clipboard fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), + }, + }) + const writeText = vi.mocked(navigator.clipboard.writeText) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Copy issue report/i })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + expect(diagnosticsApiMock.getIssueReport).toHaveBeenCalledTimes(1) + expect(writeText).toHaveBeenCalledWith('## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing') + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Issue report copied.') + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('copies the exact Event ID and reports success', async () => { + const originalClipboard = navigator.clipboard + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' })) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith('event-1') + }) + expect(writeText).toHaveBeenCalledTimes(1) + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Event ID copied.') + } finally { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('reports a meaningful error when Event ID copy fails', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')) }, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(false), + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' })) + + await waitFor(() => { + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Failed to copy event ID.') + }) + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('checks findings first and confirms before resetting only safe desktop state', async () => { + window.localStorage.clear() + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + window.localStorage.setItem(key, `${key}-value`) + } window.localStorage.setItem('cc-haha-chat-history', 'keep') render() @@ -250,11 +512,256 @@ describe('Settings > Diagnostics tab', () => { fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) await waitFor(() => { - expect(doctorRepairMock.runDoctorRepair).toHaveBeenCalled() + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project') + }) + expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value') + expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + expect(screen.getByText(/Invalid schema/i)).toBeInTheDocument() + expect(screen.getByText(/User and active project/i)).toBeInTheDocument() + expect(screen.getByText('Healthy: 1 · Not configured: 0 · Missing: 0 · Invalid: 1')).toBeInTheDocument() + expect(screen.queryByText('/.claude/skills')).not.toBeInTheDocument() + expect(screen.getByText(/cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText(/cc-haha-ui-zoom/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Reset safe UI state/i })) + const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' }) + expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value') + fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i })) + + await waitFor(() => { + expect(doctorApiMock.report).toHaveBeenCalledTimes(2) + }) + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + expect(window.localStorage.getItem(key)).toBeNull() + } + expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep') + expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + }) + + it('counts not-configured optional checks separately and excludes them from findings', async () => { + doctorApiMock.report.mockResolvedValueOnce({ + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [ + { + id: 'user-settings', + label: 'User settings', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/settings.json', + protected: true, + exists: true, + status: 'ok' as const, + bytes: 2, + }, + { + id: 'adapters', + label: 'Adapters config', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/adapters.json', + protected: true, + exists: false, + status: 'not_configured' as const, + bytes: 0, + }, + { + id: 'cc-haha-providers', + label: 'Managed providers', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/cc-haha/providers.json', + protected: true, + exists: true, + status: 'invalid_schema' as const, + bytes: 10, + }, + ], + protectedSkips: [], + summary: { total: 3, protectedCount: 3, neutralCount: 1, missingCount: 0, invalidCount: 1 }, + }, }) - const toasts = useUIStore.getState().toasts - expect(toasts[toasts.length - 1]?.message).toContain('Doctor') - expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep') + render() + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + + expect(await screen.findByText('Healthy: 1 · Not configured: 1 · Missing: 0 · Invalid: 1')).toBeInTheDocument() + expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + expect(screen.queryByText('~/.claude/adapters.json')).not.toBeInTheDocument() + }) + + it('uses user-only Doctor scope when the active work directory is unavailable', async () => { + useSessionStore.setState((state) => ({ + sessions: state.sessions.map((session) => ({ ...session, workDirExists: false })), + })) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + expect(await screen.findByText(/User only/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i })) + + await waitFor(() => { + expect(doctorApiMock.report).toHaveBeenCalledWith(undefined) + }) + }) + + it('clears an existing Doctor report when the active cwd changes', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + expect(await screen.findByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-missing-workdir', + workDir: '/workspace/missing', + projectRoot: '/workspace/missing', + workDirExists: false, + }, + ], + activeSessionId: 'session-missing-workdir', + })) + }) + + await waitFor(() => { + expect(screen.queryByText('~/.claude/cc-haha/providers.json')).not.toBeInTheDocument() + }) + expect(screen.getByText(/User only/i)).toBeInTheDocument() + }) + + it('keeps newer Doctor loading active when an older response resolves first', async () => { + const oldRequest = deferred>() + const newRequest = deferred>() + doctorApiMock.report + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(newRequest.promise) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project') + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-new', + workDir: '/workspace/new', + projectRoot: '/workspace/new', + }, + ], + activeSessionId: 'session-new', + })) + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled() + }) + fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i })) + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/new') + expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled() + + await act(async () => { + oldRequest.resolve(doctorReport('/old-finding.json')) + await Promise.resolve() + }) + expect(screen.queryByText('/old-finding.json')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled() + + await act(async () => { + newRequest.resolve(doctorReport('/new-finding.json')) + await Promise.resolve() + }) + expect(await screen.findByText('/new-finding.json')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled() + }) + + it('preserves reset results and releases reset loading when cwd changes during refresh', async () => { + const stalledRefresh = deferred>() + doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise) + window.localStorage.clear() + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + window.localStorage.setItem(key, `${key}-value`) + } + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i })) + const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' }) + fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i })) + + expect(await screen.findByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText('Failed keys: None')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).toBeDisabled() + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-reset-new', + workDir: '/workspace/reset-new', + projectRoot: '/workspace/reset-new', + }, + ], + activeSessionId: 'session-reset-new', + })) + }) + + expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText('Failed keys: None')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled() + + await act(async () => { + stalledRefresh.resolve(doctorReport('/stale-reset-finding.json')) + await Promise.resolve() + }) + expect(screen.queryByText('/stale-reset-finding.json')).not.toBeInTheDocument() + }) + + it('ignores a stale reset refresh rejection after cwd changes', async () => { + const stalledRefresh = deferred>() + doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i })) + fireEvent.click(within(await screen.findByRole('dialog', { name: 'Reset safe UI state' })) + .getByRole('button', { name: /Reset safe UI state/i })) + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-reject-new', + workDir: '/workspace/reject-new', + projectRoot: '/workspace/reject-new', + }, + ], + activeSessionId: 'session-reject-new', + })) + }) + + await act(async () => { + stalledRefresh.reject(new Error('stale reset refresh failed')) + await Promise.resolve() + }) + + expect(useUIStore.getState().toasts.map((toast) => toast.message)).not.toContain('stale reset refresh failed') + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled() }) }) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index b7c98afc..b6508fd5 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -88,6 +88,10 @@ vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({ ChatGPTOfficialLogin: () =>
, })) +vi.mock('../components/settings/GrokOfficialLogin', () => ({ + GrokOfficialLogin: () =>
, +})) + vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) @@ -209,6 +213,7 @@ describe('Settings > General tab', () => { locale: 'en', theme: 'light', permissionMode: 'default', + autoModeOptInAccepted: false, thinkingEnabled: true, autoDreamEnabled: false, skipWebFetchPreflight: true, @@ -262,6 +267,9 @@ describe('Settings > General tab', () => { setPermissionMode: vi.fn().mockImplementation(async (permissionMode: PermissionMode) => { useSettingsStore.setState({ permissionMode }) }), + acceptAutoModeOptIn: vi.fn().mockImplementation(async () => { + useSettingsStore.setState({ autoModeOptInAccepted: true } as never) + }), setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), @@ -290,8 +298,7 @@ describe('Settings > General tab', () => { appMode: { mode: 'default', portableDir: null, - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', - activeConfigDir: null, + activeConfigDir: '/Users/test/.claude', configDirSource: 'system', }, appModeRequiresRestart: false, @@ -300,9 +307,8 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ appMode: { mode, - portableDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null, - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', - activeConfigDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null, + portableDir: mode === 'portable' ? portableDir ?? null : null, + activeConfigDir: mode === 'portable' ? portableDir ?? null : '/Users/test/.claude', configDirSource: mode === 'portable' ? 'portable' : 'system', }, appModeRequiresRestart: true, @@ -510,17 +516,17 @@ describe('Settings > General tab', () => { const storageHeading = screen.getByRole('heading', { name: 'Data Storage Location' }) expect((webSearchHeading.compareDocumentPosition(storageHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect(screen.getByText(/Switching directories does not migrate existing data/)).toBeInTheDocument() + expect(screen.getByText(/Windows, upgrades recover verified legacy app-adjacent data/)).toBeInTheDocument() }) - it('lets desktop users choose a portable data directory and relaunch immediately', async () => { + it('lets desktop users choose a custom data directory and relaunch immediately', async () => { render() fireEvent.click(screen.getByText('General')) fireEvent.click(screen.getByRole('button', { name: 'Choose Folder' })) await waitFor(() => { - expect(screen.getByLabelText('Portable data directory')).toHaveValue('/Users/test/cc-haha-data') + expect(screen.getByLabelText('Custom data directory')).toHaveValue('/Users/test/cc-haha-data') }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) @@ -534,12 +540,11 @@ describe('Settings > General tab', () => { }) }) - it('switches back to the system directory without deleting portable data', async () => { + it('switches back to ~/.claude without deleting custom data', async () => { useSettingsStore.setState({ appMode: { mode: 'portable', portableDir: '/Users/test/cc-haha-data', - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', activeConfigDir: '/Users/test/cc-haha-data', configDirSource: 'portable', }, @@ -550,7 +555,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('General')) fireEvent.click(screen.getByRole('button', { name: /Use system directory/ })) - expect(screen.getByText(/Data in the portable directory is not deleted/)).toBeInTheDocument() + expect(screen.getByText(/Data in the custom directory is not deleted/)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' })) await waitFor(() => { @@ -560,19 +565,20 @@ describe('Settings > General tab', () => { }) }) - it('validates portable directory input and lets users reset to the app-side folder', async () => { + it('requires an explicit custom directory and exposes no third default-custom choice', async () => { render() fireEvent.click(screen.getByText('General')) - const input = screen.getByLabelText('Portable data directory') + const input = screen.getByLabelText('Custom data directory') fireEvent.change(input, { target: { value: '' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) - expect(screen.getByText('Choose or enter a portable data directory first.')).toBeInTheDocument() + expect(screen.getByText('Choose or enter a custom data directory first.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /default.*data folder/i })).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Use the default portable folder beside the app' })) - expect(input).toHaveValue('/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR') - expect(screen.queryByText('Choose or enter a portable data directory first.')).not.toBeInTheDocument() + fireEvent.change(input, { target: { value: '/Users/test/custom-data' } }) + expect(input).toHaveValue('/Users/test/custom-data') + expect(screen.queryByText('Choose or enter a custom data directory first.')).not.toBeInTheDocument() }) it('shows folder picker failures as an inline storage error', async () => { @@ -591,7 +597,6 @@ describe('Settings > General tab', () => { appMode: { mode: 'portable', portableDir: '/env/claude-data', - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', activeConfigDir: '/env/claude-data', configDirSource: 'environment', }, @@ -605,7 +610,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByRole('button', { name: /Use system directory/ })) expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('Portable data directory'), { target: { value: '/other/data' } }) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/other/data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) expect(screen.queryByText('Switch data storage location?')).not.toBeInTheDocument() expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument() @@ -615,6 +620,7 @@ describe('Settings > General tab', () => { render() fireEvent.click(screen.getByText('General')) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/Users/test/custom-data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) expect(screen.getByText('Switch data storage location?')).toBeInTheDocument() @@ -632,6 +638,7 @@ describe('Settings > General tab', () => { render() fireEvent.click(screen.getByText('General')) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/Users/test/custom-data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' })) @@ -774,6 +781,29 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().permissionMode).toBe('bypassPermissions') }) + it('confirms first use before saving Auto as the new-session default', async () => { + render() + + fireEvent.click(screen.getByText('General')) + await act(async () => { + await Promise.resolve() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + + expect(useSettingsStore.getState().setPermissionMode).not.toHaveBeenCalledWith('auto') + const dialog = screen.getByRole('dialog', { name: 'Enable Auto mode?' }) + + await act(async () => { + fireEvent.click(within(dialog).getByRole('button', { name: 'Enable Auto mode' })) + }) + + expect(useSettingsStore.getState().acceptAutoModeOptIn).toHaveBeenCalledOnce() + expect(useSettingsStore.getState().setPermissionMode).toHaveBeenCalledWith('auto') + expect(useSettingsStore.getState().permissionMode).toBe('auto') + }) + it('keeps Auto-dream disabled by default and confirms before enabling it', async () => { render() @@ -1125,7 +1155,31 @@ describe('Settings > General tab', () => { target: { value: '99' }, }) - expect(within(section).getByText('Port must be an integer between 1024 and 65535.')).toBeInTheDocument() + expect(within(section).getByText('Port must be a browser-safe integer between 1024 and 65535.')).toBeInTheDocument() + expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled() + expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled() + }) + + it('rejects a browser-blocked fixed port before saving', () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Fixed port'), { + target: { value: '5061' }, + }) + expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled() expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled() }) @@ -1521,7 +1575,7 @@ describe('Settings > Providers tab', () => { notes: '', }, ] - providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official'] + providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official', 'grok-official'] providerStoreState.activeId = null providerStoreState.hasLoadedProviders = true }) @@ -1570,6 +1624,18 @@ describe('Settings > Providers tab', () => { expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() }) + it('shows Grok Official as the active built-in provider', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'grok-official' + + render() + + const provider = screen.getByTestId('grok-official-provider') + expect(within(provider).getByText('Grok Official')).toBeInTheDocument() + expect(within(provider).getByText('Default')).toBeInTheDocument() + expect(screen.getByTestId('grok-official-login')).toBeInTheDocument() + }) + it('renders saved and official providers in the stored sortable order', () => { providerStoreState.providerOrder = ['provider-1', 'openai-official', 'claude-official'] @@ -1581,6 +1647,7 @@ describe('Settings > Providers tab', () => { 'provider-provider-1', 'openai-official-provider', 'claude-official-provider', + 'grok-official-provider', ]) }) @@ -1595,6 +1662,7 @@ describe('Settings > Providers tab', () => { 'provider-provider-1', 'claude-official-provider', 'openai-official-provider', + 'grok-official-provider', ]) }) diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts index 4fb34757..7a65c47b 100644 --- a/desktop/src/api/diagnostics.ts +++ b/desktop/src/api/diagnostics.ts @@ -30,6 +30,9 @@ export type DiagnosticsStatus = { maxBytes: number totalBytes: number eventCount: number + physicalLineCount: number + corruptLineCount: number + storageLimitExceeded: boolean recentErrorCount: number lastEventAt: string | null } @@ -43,6 +46,7 @@ export type DiagnosticsBundle = { export const diagnosticsApi = { getStatus: () => api.get('/api/diagnostics/status'), getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`), + getIssueReport: () => api.get<{ report: string }>('/api/diagnostics/issue-report'), recordEvent: (event: DiagnosticEventInput) => api.post<{ ok: true }>('/api/diagnostics/events', event, { timeout: 5_000 }), exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }), openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'), diff --git a/desktop/src/api/doctor.ts b/desktop/src/api/doctor.ts index bb50982e..8a41ac4b 100644 --- a/desktop/src/api/doctor.ts +++ b/desktop/src/api/doctor.ts @@ -8,7 +8,7 @@ export type DoctorReportItem = { path: string protected: boolean exists: boolean - status: 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable' + status: 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable' bytes: number entryCount?: number lineCount?: number @@ -27,49 +27,15 @@ export type DoctorReport = { summary: { total: number protectedCount: number + neutralCount: number missingCount: number invalidCount: number } } -export type DoctorRepairResult = { - dryRun: true - mutated: false - operations: Array<{ - id: string - path: string - action: 'would_repair' - }> - skips: Array<{ - id: string - path: string - reason: 'protected' - }> - summary: { - operationCount: number - skipCount: number - } -} - -export type DoctorReportRepairResponse = { - ok: boolean - report: DoctorReport - repair: DoctorRepairResult -} - export const doctorApi = { - report: () => api.get<{ report: DoctorReport }>('/api/doctor/report', { timeout: 3_000 }), - repair: () => api.post<{ result: DoctorRepairResult }>('/api/doctor/repair', {}, { timeout: 3_000 }), - reportAndRepair: async (): Promise => { - const [{ report }, { result }] = await Promise.all([ - doctorApi.report(), - doctorApi.repair(), - ]) - - return { - ok: true, - report, - repair: result, - } + report: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ report: DoctorReport }>(`/api/doctor/report${query}`, { timeout: 3_000 }) }, } diff --git a/desktop/src/api/hahaGrokOAuth.ts b/desktop/src/api/hahaGrokOAuth.ts new file mode 100644 index 00000000..cf65f8fb --- /dev/null +++ b/desktop/src/api/hahaGrokOAuth.ts @@ -0,0 +1,39 @@ +import { api, getBaseUrl } from './client' + +export type HahaGrokOAuthStatus = + | { loggedIn: false } + | { + loggedIn: true + expiresAt: number | null + email: string | null + } + +function currentServerPort(): number { + const port = new URL(getBaseUrl()).port + const parsed = Number.parseInt(port, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`) + } + return parsed +} + +export const hahaGrokOAuthApi = { + start() { + return api.post<{ authorizeUrl: string; state: string }>( + '/api/haha-grok-oauth/start', + { serverPort: currentServerPort() }, + ) + }, + + status() { + return api.get('/api/haha-grok-oauth') + }, + + successUrl() { + return `${getBaseUrl()}/api/haha-grok-oauth/success` + }, + + logout() { + return api.delete<{ ok: true }>('/api/haha-grok-oauth') + }, +} diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 35e50413..00adabeb 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -18,7 +18,7 @@ type PresetsResponse = { presets: ProviderPreset[] } type TestResultResponse = { result: ProviderTestResult } type AuthStatusResponse = { hasAuth: boolean - source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none' + source: 'cc-haha-provider' | 'openai-oauth' | 'grok-oauth' | 'original-settings' | 'env' | 'none' activeProvider?: string } diff --git a/desktop/src/components/chat/AttachmentGallery.test.tsx b/desktop/src/components/chat/AttachmentGallery.test.tsx index 9e26a81d..d435074c 100644 --- a/desktop/src/components/chat/AttachmentGallery.test.tsx +++ b/desktop/src/components/chat/AttachmentGallery.test.tsx @@ -2,10 +2,43 @@ import '@testing-library/jest-dom' import { fireEvent, render } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' import { AttachmentGallery } from './AttachmentGallery' describe('AttachmentGallery', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders diff comments as note-first composer cards with side-aware locations', () => { + const view = render( + , + ) + + const card = view.getByTestId('diff-comment-card') + expect(card.textContent).toContain('src/a.ts · new L11-L12') + expect(card.textContent).toContain('Use a shared helper') + expect(card.textContent).toContain('const result = buildResult() return result') + expect(card.textContent?.indexOf('Use a shared helper')).toBeLessThan( + card.textContent?.indexOf('const result = buildResult()') ?? -1, + ) + }) + it('renders a compact quote preview for selected workspace text', () => { render( { expect(tooltip).toHaveTextContent('这个标题更轻一点') expect(tooltip.className).toContain('group-hover/selection:visible') }) + + it('localizes diff sides and remove actions in Chinese', () => { + useSettingsStore.setState({ locale: 'zh' }) + const view = render( + , + ) + + expect(view.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · 新 L11') + expect(view.getByRole('button', { name: '移除 a.ts' })).toBeInTheDocument() + }) }) diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 9f597eea..721ca2bf 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -1,4 +1,6 @@ import { useMemo, useState } from 'react' +import { MessageSquare, X } from 'lucide-react' +import { useTranslation } from '../../i18n' import { ImageGalleryModal } from './ImageGalleryModal' export type AttachmentPreview = { @@ -11,6 +13,8 @@ export type AttachmentPreview = { isDirectory?: boolean lineStart?: number lineEnd?: number + diffSide?: 'old' | 'new' + hunkId?: string note?: string quote?: string } @@ -22,6 +26,7 @@ type Props = { } export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) { + const t = useTranslation() const [activeImageIndex, setActiveImageIndex] = useState(null) const images = useMemo( @@ -120,7 +125,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } type="button" onClick={() => onRemove(attachment.id!)} className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100" - aria-label={`Remove ${attachment.name}`} + aria-label={t('attachments.remove', { name: attachment.name })} > × @@ -129,6 +134,57 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } ) } + if (attachment.diffSide) { + const lineRange = attachment.lineStart + ? `L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` + : '' + const location = [ + attachment.path || attachment.name, + '·', + t(`workspace.diffReview.side.${attachment.diffSide}`), + lineRange, + ] + .filter(Boolean) + .join(' ') + const note = attachment.note?.trim() + const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ') + + return ( +
+
+ ) + } + const lineLabel = attachment.lineStart ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` : '' @@ -164,7 +220,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } type="button" onClick={() => onRemove(attachment.id!)} className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`} - aria-label={`Remove ${attachment.name}`} + aria-label={t('attachments.remove', { name: attachment.name })} > close diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e7ba978d..992224fd 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -224,6 +224,35 @@ describe('ChatInput file mentions', () => { vi.unstubAllGlobals() }) + it('passes diff metadata to the composer card and clears the reference after send', async () => { + act(() => { + useWorkspaceChatContextStore.getState().addReference(sessionId, { + kind: 'code-comment', + path: 'src/a.ts', + absolutePath: '/repo/src/a.ts', + name: 'a.ts', + lineStart: 11, + lineEnd: 12, + diffSide: 'new', + hunkId: 'hunk-1', + note: 'Use a shared helper', + quote: 'const result = buildResult()\nreturn result', + }) + }) + + render() + + expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · new L11-L12') + expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('Use a shared helper') + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) + + await waitFor(() => { + expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toEqual([]) + }) + expect(screen.queryByTestId('diff-comment-card')).not.toBeInTheDocument() + }) + it('keeps unsent composer drafts isolated when switching between session tabs', async () => { const historySessionId = 'history-session' useTabStore.setState({ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index e88394b6..39c86bc5 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -65,6 +65,8 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, + diffSide: reference.diffSide, + hunkId: reference.hunkId, note: reference.note, quote: reference.quote, } @@ -691,6 +693,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, + diffSide: reference.diffSide, + hunkId: reference.hunkId, note: reference.note, quote: reference.quote, })), @@ -974,6 +978,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return (
{ + it('keeps only visible user and assistant messages in transcript order', () => { + const items = buildConversationNavigationItems([ + source({ id: 'user-1', type: 'user_text', content: ' Review the API ', timestamp: 1 }, 0), + source({ id: 'thinking-1', type: 'thinking', content: 'hidden', timestamp: 2 }, 1), + source({ id: 'assistant-empty', type: 'assistant_text', content: ' ', timestamp: 3 }, 2), + source({ id: 'assistant-1', type: 'assistant_text', content: '**API** review complete', timestamp: 4 }, 3), + source({ id: 'system-1', type: 'system', content: 'hidden', timestamp: 5 }, 4), + ]) + + expect(items).toEqual([ + { + id: 'user-1', + renderItemKey: 'user-1', + renderIndex: 0, + role: 'user', + preview: 'Review the API', + attachmentCount: 0, + }, + { + id: 'assistant-1', + renderItemKey: 'assistant-1', + renderIndex: 3, + role: 'assistant', + preview: 'API review complete', + attachmentCount: 0, + }, + ]) + }) + + it('counts user attachments and flattens markdown into preview text', () => { + const items = buildConversationNavigationItems([ + source({ + id: 'user-files', + type: 'user_text', + content: '> Please inspect [`MessageList`](https://example.com)\n\n```ts\nconst ready = true\n```', + timestamp: 1, + attachments: [ + { type: 'file', name: 'one.ts', mimeType: 'text/plain' }, + { type: 'file', name: 'two.ts', mimeType: 'text/plain' }, + ], + }, 0), + ]) + + expect(items[0]).toMatchObject({ + preview: 'Please inspect MessageList const ready = true', + attachmentCount: 2, + }) + }) + + it('bounds previews for very long messages', () => { + const items = buildConversationNavigationItems([ + source({ id: 'long', type: 'assistant_text', content: 'long answer '.repeat(200), timestamp: 1 }, 0), + ]) + + expect(items[0]?.preview.length).toBeLessThanOrEqual(280) + expect(items[0]?.preview.endsWith('…')).toBe(true) + }) +}) + +describe('ConversationNavigator', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders ordered role markers and identifies the active target', () => { + render( + , + ) + + const markers = screen.getAllByRole('button') + expect(markers.map((marker) => marker.getAttribute('data-role'))).toEqual(['user', 'assistant']) + expect(markers[0]?.getAttribute('aria-current')).toBeNull() + expect(markers[1]?.getAttribute('aria-current')).toBe('location') + + const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]')) + expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('full') + expect(markerBars.every((bar) => (bar as HTMLElement).style.width === '12px')).toBe(true) + expect(markerBars.every((bar) => bar?.className.includes('transition-[width,background-color,opacity]'))).toBe(true) + expect(markerBars[1]?.className).toContain('bg-[var(--color-brand)]') + expect((markerBars[1] as HTMLElement).style.width).toBe('12px') + }) + + it('magnifies nearby markers as a continuous proximity wave', () => { + render( + ({ + id: `assistant-${index}`, + renderItemKey: `assistant-${index}`, + renderIndex: index, + role: 'assistant' as const, + preview: `Answer ${index}`, + attachmentCount: 0, + }))} + activeItemId="assistant-8" + onNavigate={vi.fn()} + />, + ) + + const navigator = screen.getByTestId('conversation-navigator') + const lane = navigator.querySelector('.conversation-navigation-scroll') as HTMLElement + vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({ + bottom: 180, + height: 180, + left: 0, + right: 56, + top: 0, + width: 56, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + fireEvent.mouseMove(lane, { clientY: 88 }) + + const widths = screen.getAllByRole('button').map((marker) => ( + Number.parseFloat((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width) + )) + expect(widths[4]).toBe(52) + expect(widths[3]).toBeGreaterThan(widths[2]!) + expect(widths[2]).toBeGreaterThan(widths[1]!) + expect(widths[1]).toBeGreaterThan(widths[0]!) + expect(widths[0]).toBe(12) + expect(widths.slice(0, 4)).toEqual(widths.slice(5).reverse()) + + fireEvent.mouseLeave(lane) + expect(screen.getAllByRole('button').every((marker) => ( + (marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width === '12px' + ))).toBe(true) + }) + + it('uses equal shorter marker geometry in compact mode', () => { + render( + , + ) + + const markers = screen.getAllByRole('button') + const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]')) + expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('compact') + expect(markerBars.every((bar) => (bar as HTMLElement).style.width === '10px')).toBe(true) + expect(markerBars.every((bar) => bar?.className.includes('motion-reduce:transition-none'))).toBe(true) + }) + + it('uses an edge-sized lane when the transcript becomes narrow', () => { + render( + , + ) + + const markers = screen.getAllByRole('button') + const markerBars = markers.map((marker) => marker.querySelector('[aria-hidden="true"]')) + expect(screen.getByTestId('conversation-navigator').getAttribute('data-mode')).toBe('edge') + expect(markerBars.every((bar) => (bar as HTMLElement).style.width === '6px')).toBe(true) + }) + + it('shows the preview on hover or focus and navigates on click', () => { + const onNavigate = vi.fn() + const item = { + id: 'user-1', + renderItemKey: 'user-1', + renderIndex: 0, + role: 'user' as const, + preview: 'Inspect the virtual transcript', + attachmentCount: 2, + } + render( + , + ) + + const marker = screen.getByRole('button', { name: /User message.*Inspect the virtual transcript/ }) + expect(screen.queryByTestId('conversation-navigation-preview')).toBeNull() + + fireEvent.mouseEnter(marker) + const preview = screen.getByTestId('conversation-navigation-preview') + expect(preview.parentElement).toBe(document.body) + expect(preview.textContent).toContain('User message') + expect(preview.textContent).toContain('Inspect the virtual transcript') + expect(preview.textContent).toContain('2') + + fireEvent.mouseLeave(marker) + fireEvent.focus(marker) + expect(screen.getByTestId('conversation-navigation-preview')).toBeTruthy() + expect((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width).toBe('52px') + + fireEvent.click(marker) + expect(onNavigate).toHaveBeenCalledWith(item) + + fireEvent.blur(marker) + expect((marker.querySelector('[aria-hidden="true"]') as HTMLElement).style.width).toBe('12px') + }) +}) diff --git a/desktop/src/components/chat/ConversationNavigator.tsx b/desktop/src/components/chat/ConversationNavigator.tsx new file mode 100644 index 00000000..f9527c59 --- /dev/null +++ b/desktop/src/components/chat/ConversationNavigator.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Paperclip } from 'lucide-react' +import { useTranslation } from '../../i18n' +import type { UIMessage } from '../../types/chat' + +export type ConversationNavigationSource = { + message: UIMessage + renderItemKey: string + renderIndex: number +} + +export type ConversationNavigationItem = { + id: string + renderItemKey: string + renderIndex: number + role: 'user' | 'assistant' + preview: string + attachmentCount: number +} + +export type ConversationNavigationMode = 'full' | 'compact' | 'edge' + +const NAVIGATION_MODE_STYLES: Record = { + full: { + position: 'left-2', + lane: 'w-16', + button: 'w-16 pl-1.5', + restingWidth: 12, + expandedWidth: 52, + }, + compact: { + position: 'left-1', + lane: 'w-9', + button: 'w-9 pl-1', + restingWidth: 10, + expandedWidth: 32, + }, + edge: { + position: 'left-0', + lane: 'w-6', + button: 'w-6 pl-0.5', + restingWidth: 6, + expandedWidth: 20, + }, +} + +const NAVIGATION_ITEM_HEIGHT_PX = 16 +const NAVIGATION_ITEM_GAP_PX = 2 +const NAVIGATION_LANE_PADDING_PX = 8 +const NAVIGATION_WAVE_RADIUS_ITEMS = 4 + +function getMarkerWidth( + restingWidth: number, + expandedWidth: number, + itemIndex: number, + interactionIndex: number | null, +) { + if (interactionIndex === null) return restingWidth + const distance = Math.abs(itemIndex - interactionIndex) + if (distance >= NAVIGATION_WAVE_RADIUS_ITEMS) return restingWidth + + const proximity = 1 - distance / NAVIGATION_WAVE_RADIUS_ITEMS + const easedProximity = Math.sin(proximity * Math.PI / 2) ** 2 + return restingWidth + (expandedWidth - restingWidth) * easedProximity +} + +function normalizePreview(content: string) { + const normalized = content.slice(0, 2_000) + .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') + .replace(/```[a-z0-9_-]*\s*/gi, ' ') + .replace(/```/g, ' ') + .replace(/[`*_>#~]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (normalized.length <= 280) return normalized + return `${normalized.slice(0, 279).trimEnd()}…` +} + +export function buildConversationNavigationItems( + sources: ConversationNavigationSource[], +): ConversationNavigationItem[] { + return sources.flatMap(({ message, renderItemKey, renderIndex }) => { + if (message.type !== 'user_text' && message.type !== 'assistant_text') return [] + const preview = normalizePreview(message.content) + if (!preview) return [] + + return [{ + id: message.id, + renderItemKey, + renderIndex, + role: message.type === 'user_text' ? 'user' : 'assistant', + preview, + attachmentCount: message.type === 'user_text' ? message.attachments?.length ?? 0 : 0, + }] + }) +} + +export function ConversationNavigator({ + mode, + items, + activeItemId, + onNavigate, +}: { + mode: ConversationNavigationMode + items: ConversationNavigationItem[] + activeItemId: string | null + onNavigate: (item: ConversationNavigationItem) => void +}) { + const t = useTranslation() + const [previewItemId, setPreviewItemId] = useState(null) + const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 }) + const [pointerIndex, setPointerIndex] = useState(null) + const [focusIndex, setFocusIndex] = useState(null) + const markerRefs = useRef(new Map()) + const previewItem = items.find((item) => item.id === previewItemId) ?? null + const modeStyles = NAVIGATION_MODE_STYLES[mode] + const interactionIndex = pointerIndex ?? focusIndex + + const openPreview = (itemId: string, marker: HTMLButtonElement) => { + const rect = marker.getBoundingClientRect() + setPreviewPosition({ + left: rect.right + 6, + top: Math.min(window.innerHeight - 88, Math.max(88, rect.top + rect.height / 2)), + }) + setPreviewItemId(itemId) + } + + useEffect(() => { + if (!activeItemId) return + markerRefs.current.get(activeItemId)?.scrollIntoView?.({ block: 'nearest' }) + }, [activeItemId]) + + return ( + + ) +} diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx index 2969d82d..cb143f15 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx @@ -6,12 +6,13 @@ import { act } from 'react' // ────────────────────────────────────────────────────────────────────────────── // Hoisted mocks (vi.hoisted runs before module evaluation) // ────────────────────────────────────────────────────────────────────────────── -const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } = vi.hoisted(() => { +const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } = vi.hoisted(() => { const openPreviewSpy = vi.fn().mockResolvedValue(undefined) const browserOpenSpy = vi.fn() const openTargetSpy = vi.fn().mockResolvedValue(undefined) const ensureTargetsMock = vi.fn().mockResolvedValue(undefined) - return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } + const panelState = { isOpen: false } + return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } }) // Mock openTargetStore @@ -49,10 +50,10 @@ vi.mock('../../stores/browserPanelStore', () => ({ // Mock workspacePanelStore vi.mock('../../stores/workspacePanelStore', () => ({ useWorkspacePanelStore: Object.assign( - (selector: (s: { openPreview: () => Promise }) => unknown) => - selector({ openPreview: openPreviewSpy }), + (selector: (s: { openPreview: () => Promise; isPanelOpen: () => boolean }) => unknown) => + selector({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen }), { - getState: vi.fn(() => ({ openPreview: openPreviewSpy })), + getState: vi.fn(() => ({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen })), }, ), })) @@ -109,7 +110,7 @@ function makeCheckpoint(filesChanged: string[]): SessionTurnCheckpoint { } } -function renderCard(filesChanged: string[]) { +function renderCard(filesChanged: string[], isLatest = true) { const checkpoint = makeCheckpoint(filesChanged) return render( , ) @@ -136,6 +137,7 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => { vi.clearAllMocks() ensureTargetsMock.mockResolvedValue(undefined) openPreviewSpy.mockResolvedValue(undefined) + panelState.isOpen = false }) it('renders the filename (not just full path) for each file', () => { @@ -192,14 +194,14 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => { const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) // displayPath is the workDir-relative path (matches the workspace file tree) - expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff') + expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' })) }) it('passes the workDir-relative displayPath (not the absolute path) to openPreview', () => { renderCard(['/w/proj/README.md']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) - expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff') + expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' })) }) it('clicking an outside-workspace html changed file opens the in-app browser via local-file', () => { @@ -216,7 +218,7 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => { renderCard(['/other/place/notes.txt']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) - expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file') + expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' })) expect(browserOpenSpy).not.toHaveBeenCalled() }) @@ -264,11 +266,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2) }) - it('hides the workspace chevron on rows that already show an open-with button', () => { + it('keeps open-with secondary while every row retains its workspace chevron', () => { renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts']) expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2) - expect(screen.getAllByText('chevron_right')).toHaveLength(1) + const rows = screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) + expect(rows.every((row) => row.querySelector('.lucide-chevron-right'))).toBe(true) + }) + + it('shows the same destination chevron on every changed-file row', () => { + const { container } = renderCard(['/w/proj/README.md', '/w/proj/src/main.ts']) + + expect(container.querySelectorAll('.lucide-chevron-right')).toHaveLength(2) }) it('clicking README.md open-with opens menu with workspace preview item', async () => { @@ -363,6 +372,36 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { }) }) +describe('CurrentTurnChangeCard – conversation continuity', () => { + beforeEach(() => { + vi.clearAllMocks() + panelState.isOpen = false + openPreviewSpy.mockImplementation(async () => { + panelState.isOpen = true + }) + }) + + it('truthfully labels a historical row as opening the current workspace diff', () => { + renderCard(['/w/proj/src/main.ts'], false) + + expect(screen.getByText('chat.turnChangesCurrentWorkspaceDiff')).toBeInTheDocument() + }) + + it('records a stable opener id and semantic turn key before opening the diff', () => { + renderCard(['/w/proj/src/main.ts']) + const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) + + fireEvent.click(row) + + expect(row.id).toContain('msg-1') + expect(row).toHaveAttribute('data-source-turn-key', 'msg-1') + expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', { + sourceTurnKey: 'msg-1', + sourceElementId: row.id, + }) + }) +}) + describe('CurrentTurnChangeCard – collapse long file lists', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index 896d3c7e..12808927 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react' import type { MouseEvent as ReactMouseEvent } from 'react' -import { ChevronDown, ChevronUp } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react' import type { SessionTurnCheckpoint } from '../../api/sessions' import { useTranslation, type TranslationKey } from '../../i18n' import { OpenWithMenu } from '../common/OpenWithMenu' @@ -59,7 +59,12 @@ export function CurrentTurnChangeCard({ ? files.slice(0, COLLAPSED_COUNT) : files - const openChangedFile = useCallback((fileEntry: ChangedFileEntry) => { + const openChangedFile = useCallback((event: ReactMouseEvent, fileEntry: ChangedFileEntry) => { + const renderItem = event.currentTarget.closest('[data-chat-render-item-key]') + const origin = { + sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId, + sourceElementId: event.currentTarget.id, + } // A changed file outside the workdir (absolute displayPath — e.g. another // drive) has no checkpoint baseline, so a diff is meaningless. Render html in // the in-app browser and everything else as a file preview (served by its @@ -69,14 +74,14 @@ export function CurrentTurnChangeCard({ useBrowserPanelStore.getState().open(sessionId, localFileUrl(getServerBaseUrl(), fileEntry.apiPath)) return } - void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file') + void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file', origin) return } // Jump to the right-side workspace and open a diff tab. We pass the workDir-relative // path (same format the workspace file tree passes to openPreview), so the diff tab // is keyed/fetched identically to the tree-driven one. - void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff') - }, [sessionId, files]) + void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff', origin) + }, [checkpoint.target.targetUserMessageId, sessionId, files]) const handleOpenWith = useCallback((event: ReactMouseEvent, fileEntry: ChangedFileEntry) => { event.stopPropagation() @@ -113,7 +118,7 @@ export function CurrentTurnChangeCard({ : t('chat.turnChangesHistoricalCardLabel') const subtitle = isLatest ? t('chat.turnChangesLatestSubtitle') - : t('chat.turnChangesHistoricalSubtitle') + : t('chat.turnChangesCurrentWorkspaceDiff') const undoLabel = isLatest ? t('chat.turnChangesLatestUndo') : t('chat.turnChangesHistoricalUndo') @@ -165,7 +170,9 @@ export function CurrentTurnChangeCard({
{previewable && (
+ )} + actions={[ + { + label: t('common.cancel'), + onClick: onClose, + variant: 'secondary', + }, + { + label: t('permMode.enableAutoBtn'), + onClick: onConfirm, + variant: 'primary', + loading, + }, + ]} + /> + ) +} diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index ffc012d5..d9a6f969 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { ModelSelector } from './ModelSelector' import { useChatStore } from '../../stores/chatStore' import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore' import { useProviderStore } from '../../stores/providerStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' @@ -32,11 +33,13 @@ afterEach(() => { useChatStore.setState(useChatStore.getInitialState(), true) useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true) useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true) + useHahaGrokOAuthStore.setState(useHahaGrokOAuthStore.getInitialState(), true) }) beforeEach(() => { useHahaOAuthStore.setState({ fetchStatus: async () => {} }) useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} }) + useHahaGrokOAuthStore.setState({ fetchStatus: async () => {} }) }) describe('ModelSelector', () => { @@ -295,8 +298,8 @@ describe('ModelSelector', () => { render() - await clickByRole(/provider-main/i) - await clickByRole(/^High$/) + await clickByRole('Effort: Max') + fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'ArrowLeft' }) expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ providerId: 'provider-a', @@ -420,6 +423,17 @@ describe('ModelSelector', () => { render() + expect(screen.getByRole('button', { name: 'GPT-5.6-Sol, ChatGPT Official' })).toHaveAttribute( + 'title', + 'ChatGPT Official · GPT-5.6-Sol', + ) + expect(screen.queryByTestId('model-provider-badge')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Effort: Max' })).toBeInTheDocument() + await clickByRole('Effort: Max') + expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuemax', '4') + expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5) + fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'Escape' }) + await clickByRole(/GPT-5\.6-Sol/i) await clickByRole(/GPT-5\.5/) @@ -429,9 +443,11 @@ describe('ModelSelector', () => { effortLevel: 'medium', }) - await clickByRole(/GPT-5\.5/i) - expect(screen.queryByRole('button', { name: /^Max$/ })).not.toBeInTheDocument() - await clickByRole(/^X-High$/) + expect(screen.getByRole('button', { name: 'Effort: Medium' })).toBeInTheDocument() + await clickByRole('Effort: Medium') + expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuemax', '3') + fireEvent.keyDown(screen.getByRole('slider', { name: 'Effort' }), { key: 'End' }) + expect(screen.getByRole('slider', { name: 'Effort' })).toHaveAttribute('aria-valuetext', 'X-High') expect(useSessionRuntimeStore.getState().selections['session-openai-effort']).toEqual({ providerId: OPENAI_OFFICIAL_PROVIDER_ID, @@ -440,9 +456,93 @@ describe('ModelSelector', () => { }) }) + it('selects Grok Official models for a logged-in runtime', async () => { + const grokModels: ModelInfo[] = [{ + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Grok frontier text model', + context: '', + supportedReasoningEfforts: [], + }] + useHahaGrokOAuthStore.setState({ + status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' }, + fetchStatus: async () => {}, + }) + useSettingsStore.setState({ + locale: 'en', + availableModels: grokModels, + currentModel: grokModels[0], + activeProviderName: 'Grok Official', + }) + useProviderStore.setState({ + providers: [], + activeId: 'grok-official', + hasLoadedProviders: true, + isLoading: false, + }) + + render() + await clickByRole(/Grok 4\.5/i) + await act(async () => { + fireEvent.click(screen.getAllByRole('button', { name: /Grok 4\.5/i })[1]!) + await Promise.resolve() + }) + + expect(useSessionRuntimeStore.getState().selections['session-grok']).toMatchObject({ + providerId: 'grok-official', + modelId: 'grok-4.5', + }) + expect(screen.queryByRole('button', { name: /Effort:/i })).not.toBeInTheDocument() + }) + + it('replaces a stale Grok runtime model with the current official default', async () => { + const grokModels: ModelInfo[] = [{ + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Grok frontier text model', + context: '500000', + defaultReasoningEffort: 'high', + supportedReasoningEfforts: ['low', 'medium', 'high'], + }] + useHahaGrokOAuthStore.setState({ + status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' }, + fetchStatus: async () => {}, + }) + useSettingsStore.setState({ + locale: 'en', + availableModels: grokModels, + currentModel: grokModels[0], + activeProviderName: 'Grok Official', + effortLevel: 'max', + }) + useProviderStore.setState({ + providers: [], + activeId: 'grok-official', + hasLoadedProviders: true, + isLoading: false, + }) + useSessionRuntimeStore.getState().setSelection('session-stale-grok', { + providerId: 'grok-official', + modelId: 'grok-build', + effortLevel: 'max', + }) + render() + + expect(screen.queryByText('grok-build')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Grok 4.5, Grok Official' })).toBeInTheDocument() + await waitFor(() => { + expect(useSessionRuntimeStore.getState().selections['session-stale-grok']).toEqual({ + providerId: 'grok-official', + modelId: 'grok-4.5', + effortLevel: 'high', + }) + }) + }) + it('hides official provider sections when OAuth is not logged in', async () => { useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) + useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} }) useSettingsStore.setState({ locale: 'en', availableModels: MODELS, diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index d1afa6dc..666bfdd9 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -18,7 +18,13 @@ import { isDesktopRuntime } from '../../lib/desktopRuntime' import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection' import { useHahaOAuthStore } from '../../stores/hahaOAuthStore' import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore' +import { + GROK_OFFICIAL_MODELS, + GROK_OFFICIAL_PROVIDER_ID, +} from '../../constants/grokOfficialProvider' import { MobileBottomSheet } from '../shared/MobileBottomSheet' +import { ReasoningEffortPopover } from './ReasoningEffortPopover' type ProviderChoice = { providerId: string | null @@ -107,9 +113,11 @@ function buildProviderChoices( availableModels: ModelInfo[], officialName: string, openAIOfficialName: string, + grokOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, claudeOfficialLoggedIn: boolean, openAIOfficialLoggedIn: boolean, + grokOfficialLoggedIn: boolean, ): ProviderChoice[] { const claudeOfficialModels = activeId === null && availableModels.length > 0 ? availableModels @@ -117,6 +125,9 @@ function buildProviderChoices( const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0 ? availableModels : OPENAI_OFFICIAL_MODELS + const grokOfficialModels = activeId === GROK_OFFICIAL_PROVIDER_ID && availableModels.length > 0 + ? availableModels + : GROK_OFFICIAL_MODELS const choices: ProviderChoice[] = [] @@ -131,6 +142,14 @@ function buildProviderChoices( openAIOfficialName, )) } + if (grokOfficialLoggedIn) { + choices.push(officialChoices( + GROK_OFFICIAL_PROVIDER_ID, + grokOfficialModels, + activeId === GROK_OFFICIAL_PROVIDER_ID, + grokOfficialName, + )) + } for (const provider of providers) { choices.push({ @@ -172,12 +191,16 @@ export const ModelSelector = forwardRef(function Mod const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus) const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status) const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus) + const grokOAuthStatus = useHahaGrokOAuthStore((s) => s.status) + const fetchGrokOAuthStatus = useHahaGrokOAuthStore((s) => s.fetchStatus) const runtimeSelection = useSessionRuntimeStore((state) => runtimeKey ? state.selections[runtimeKey] : undefined, ) const [open, setOpen] = useState(false) + const [effortOpen, setEffortOpen] = useState(false) const [dropdownPosition, setDropdownPosition] = useState(null) const ref = useRef(null) + const effortButtonRef = useRef(null) const dropdownRef = useRef(null) const requestedProvidersRef = useRef(false) const requestedOAuthStatusRef = useRef(false) @@ -189,6 +212,13 @@ export const ModelSelector = forwardRef(function Mod { value: 'xhigh', label: t('settings.general.effort.xhigh') }, { value: 'max', label: t('settings.general.effort.max') }, ] + const effortLabels: Record = { + low: t('settings.general.effort.low'), + medium: t('settings.general.effort.medium'), + high: t('settings.general.effort.high'), + xhigh: t('settings.general.effort.xhigh'), + max: t('settings.general.effort.max'), + } const isControlled = value !== undefined const isRuntimeScoped = @@ -207,10 +237,14 @@ export const ModelSelector = forwardRef(function Mod requestedOAuthStatusRef.current = true void fetchClaudeOAuthStatus() void fetchOpenAIOAuthStatus() - }, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open]) + void fetchGrokOAuthStatus() + }, [fetchClaudeOAuthStatus, fetchGrokOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open]) const openSelector = useCallback(() => { - if (!disabled) setOpen(true) + if (!disabled) { + setEffortOpen(false) + setOpen(true) + } }, [disabled]) useImperativeHandle(selectorRef, () => ({ @@ -305,11 +339,13 @@ export const ModelSelector = forwardRef(function Mod availableModels, t('settings.providers.officialName'), t('settings.providers.openaiOfficialName'), + t('settings.providers.grokOfficialName'), roleLabels, claudeOAuthStatus?.loggedIn === true, openAIOAuthStatus?.loggedIn === true, + grokOAuthStatus?.loggedIn === true, ), - [activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus], + [activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, grokOAuthStatus, openAIOAuthStatus], ) const selectedModel = isControlled @@ -345,13 +381,15 @@ export const ModelSelector = forwardRef(function Mod const buttonProviderLabel = isRuntimeScoped ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') : null - const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel - ?? selectedRuntimeModel?.defaultReasoningEffort - ?? effortLevel const supportedRuntimeEfforts = selectedRuntimeModel?.supportedReasoningEfforts - const runtimeEffortOptions = supportedRuntimeEfforts?.length - ? EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value)) - : EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh') + const selectedRuntimeEffort = supportedRuntimeEfforts?.length === 0 + ? undefined + : activeRuntimeSelection?.effortLevel + ?? selectedRuntimeModel?.defaultReasoningEffort + ?? effortLevel + const runtimeEffortOptions = supportedRuntimeEfforts === undefined + ? EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh') + : EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value)) const handleRuntimeSelect = (selection: RuntimeSelection) => { onRuntimeSelectionChange?.(selection) @@ -407,11 +445,13 @@ export const ModelSelector = forwardRef(function Mod onClick={() => { const supportedEfforts = model.supportedReasoningEfforts const explicitEffort = activeRuntimeSelection?.effortLevel - const nextEffort = supportedEfforts?.length - ? explicitEffort && supportedEfforts.includes(explicitEffort) - ? explicitEffort - : model.defaultReasoningEffort ?? supportedEfforts[0] - : explicitEffort ?? effortLevel + const nextEffort = supportedEfforts === undefined + ? explicitEffort ?? effortLevel + : supportedEfforts.length + ? explicitEffort && supportedEfforts.includes(explicitEffort) + ? explicitEffort + : model.defaultReasoningEffort ?? supportedEfforts[0] + : undefined handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id, @@ -503,35 +543,6 @@ export const ModelSelector = forwardRef(function Mod )}
- {canEditRuntimeEffort && ( -
-
- {t('model.effort')} -
-
- {runtimeEffortOptions.map((opt) => { - const isSelected = opt.value === selectedRuntimeEffort - return ( - - ) - })} -
-
- )} ) @@ -568,27 +579,63 @@ export const ModelSelector = forwardRef(function Mod : null return ( -
-
- expand_more - + expand_more + + + {canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && ( + + )} +
{dropdown} + {canEditRuntimeEffort && selectedRuntimeEffort && ( + option.value)} + value={selectedRuntimeEffort} + labels={effortLabels} + ariaLabel={t('model.effort')} + onChange={handleRuntimeEffortSelect} + onClose={() => setEffortOpen(false)} + /> + )}
) }) diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index 3945bcb8..0488a338 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' @@ -21,6 +21,8 @@ vi.mock('../../i18n', () => ({ 'permMode.askPermDesc': 'Ask before changing files or running commands', 'permMode.autoAccept': 'Auto accept edits', 'permMode.autoAcceptDesc': 'Automatically accept edit operations', + 'permMode.autoMode': 'Auto mode', + 'permMode.autoModeDesc': 'Automatically review tool calls before running them', 'permMode.planMode': 'Plan mode', 'permMode.planModeDesc': 'Plan before executing', 'permMode.bypass': 'Bypass permissions', @@ -28,6 +30,7 @@ vi.mock('../../i18n', () => ({ 'permMode.executionPermissions': 'Execution Permissions', 'permMode.label.default': 'Ask permissions', 'permMode.label.acceptEdits': 'Auto accept edits', + 'permMode.label.auto': 'Auto mode', 'permMode.label.plan': 'Plan mode', 'permMode.label.bypassPermissions': 'Bypass permissions', 'permMode.label.dontAsk': 'Bypass permissions', @@ -39,6 +42,10 @@ vi.mock('../../i18n', () => ({ 'permMode.permPackages': 'Install packages', 'permMode.enableBypassBtn': 'Enable bypass', 'permMode.disabledDuringTurn': 'Cannot switch permissions while session is active', + 'permMode.enableAutoTitle': 'Enable Auto mode?', + 'permMode.enableAutoBody': 'Auto mode reduces prompts but does not guarantee safety.', + 'permMode.enableAutoDetail': 'Claude reviews tool calls and blocks actions it considers risky.', + 'permMode.enableAutoBtn': 'Enable Auto mode', 'common.cancel': 'Cancel', 'tabs.close': 'Close', }[key] ?? key), @@ -49,8 +56,10 @@ import { useChatStore, type PerSessionState } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' const initialSetSessionPermissionMode = useChatStore.getState().setSessionPermissionMode +const initialAcceptAutoModeOptIn = useSettingsStore.getState().acceptAutoModeOptIn function makeChatSession(chatState: PerSessionState['chatState']): PerSessionState { return { @@ -77,13 +86,18 @@ function makeChatSession(chatState: PerSessionState['chatState']): PerSessionSta describe('PermissionModeSelector', () => { beforeEach(() => { viewportMocks.isMobile = false - useSettingsStore.setState({ permissionMode: 'default' }) + useSettingsStore.setState({ + permissionMode: 'default', + autoModeOptInAccepted: false, + acceptAutoModeOptIn: initialAcceptAutoModeOptIn, + }) useChatStore.setState({ sessions: {}, setSessionPermissionMode: initialSetSessionPermissionMode, }) useSessionStore.setState({ sessions: [], activeSessionId: null }) useTabStore.setState({ activeTabId: null, tabs: [] }) + useUIStore.setState({ toasts: [] }) }) it('updates the active session without writing the global default mode', () => { @@ -418,6 +432,214 @@ describe('PermissionModeSelector', () => { expect(onChange).toHaveBeenCalledWith('acceptEdits') }) + it('shows Auto beside the existing permission modes', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + + expect(screen.getByRole('menuitem', { name: /Auto mode/ })).toBeInTheDocument() + }) + + it('uses the automatic-execution glyph for Auto mode', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + const autoItem = screen.getByRole('menuitem', { name: /Auto mode/ }) + expect(autoItem.querySelector('.material-symbols-outlined')).toHaveTextContent('autoplay') + expect(autoItem.querySelector('.material-symbols-outlined')).not.toHaveTextContent('auto_awesome') + + rerender() + expect(screen.getByRole('button', { name: 'Auto mode' })) + .toHaveTextContent('autoplay') + }) + + it('renders the visually larger Auto glyph at a reduced size', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + const autoIcon = screen + .getByRole('menuitem', { name: /Auto mode/ }) + .querySelector('.material-symbols-outlined') + expect(autoIcon).toHaveClass('text-[18px]') + + rerender() + const triggerIcon = screen + .getByRole('button', { name: 'Auto mode' }) + .querySelector('.material-symbols-outlined') + expect(triggerIcon).toHaveClass('text-[12px]') + }) + + it('does not change mode when first-use Auto confirmation is cancelled', () => { + const onChange = vi.fn() + useSettingsStore.setState({ autoModeOptInAccepted: false } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + + expect(screen.getByRole('dialog', { name: 'Enable Auto mode?' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onChange).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', { name: 'Enable Auto mode?' })).not.toBeInTheDocument() + }) + + it('persists first-use consent before selecting Auto', async () => { + const onChange = vi.fn() + const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined) + useSettingsStore.setState({ + autoModeOptInAccepted: false, + acceptAutoModeOptIn, + } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' })) + + await waitFor(() => { + expect(acceptAutoModeOptIn).toHaveBeenCalledOnce() + expect(onChange).toHaveBeenCalledWith('auto') + }) + }) + + it('confirms every entry into Auto without rewriting prior consent', async () => { + const onChange = vi.fn() + const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined) + useSettingsStore.setState({ + autoModeOptInAccepted: true, + acceptAutoModeOptIn, + } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + + expect(onChange).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: 'Enable Auto mode?' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' })) + + await waitFor(() => expect(onChange).toHaveBeenCalledWith('auto')) + expect(acceptAutoModeOptIn).not.toHaveBeenCalled() + }) + + it('applies first-use Auto consent to the active session', async () => { + const setSessionPermissionMode = vi.fn() + const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined) + useSettingsStore.setState({ + autoModeOptInAccepted: false, + acceptAutoModeOptIn, + } as never) + useChatStore.setState({ + setSessionPermissionMode, + sessions: { + 'current-tab': makeChatSession('idle'), + }, + } as Partial>) + useTabStore.setState({ + activeTabId: 'current-tab', + tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }], + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' })) + + await waitFor(() => { + expect(setSessionPermissionMode).toHaveBeenCalledWith('current-tab', 'auto') + }) + }) + + it('does not apply Auto when the active tab changes while consent is saving', async () => { + let resolveConsent!: () => void + const onChange = vi.fn() + const acceptAutoModeOptIn = vi.fn(() => new Promise((resolve) => { + resolveConsent = resolve + })) + useSettingsStore.setState({ + autoModeOptInAccepted: false, + acceptAutoModeOptIn, + } as never) + useTabStore.setState({ + activeTabId: 'current-tab', + tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }], + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' })) + act(() => { + useTabStore.setState({ + activeTabId: 'next-tab', + tabs: [{ sessionId: 'next-tab', title: 'Next', type: 'session', status: 'idle' }], + }) + resolveConsent() + }) + + await waitFor(() => expect(acceptAutoModeOptIn).toHaveBeenCalledOnce()) + expect(onChange).not.toHaveBeenCalled() + }) + + it('keeps the Auto confirmation open and reports a consent persistence failure', async () => { + const onChange = vi.fn() + const acceptAutoModeOptIn = vi.fn().mockRejectedValue(new Error('Could not save Auto consent')) + useSettingsStore.setState({ + autoModeOptInAccepted: false, + acceptAutoModeOptIn, + } as never) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ })) + fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' })) + + await waitFor(() => { + expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ + type: 'error', + message: 'Could not save Auto consent', + }) + }) + expect(screen.getByRole('dialog', { name: 'Enable Auto mode?' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Enable Auto mode' })).toBeEnabled() + expect(onChange).not.toHaveBeenCalled() + }) + + it('keeps Auto behind the active-turn guard', () => { + const setSessionPermissionMode = vi.fn() + useSettingsStore.setState({ autoModeOptInAccepted: true } as never) + useChatStore.setState({ + setSessionPermissionMode, + sessions: { + 'current-tab': makeChatSession('thinking'), + }, + } as Partial>) + useTabStore.setState({ + activeTabId: 'current-tab', + tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'running' }], + }) + + render() + + const trigger = screen.getByRole('button', { name: 'Ask permissions' }) + expect(trigger).toBeDisabled() + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /Auto mode/ })).not.toBeInTheDocument() + expect(setSessionPermissionMode).not.toHaveBeenCalled() + }) + it('closes the permission menu when its trigger is clicked again', () => { render() diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index af9938b5..3fbc7cb3 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -4,16 +4,19 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { PermissionMode } from '../../types/settings' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isDesktopRuntime } from '../../lib/desktopRuntime' import { MobileBottomSheet } from '../shared/MobileBottomSheet' import { ActionDialog } from '../shared/ActionDialog' +import { AutoModeOptInDialog } from './AutoModeOptInDialog' const MODE_ICONS: Record = { default: 'verified_user', acceptEdits: 'bolt', + auto: 'autoplay', plan: 'architecture', bypassPermissions: 'gavel', dontAsk: 'gavel', @@ -32,7 +35,11 @@ type Props = { export function PermissionModeSelector({ workDir: workDirProp, compact = false, menuPlacement = 'top', value, onChange }: Props = {}) { const t = useTranslation() const isMobile = useMobileViewport() && !isDesktopRuntime() - const { permissionMode: storeMode } = useSettingsStore() + const { + permissionMode: storeMode, + autoModeOptInAccepted, + acceptAutoModeOptIn, + } = useSettingsStore() const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) const activeTabId = useTabStore((s) => s.activeTabId) const sessions = useSessionStore((s) => s.sessions) @@ -46,6 +53,8 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, } const [open, setOpen] = useState(false) const [confirmDialog, setConfirmDialog] = useState(false) + const [autoDialog, setAutoDialog] = useState(false) + const [autoConsentPending, setAutoConsentPending] = useState(false) const interactionTabIdRef = useRef(null) const ref = useRef(null) const menuRef = useRef(null) @@ -70,6 +79,13 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, description: t('permMode.autoAcceptDesc'), icon: 'bolt', }, + { + value: 'auto', + label: t('permMode.autoMode'), + description: t('permMode.autoModeDesc'), + icon: 'autoplay', + color: 'text-[var(--color-brand)]', + }, { value: 'plan', label: t('permMode.planMode'), @@ -89,6 +105,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const MODE_LABELS: Record = { default: t('permMode.label.default'), acceptEdits: t('permMode.label.acceptEdits'), + auto: t('permMode.label.auto'), plan: t('permMode.label.plan'), bypassPermissions: t('permMode.label.bypassPermissions'), dontAsk: t('permMode.label.dontAsk'), @@ -115,20 +132,22 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, if (isTurnActive) { setOpen(false) setConfirmDialog(false) + setAutoDialog(false) interactionTabIdRef.current = null } }, [isTurnActive]) useEffect(() => { if ( - (open || confirmDialog) && + (open || confirmDialog || autoDialog) && activeTabId !== interactionTabIdRef.current ) { setOpen(false) setConfirmDialog(false) + setAutoDialog(false) interactionTabIdRef.current = null } - }, [activeTabId, confirmDialog, open]) + }, [activeTabId, autoDialog, confirmDialog, open]) useEffect(() => { if (!open) return @@ -167,9 +186,15 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, ) { setOpen(false) setConfirmDialog(false) + setAutoDialog(false) interactionTabIdRef.current = null return } + if (item.value === 'auto' && item.value !== currentMode) { + setOpen(false) + setAutoDialog(true) + return + } if (item.value === 'bypassPermissions') { setOpen(false) setConfirmDialog(true) @@ -189,7 +214,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, ${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''} `} > - + {item.icon}
@@ -239,7 +264,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, isTurnActive ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--color-surface-hover)]' } ${compactButtonClass}`} > - {MODE_ICONS[currentMode]} + + {MODE_ICONS[currentMode]} + {!compact && ( <> {MODE_LABELS[currentMode]} @@ -337,6 +364,55 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, }, ]} /> + + { + if (autoConsentPending) return + setAutoDialog(false) + interactionTabIdRef.current = null + }} + onConfirm={async () => { + const actionTabId = useTabStore.getState().activeTabId + if ( + actionTabId !== interactionTabIdRef.current || + isTurnActiveNow(actionTabId) + ) { + setAutoDialog(false) + interactionTabIdRef.current = null + return + } + + setAutoConsentPending(true) + try { + if (!autoModeOptInAccepted) { + await acceptAutoModeOptIn() + } + const confirmedTabId = useTabStore.getState().activeTabId + if ( + confirmedTabId !== interactionTabIdRef.current || + isTurnActiveNow(confirmedTabId) + ) { + return + } + if (isControlled) { + onChange?.('auto') + } else if (confirmedTabId) { + setSessionPermissionMode(confirmedTabId, 'auto') + } + setAutoDialog(false) + interactionTabIdRef.current = null + } catch (err) { + useUIStore.getState().addToast({ + type: 'error', + message: err instanceof Error ? err.message : t('common.error'), + }) + } finally { + setAutoConsentPending(false) + } + }} + />
) } diff --git a/desktop/src/components/controls/ReasoningEffortPopover.test.tsx b/desktop/src/components/controls/ReasoningEffortPopover.test.tsx new file mode 100644 index 00000000..10cf4d13 --- /dev/null +++ b/desktop/src/components/controls/ReasoningEffortPopover.test.tsx @@ -0,0 +1,127 @@ +import { createRef } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ReasoningEffortPopover } from './ReasoningEffortPopover' + +const options = ['low', 'medium', 'high', 'xhigh', 'max'] as const +const labels = { + low: '低', + medium: '中', + high: '高', + xhigh: '极高', + max: '最大', +} + +afterEach(cleanup) + +function renderPopover(overrides: Partial> = {}) { + const anchorRef = createRef() + const onChange = vi.fn() + const onClose = vi.fn() + const view = render( + <> + + + + , + ) + return { ...view, anchorRef, onChange, onClose } +} + +describe('ReasoningEffortPopover', () => { + it('keeps the effort visual compact without non-functional icon controls', () => { + renderPopover() + + const popover = screen.getByTestId('reasoning-effort-popover') + expect(popover).toHaveStyle({ width: '240px' }) + expect(popover).toHaveClass('px-3.5', 'pb-3.5', 'pt-3') + expect(popover.querySelectorAll('svg')).toHaveLength(0) + expect(screen.getByTestId('reasoning-effort-header')).toHaveClass('mb-2.5', 'justify-between') + expect(screen.getByTestId('reasoning-effort-label')).toHaveClass('text-sm') + expect(screen.getByTestId('reasoning-effort-context-label')).toHaveClass('text-[10px]') + expect(screen.getByTestId('reasoning-effort-context-label')).toHaveTextContent('推理强度') + expect(screen.getByRole('slider', { name: '推理强度' })).toHaveClass('h-9') + expect(screen.getByTestId('reasoning-effort-track')).toHaveClass('h-6') + expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('h-8', 'w-8') + }) + + it('renders every model-supported stop and exposes the selected localized value', () => { + renderPopover() + + const slider = screen.getByRole('slider', { name: '推理强度' }) + expect(slider).toHaveAttribute('aria-valuemin', '0') + expect(slider).toHaveAttribute('aria-valuemax', '4') + expect(slider).toHaveAttribute('aria-valuenow', '3') + expect(slider).toHaveAttribute('aria-valuetext', '极高') + expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5) + expect(screen.getByText('极高')).toBeInTheDocument() + expect(screen.getByTestId('reasoning-effort-fill')).toHaveClass('bg-[var(--color-brand)]') + expect(slider).toHaveClass('focus-visible:ring-[var(--color-brand)]') + }) + + it('selects a discrete stop from the track', () => { + const { onChange } = renderPopover() + const slider = screen.getByRole('slider', { name: '推理强度' }) + vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 400, + height: 48, + top: 0, + right: 400, + bottom: 48, + left: 0, + toJSON: () => ({}), + }) + + fireEvent.click(slider, { clientX: 200 }) + + expect(onChange).toHaveBeenCalledWith('high') + }) + + it('supports keyboard navigation and clamps at supported endpoints', () => { + const { onChange, rerender, anchorRef } = renderPopover({ value: 'low' }) + const slider = screen.getByRole('slider', { name: '推理强度' }) + + fireEvent.keyDown(slider, { key: 'ArrowLeft' }) + fireEvent.keyDown(slider, { key: 'ArrowRight' }) + fireEvent.keyDown(slider, { key: 'End' }) + + expect(onChange.mock.calls).toEqual([['medium'], ['max']]) + + rerender( + , + ) + fireEvent.keyDown(screen.getByRole('slider', { name: '推理强度' }), { key: 'ArrowRight' }) + expect(onChange.mock.calls).toEqual([['medium'], ['max']]) + }) + + it('closes on Escape and outside pointer interaction', () => { + const { onClose } = renderPopover() + const slider = screen.getByRole('slider', { name: '推理强度' }) + + fireEvent.keyDown(slider, { key: 'Escape' }) + fireEvent.pointerDown(screen.getByRole('button', { name: '外部区域' })) + + expect(onClose).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop/src/components/controls/ReasoningEffortPopover.tsx b/desktop/src/components/controls/ReasoningEffortPopover.tsx new file mode 100644 index 00000000..c87666d0 --- /dev/null +++ b/desktop/src/components/controls/ReasoningEffortPopover.tsx @@ -0,0 +1,214 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import type { ReasoningEffortLevel } from '../../types/settings' + +type Props = { + open: boolean + anchorRef: React.RefObject + options: ReasoningEffortLevel[] + value: ReasoningEffortLevel + labels: Record + onChange: (value: ReasoningEffortLevel) => void + onClose: () => void + ariaLabel?: string +} + +type PopoverPosition = { + bottom: number + left: number + width: number +} + +const POPOVER_WIDTH = 240 +const VIEWPORT_MARGIN = 16 +const POPOVER_GAP = 10 + +export function ReasoningEffortPopover({ + open, + anchorRef, + options, + value, + labels, + onChange, + onClose, + ariaLabel = '推理强度', +}: Props) { + const popoverRef = useRef(null) + const sliderRef = useRef(null) + const draggingRef = useRef(false) + const [position, setPosition] = useState(null) + const selectedIndex = Math.max(0, options.indexOf(value)) + const maxIndex = Math.max(0, options.length - 1) + const fillPercent = maxIndex === 0 ? 0 : (selectedIndex / maxIndex) * 100 + + useLayoutEffect(() => { + if (!open) { + setPosition(null) + return + } + + const updatePosition = () => { + const rect = anchorRef.current?.getBoundingClientRect() + const viewportWidth = window.innerWidth || document.documentElement.clientWidth + const width = Math.min(POPOVER_WIDTH, viewportWidth - VIEWPORT_MARGIN * 2) + const anchorRight = rect?.right ?? viewportWidth - VIEWPORT_MARGIN + const anchorTop = rect?.top ?? window.innerHeight / 2 + const left = Math.min( + Math.max(VIEWPORT_MARGIN, anchorRight - width), + Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN), + ) + setPosition({ + bottom: Math.max(VIEWPORT_MARGIN, window.innerHeight - anchorTop + POPOVER_GAP), + left, + width, + }) + } + + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [anchorRef, open]) + + useEffect(() => { + if (!open) return + const handleOutsidePointer = (event: PointerEvent) => { + const target = event.target as Node + if (!popoverRef.current?.contains(target) && !anchorRef.current?.contains(target)) { + onClose() + } + } + document.addEventListener('pointerdown', handleOutsidePointer) + return () => document.removeEventListener('pointerdown', handleOutsidePointer) + }, [anchorRef, onClose, open]) + + if (!open || !position || options.length === 0) return null + + const selectFromClientX = (clientX: number) => { + const rect = sliderRef.current?.getBoundingClientRect() + if (!rect || rect.width === 0) return + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) + const nextIndex = Math.round(ratio * maxIndex) + const nextValue = options[nextIndex] + if (nextValue && nextValue !== value) onChange(nextValue) + } + + const moveBy = (offset: number) => { + const nextIndex = Math.min(maxIndex, Math.max(0, selectedIndex + offset)) + const nextValue = options[nextIndex] + if (nextValue && nextValue !== value) onChange(nextValue) + } + + return createPortal( +
+
+
+ {labels[value]} +
+
+ {ariaLabel} +
+
+ +
selectFromClientX(event.clientX)} + onPointerDown={(event) => { + draggingRef.current = true + event.currentTarget.setPointerCapture?.(event.pointerId) + selectFromClientX(event.clientX) + }} + onPointerMove={(event) => { + if (draggingRef.current) selectFromClientX(event.clientX) + }} + onPointerUp={(event) => { + if (!draggingRef.current) return + draggingRef.current = false + selectFromClientX(event.clientX) + event.currentTarget.releasePointerCapture?.(event.pointerId) + }} + onPointerCancel={() => { + draggingRef.current = false + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose() + anchorRef.current?.focus() + return + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') { + event.preventDefault() + moveBy(-1) + } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') { + event.preventDefault() + moveBy(1) + } else if (event.key === 'Home') { + event.preventDefault() + const firstValue = options[0] + if (firstValue && firstValue !== value) onChange(firstValue) + } else if (event.key === 'End') { + event.preventDefault() + const lastValue = options[maxIndex] + if (lastValue && lastValue !== value) onChange(lastValue) + } + }} + > +
+
+
+ +
+ {options.map((option, index) => ( + + ))} +
+ + +
, + document.body, + ) +} diff --git a/desktop/src/components/doctor/DoctorPanel.tsx b/desktop/src/components/doctor/DoctorPanel.tsx index cb091d86..d536af96 100644 --- a/desktop/src/components/doctor/DoctorPanel.tsx +++ b/desktop/src/components/doctor/DoctorPanel.tsx @@ -1,9 +1,16 @@ -import { Stethoscope } from 'lucide-react' -import { useState } from 'react' -import { Button } from '../shared/Button' +import { RotateCcw, Stethoscope } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { DoctorReport, DoctorReportItem } from '../../api/doctor' import { useTranslation } from '../../i18n' -import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair' +import { + runDoctorCheck, + runLocalDoctorRepair, + type LocalDoctorRepairResult, +} from '../../lib/doctorRepair' +import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' +import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' type DoctorPanelProps = { compact?: boolean @@ -12,32 +19,104 @@ type DoctorPanelProps = { export function DoctorPanel({ compact = false }: DoctorPanelProps) { const t = useTranslation() const addToast = useUIStore((s) => s.addToast) - const [isRunning, setIsRunning] = useState(false) - const [result, setResult] = useState(null) + const activeSessionId = useSessionStore((s) => s.activeSessionId) + const sessions = useSessionStore((s) => s.sessions) + const activeSession = useMemo( + () => sessions.find((session) => session.id === activeSessionId), + [activeSessionId, sessions], + ) + const cwd = activeSession?.workDirExists === false + ? undefined + : activeSession?.workDir ?? activeSession?.projectRoot ?? undefined + const requestSequence = useRef(0) + const cwdRef = useRef(cwd) + cwdRef.current = cwd + const [runningRequestId, setRunningRequestId] = useState(null) + const [resettingRequestId, setResettingRequestId] = useState(null) + const [resetConfirmOpen, setResetConfirmOpen] = useState(false) + const [reportResult, setReportResult] = useState<{ cwd?: string; report: DoctorReport } | null>(null) + const [resetResult, setResetResult] = useState(null) + const report = reportResult && reportResult.cwd === cwd ? reportResult.report : null + + useEffect(() => { + requestSequence.current += 1 + setRunningRequestId(null) + setResettingRequestId(null) + setReportResult(null) + }, [cwd]) + + const beginReportRequest = () => { + const requestId = ++requestSequence.current + const requestCwd = cwd + return { + requestId, + requestCwd, + response: runDoctorCheck({ cwd: requestCwd }), + } + } + + const isCurrentRequest = (requestId: number, requestCwd?: string) => { + return requestSequence.current === requestId && cwdRef.current === requestCwd + } const handleRunDoctor = async () => { - setIsRunning(true) + const request = beginReportRequest() + setResettingRequestId(null) + setRunningRequestId(request.requestId) try { - const nextResult = await runDoctorRepair() - setResult(nextResult) - addToast({ - type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning', - message: getDoctorToastMessage(t, nextResult), - }) + const nextReport = await request.response + if (!isCurrentRequest(request.requestId, request.requestCwd)) return + setReportResult({ cwd: request.requestCwd, report: nextReport }) + addToast({ type: 'success', message: t('settings.diagnostics.doctorCheckCompleted') }) } catch (error) { + if (!isCurrentRequest(request.requestId, request.requestCwd)) return addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'), }) } finally { - setIsRunning(false) + setRunningRequestId((current) => current === request.requestId ? null : current) } } - const statusText = result ? getDoctorStatusMessage(t, result) : null + const handleResetSafeState = async () => { + let requestId: number | null = null + const requestCwd = cwd + try { + const result = runLocalDoctorRepair() + setResetResult(result) + setResetConfirmOpen(false) + addToast({ + type: result.failedKeys.length === 0 ? 'success' : 'warning', + message: result.failedKeys.length === 0 + ? t('settings.diagnostics.doctorResetCompleted') + : t('settings.diagnostics.doctorPartial', { count: String(result.failedKeys.length) }), + }) + const request = beginReportRequest() + requestId = request.requestId + setRunningRequestId(null) + setResettingRequestId(request.requestId) + const nextReport = await request.response + if (!isCurrentRequest(request.requestId, request.requestCwd)) return + setReportResult({ cwd: request.requestCwd, report: nextReport }) + } catch (error) { + if (requestId !== null && !isCurrentRequest(requestId, requestCwd)) return + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'), + }) + } finally { + setResettingRequestId((current) => current === requestId ? null : current) + } + } + + const unhealthyItems = report?.items.filter( + (item) => item.status !== 'ok' && item.status !== 'not_configured', + ) ?? [] + const healthyCount = report?.items.filter((item) => item.status === 'ok').length ?? 0 return ( -
+
{t('settings.diagnostics.doctorTitle')}
@@ -48,59 +127,109 @@ export function DoctorPanel({ compact = false }: DoctorPanelProps) { {t('settings.diagnostics.doctorProtectedData')}

-
+
+
{t('settings.diagnostics.doctorSafeKeys')}
+
+ {t('settings.diagnostics.doctorScope')}: {cwd + ? t('settings.diagnostics.doctorScopeProject') + : t('settings.diagnostics.doctorScopeUser')} +
- {statusText ? ( -
- {statusText} + {report ? ( +
+
+ {t('settings.diagnostics.doctorSummary', { + healthy: String(healthyCount), + neutral: String(report.summary.neutralCount), + missing: String(report.summary.missingCount), + invalid: String(report.summary.invalidCount), + })} +
+ {unhealthyItems.length === 0 ? ( +
+ {t('settings.diagnostics.doctorNoFindings')} +
+ ) : ( +
+ {unhealthyItems.map((item) => )} +
+ )}
) : null} + + {resetResult ? ( +
+
{t('settings.diagnostics.doctorRemovedKeys')}: {formatKeys(resetResult.removedKeys, t('settings.diagnostics.doctorNoKeys'))}
+
{t('settings.diagnostics.doctorFailedKeys')}: {formatKeys(resetResult.failedKeys, t('settings.diagnostics.doctorNoKeys'))}
+
+ ) : null} + + { + if (resettingRequestId === null) setResetConfirmOpen(false) + }} + onConfirm={handleResetSafeState} + title={t('settings.diagnostics.resetSafeUiState')} + body={t('settings.diagnostics.confirmResetSafeUiState')} + confirmLabel={t('settings.diagnostics.resetSafeUiState')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={resettingRequestId !== null} + />
) } -function getDoctorToastMessage( - t: ReturnType, - result: DoctorRepairResult, -): string { - if (result.local.failedKeys.length > 0) { - return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) }) - } - return t('settings.diagnostics.doctorCompleted') +function DoctorFinding({ item }: { item: DoctorReportItem }) { + const t = useTranslation() + return ( +
+
+ {item.path} + + {getStatusLabel(t, item.status)} + +
+ {item.error ?
{item.error}
: null} +
+ ) } -function getDoctorStatusMessage( - t: ReturnType, - result: DoctorRepairResult, -): string { - const clearedCount = result.local.removedKeys.length - const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) }) - - if (result.local.failedKeys.length > 0) { - return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}` +function getStatusLabel(t: ReturnType, status: DoctorReportItem['status']): string { + switch (status) { + case 'not_configured': return t('settings.diagnostics.doctorStatusNotConfigured') + case 'missing': return t('settings.diagnostics.doctorStatusMissing') + case 'invalid_json': return t('settings.diagnostics.doctorStatusInvalidJson') + case 'invalid_jsonl': return t('settings.diagnostics.doctorStatusInvalidJsonl') + case 'invalid_schema': return t('settings.diagnostics.doctorStatusInvalidSchema') + case 'unreadable': return t('settings.diagnostics.doctorStatusUnreadable') + default: return t('settings.diagnostics.doctorStatusHealthy') } - - if (result.server) { - return `${base} ${t('settings.diagnostics.doctorServerRan')}` - } - - if (result.serverError) { - return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}` - } - - return base +} + +function formatKeys(keys: string[], emptyLabel: string): string { + return keys.length > 0 ? keys.join(', ') : emptyLabel } diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 99c3fde2..ba4e6078 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -295,11 +295,12 @@ describe('TabBar', () => { expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument() }) - it('shows the activity button for completed TodoWrite history without a badge', async () => { + it('shows the activity button for completed TodoWrite history and hides it while the workspace is open', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') const { useSessionStore } = await import('../../stores/sessionStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') const sessionId = 'session-1' const chatSession = makeChatSession('idle') chatSession.messages = [completedTodoWriteMessage()] @@ -324,6 +325,12 @@ describe('TabBar', () => { expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument() expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument() + + act(() => { + useWorkspacePanelStore.getState().openPanel(sessionId) + }) + + expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument() }) it('shows the activity button without a numeric badge for running or failed activity', async () => { diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 83c10d6d..4d3644c3 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -141,7 +141,7 @@ export function TabBar() { hasVisibleActivity: hasVisibleSessionActivity(model), } })) - const showActivityButton = activeTabId && activityState.hasVisibleActivity + const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) diff --git a/desktop/src/components/settings/GrokOfficialLogin.test.tsx b/desktop/src/components/settings/GrokOfficialLogin.test.tsx new file mode 100644 index 00000000..9f67f595 --- /dev/null +++ b/desktop/src/components/settings/GrokOfficialLogin.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +const { copyMock, logoutMock, startMock, statusMock } = vi.hoisted(() => ({ + copyMock: vi.fn(), + logoutMock: vi.fn(), + startMock: vi.fn(), + statusMock: vi.fn(), +})) + +vi.mock('../../api/hahaGrokOAuth', () => ({ + hahaGrokOAuthApi: { + start: startMock, + status: statusMock, + logout: logoutMock, + successUrl: () => 'http://127.0.0.1:3456/api/haha-grok-oauth/success', + }, +})) + +vi.mock('../chat/clipboard', () => ({ copyTextToClipboard: copyMock })) + +import { GrokOfficialLogin } from './GrokOfficialLogin' +import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { browserHost } from '../../lib/desktopHost/browserHost' + +const initialOAuthState = useHahaGrokOAuthStore.getState() + +describe('GrokOfficialLogin', () => { + beforeEach(() => { + statusMock.mockResolvedValue({ loggedIn: false }) + startMock.mockResolvedValue({ + authorizeUrl: 'https://accounts.x.ai/oauth/authorize?state=grok-state', + state: 'grok-state', + }) + copyMock.mockResolvedValue(true) + useSettingsStore.setState({ locale: 'en' }) + useHahaGrokOAuthStore.setState({ + ...initialOAuthState, + status: null, + isPolling: false, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + useHahaGrokOAuthStore.getState().stopPolling() + useHahaGrokOAuthStore.setState(initialOAuthState) + Reflect.deleteProperty(window, 'desktopHost') + cleanup() + vi.restoreAllMocks() + }) + + it('keeps a copyable authorization link when opening the browser fails', async () => { + const open = vi.fn().mockRejectedValue(new Error('shell unavailable')) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { ...browserHost.capabilities, shell: true }, + shell: { ...browserHost.shell, open }, + } + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + await screen.findByRole('button', { name: 'Sign in with Grok' }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' }))) + + expect(open).toHaveBeenCalled() + expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument() + }) + + it('opens the local success page when authorization completes', async () => { + const open = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { ...browserHost.capabilities, shell: true }, + shell: { ...browserHost.shell, open }, + } + + render() + await screen.findByRole('button', { name: 'Sign in with Grok' }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' }))) + expect(open).toHaveBeenCalledWith(expect.stringContaining('accounts.x.ai')) + + act(() => { + useHahaGrokOAuthStore.setState({ + status: { loggedIn: true, expiresAt: Date.now() + 60_000, email: 'grok@example.com' }, + }) + }) + + await waitFor(() => { + expect(open).toHaveBeenCalledWith('http://127.0.0.1:3456/api/haha-grok-oauth/success') + }) + }) +}) diff --git a/desktop/src/components/settings/GrokOfficialLogin.tsx b/desktop/src/components/settings/GrokOfficialLogin.tsx new file mode 100644 index 00000000..9b6bf0a3 --- /dev/null +++ b/desktop/src/components/settings/GrokOfficialLogin.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react' +import { Copy, LogIn, LogOut } from 'lucide-react' +import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore' +import { useTranslation } from '../../i18n' +import { copyTextToClipboard } from '../chat/clipboard' +import { getDesktopHost } from '../../lib/desktopHost' +import { hahaGrokOAuthApi } from '../../api/hahaGrokOAuth' + +export function GrokOfficialLogin() { + const t = useTranslation() + const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState(null) + const [isAwaitingAuthorization, setIsAwaitingAuthorization] = useState(false) + const { status, isLoading, error, fetchStatus, login, logout, startPolling, stopPolling } = + useHahaGrokOAuthStore() + + useEffect(() => { + void fetchStatus() + return () => stopPolling() + }, [fetchStatus, stopPolling]) + + useEffect(() => { + if (status?.loggedIn) setManualAuthorizeUrl(null) + }, [status?.loggedIn]) + + useEffect(() => { + if (!status?.loggedIn || !isAwaitingAuthorization) return + setIsAwaitingAuthorization(false) + void getDesktopHost().shell.open(hahaGrokOAuthApi.successUrl()).catch((err) => { + console.error('[GrokOfficialLogin] success page open failed:', err) + }) + }, [isAwaitingAuthorization, status?.loggedIn]) + + const handleLogin = async () => { + setManualAuthorizeUrl(null) + try { + const { authorizeUrl } = await login() + setManualAuthorizeUrl(authorizeUrl) + try { + await getDesktopHost().shell.open(authorizeUrl) + setManualAuthorizeUrl(null) + setIsAwaitingAuthorization(true) + startPolling() + } catch (err) { + console.error('[GrokOfficialLogin] shellOpen failed:', err) + useHahaGrokOAuthStore.setState({ + error: t('settings.grokOfficialLogin.openBrowserFailed'), + }) + } + } catch { + // Store owns request errors. + } + } + + const handleCopyAuthorizeUrl = async () => { + if (!manualAuthorizeUrl) return + if (await copyTextToClipboard(manualAuthorizeUrl)) { + setManualAuthorizeUrl(null) + setIsAwaitingAuthorization(true) + useHahaGrokOAuthStore.setState({ error: null }) + startPolling() + } else { + useHahaGrokOAuthStore.setState({ + error: t('settings.grokOfficialLogin.copyLinkFailed'), + }) + } + } + + const manualAuthorizeButton = manualAuthorizeUrl ? ( + + ) : null + + if (status === null) { + return ( +
+ {error ? ( +
{t('settings.grokOfficialLogin.errorPrefix')}{error}
+ ) : ( +
{t('common.loading')}
+ )} + {manualAuthorizeButton} +
+ ) + } + + if (status.loggedIn) { + return ( +
+ + {t('settings.grokOfficialLogin.loggedInPrefix')} {status.email || t('settings.grokOfficialLogin.accountUnknown')} + + +
+ ) + } + + return ( +
+
{t('settings.grokOfficialLogin.intro')}
+ + {error &&
{t('settings.grokOfficialLogin.errorPrefix')}{error}
} + {manualAuthorizeButton} +
+ ) +} diff --git a/desktop/src/components/shared/Toast.test.tsx b/desktop/src/components/shared/Toast.test.tsx new file mode 100644 index 00000000..f93d19a1 --- /dev/null +++ b/desktop/src/components/shared/Toast.test.tsx @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { useSettingsStore } from '../../stores/settingsStore' +import { useUIStore } from '../../stores/uiStore' +import { ToastContainer } from './Toast' + +describe('ToastContainer accessibility', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ toasts: [] }) + }) + + it('announces success and info messages politely as statuses', () => { + useUIStore.setState({ + toasts: [ + { id: 'success', type: 'success', message: 'Saved' }, + { id: 'info', type: 'info', message: 'Refreshing' }, + ], + }) + + render() + + expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('role', 'status') + expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('aria-live', 'polite') + expect(screen.getByText('Refreshing').closest('[role]')).toHaveAttribute('role', 'status') + }) + + it('announces warning and error messages assertively as alerts', () => { + useSettingsStore.setState({ locale: 'zh' }) + useUIStore.setState({ + toasts: [ + { id: 'warning', type: 'warning', message: 'Check settings' }, + { id: 'error', type: 'error', message: 'Save failed' }, + ], + }) + + render() + + expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('role', 'alert') + expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('aria-live', 'assertive') + expect(screen.getByText('Save failed').closest('[role]')).toHaveAttribute('role', 'alert') + expect(screen.getAllByRole('button', { name: '关闭通知' })).toHaveLength(2) + }) +}) diff --git a/desktop/src/components/shared/Toast.tsx b/desktop/src/components/shared/Toast.tsx index a48b1735..20eae1f8 100644 --- a/desktop/src/components/shared/Toast.tsx +++ b/desktop/src/components/shared/Toast.tsx @@ -1,4 +1,5 @@ import { useUIStore, type Toast as ToastType } from '../../stores/uiStore' +import { useTranslation } from '../../i18n' const typeStyles: Record = { success: 'border-l-4 border-l-[var(--color-success)]', @@ -8,10 +9,15 @@ const typeStyles: Record = { } function ToastItem({ toast }: { toast: ToastType }) { + const t = useTranslation() const removeToast = useUIStore((s) => s.removeToast) + const isUrgent = toast.type === 'warning' || toast.type === 'error' return (
{toast.message} + )}
{MODE_ITEMS.map(({ mode: itemMode, labelKey, Icon }) => { const isActive = mode === itemMode @@ -73,9 +101,9 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe role="tab" aria-selected={isActive} onClick={() => handleModeSelect(itemMode)} - className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${ + className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-[color,background-color,transform] duration-200 ease-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30 ${ isActive - ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]' + ? 'bg-[var(--color-surface)] text-[var(--color-text-primary)] shadow-[0_1px_2px_rgba(15,23,42,0.08)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]' }`} > @@ -93,7 +121,7 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe aria-label={t('workbench.expand')} title={t('workbench.expand')} onClick={handleExpand} - className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" + className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30" >
-
+
{mode === 'browser' ? ( diff --git a/desktop/src/components/workspace/WorkspaceCodeSurface.tsx b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx index 9ac3b490..2f0b3cce 100644 --- a/desktop/src/components/workspace/WorkspaceCodeSurface.tsx +++ b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx @@ -1,192 +1,14 @@ -import { useEffect, useState } from 'react' -import { Highlight, type PrismTheme } from 'prism-react-renderer' -import { useTranslation } from '../../i18n' - -export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000 -export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000 - -export const workspacePrismTheme: PrismTheme = { - plain: { - color: 'var(--color-code-fg)', - backgroundColor: 'transparent', - }, - styles: [ - { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } }, - { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, - { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, - { types: ['function'], style: { color: 'var(--color-code-function)' } }, - { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, - { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, - { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, - { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, - { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, - { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, - { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, - { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, - { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, - ], -} - -export function getFileExtension(name: string) { - const cleanName = name.split('/').pop() ?? name - const lastDot = cleanName.lastIndexOf('.') - if (lastDot <= 0 || lastDot === cleanName.length - 1) return '' - return cleanName.slice(lastDot + 1).toLowerCase() -} - -export function normalizePrismLanguage(language: string) { - const lower = language.toLowerCase() - const map: Record = { - text: 'text', - typescript: 'typescript', - ts: 'typescript', - tsx: 'tsx', - javascript: 'javascript', - js: 'javascript', - jsx: 'jsx', - markdown: 'markdown', - md: 'markdown', - html: 'markup', - xml: 'markup', - shell: 'bash', - sh: 'bash', - zsh: 'bash', - diff: 'diff', - } - return map[lower] ?? lower -} - -export function getLanguageFromPath(path: string) { - return normalizePrismLanguage(getFileExtension(path) || 'text') -} - -export function InlineHighlightedCode({ - value, - language, -}: { - value: string - language: string -}) { - return ( - - {({ tokens, getTokenProps }) => ( - <> - {(tokens[0] ?? []).map((token, tokenIndex) => { - const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) - return - })} - - )} - - ) -} - -export function WorkspaceDiffSurface({ - value, - path, - className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]', - lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT, -}: { - value: string - path: string - className?: string - lineLimit?: number -}) { - const t = useTranslation() - const [showAllLines, setShowAllLines] = useState(false) - const lines = value.split('\n') - const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit) - const language = getLanguageFromPath(path) - const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD - - useEffect(() => { - setShowAllLines(false) - }, [path, value]) - - return ( -
-
-
-          {visibleLines.map((line, index) => {
-            const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
-            const isHunk = line.startsWith('@@')
-            const isAdded = line.startsWith('+') && !line.startsWith('+++')
-            const isRemoved = line.startsWith('-') && !line.startsWith('---')
-            const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
-            const code = isCodeLine ? line.slice(1) : line
-            const prefix = isCodeLine ? line[0] : ' '
-
-            return (
-              
- - {index + 1} - - - {prefix} - - - {isCodeLine && !usePlainLargePreview ? ( - code ? : ' ' - ) : ( - code || ' ' - )} - -
- ) - })} -
- {lines.length > lineLimit && ( -
- - {showAllLines - ? t('workspace.previewAllLines', { total: lines.length }) - : t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })} - - -
- )} -
-
- ) -} +export { + getFileExtension, + getLanguageFromPath, + InlineHighlightedCode, + normalizePrismLanguage, + WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD, + WORKSPACE_PREVIEW_LINE_LIMIT, + WorkspaceDiffSurface, + workspacePrismTheme, +} from './WorkspaceDiffSurface' +export type { + WorkspaceDiffCommentSelection, + WorkspaceDiffSurfaceProps, +} from './WorkspaceDiffSurface' diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx new file mode 100644 index 00000000..8ed8a346 --- /dev/null +++ b/desktop/src/components/workspace/WorkspaceDiffSurface.test.tsx @@ -0,0 +1,533 @@ +import '@testing-library/jest-dom/vitest' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { WorkspaceDiffSurface } from './WorkspaceDiffSurface' +import { + WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD, + WORKSPACE_PREVIEW_LINE_LIMIT, + WorkspaceDiffSurface as ExportedWorkspaceDiffSurface, +} from './WorkspaceCodeSurface' + +const highlightRequestSpy = vi.hoisted(() => vi.fn()) + +vi.mock('./workspaceDiffHighlightRuntime', () => { + return { + createWorkspaceDiffHighlightCacheKey: (path: string, value: string) => `${path}:${value}`, + requestWorkspaceDiffHighlight: highlightRequestSpy, + } +}) + +const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -10,2 +10,3 @@', + ' const a = 1', + '-const b = 2', + '+const b = 3', + '+const c = 4', + '@@ -20 +21 @@', + '-old tail', + '+new tail', +].join('\n') + +function getCodeRow(text: string) { + const row = document.querySelector(`[data-row-text="${text}"]`) + expect(row).not.toBeNull() + return row! +} + +function createHighlightResult(files: Array<{ rows: Array<{ id: string; text: string; selectable: boolean }> }>) { + const tokensByRowId: Record> = {} + files.flatMap((file) => file.rows) + .filter((row) => row.selectable) + .forEach((row) => { + tokensByRowId[row.id] = row.text.split(/(const)/).filter(Boolean).map((content) => ({ + content, + color: content === 'const' + ? 'var(--color-diff-syntax-keyword)' + : 'var(--color-diff-syntax-foreground)', + })) + }) + return { engine: 'shiki', tokensByRowId, wordRangesByRowId: {} } +} + +describe('WorkspaceDiffSurface', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + highlightRequestSpy.mockReset() + highlightRequestSpy.mockImplementation(() => new Promise(() => {})) + }) + + it('keeps one scroll surface while hiding redundant single-file patch chrome', () => { + render() + + const scrollSurface = screen.getByTestId('workspace-diff-scroll') + expect(scrollSurface.className).toContain('min-h-0') + expect(scrollSurface.className).toContain('overflow-auto') + expect(scrollSurface).toHaveStyle({ containerType: 'inline-size' }) + expect(screen.getByTestId('workspace-diff-content').className).toContain('w-max') + expect(screen.queryByTestId('workspace-diff-file-header')).not.toBeInTheDocument() + expect(screen.queryByText('--- a/src/a.ts')).not.toBeInTheDocument() + expect(screen.queryByText('+++ b/src/a.ts')).not.toBeInTheDocument() + expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument() + expect(getCodeRow('const a = 1')).toBeInTheDocument() + }) + + it('does not spend the visible line limit on hidden single-file patch metadata', () => { + render() + + expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument() + expect(getCodeRow('const a = 1')).toBeInTheDocument() + expect(document.querySelector('[data-row-text="const b = 2"]')).not.toBeInTheDocument() + }) + + it('uses the Codex-style compact number gutter without a dedicated comment column', () => { + render() + + const code = screen.getByTestId('workspace-code') + const row = getCodeRow('const b = 3').closest('[data-diff-row-id]') + const gutter = row?.querySelector('[data-diff-number-gutter]') + const commentButton = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + + expect(code.style.getPropertyValue('--workspace-diff-gutter-width')).toBe('6ch') + expect(row).toHaveStyle({ + gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)', + }) + expect(gutter).toHaveTextContent('11') + expect(gutter).toContainElement(commentButton) + expect(gutter?.querySelector('[data-diff-gutter-utility-slot]')).toContainElement(commentButton) + expect(gutter?.className).toContain('bg-[var(--color-diff-added-bg)]') + expect(getCodeRow('const a = 1').closest('[data-diff-row-id]')?.querySelector('[data-diff-number-gutter]')?.className).toContain('bg-[var(--color-code-bg)]') + expect(commentButton.className).toContain('h-5') + expect(commentButton.className).toContain('w-5') + expect(row?.querySelectorAll('[data-diff-line-number]')).toHaveLength(1) + expect(row?.className).toContain('min-h-5') + }) + + it('submits a forward range with its source coordinates and quote', () => { + const onAddComment = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true }) + expect(screen.getByText('new L11-L12')).toBeInTheDocument() + const rangeEndRow = getCodeRow('const c = 4').closest('[data-diff-row-id]') + const editorContainer = screen.getByRole('textbox', { name: 'Review comment' }).closest('[data-diff-editor]') + expect(rangeEndRow?.nextElementSibling).toBe(editorContainer) + expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true') + expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true') + + const editor = screen.getByRole('textbox', { name: 'Review comment' }) + fireEvent.change(editor, { target: { value: 'Use a shared helper' } }) + fireEvent.keyDown(editor, { key: 'Enter', metaKey: true }) + + expect(onAddComment).toHaveBeenCalledWith(expect.objectContaining({ + side: 'new', + lineStart: 11, + lineEnd: 12, + quote: 'const b = 3\nconst c = 4', + hunkId: 'file-0-hunk-0', + }), 'Use a shared helper') + }) + + it('normalizes reverse Shift selection', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })) + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }), { shiftKey: true }) + + expect(screen.getByText('new L11-L12')).toBeInTheDocument() + expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true') + expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true') + }) + + it('exposes the selected Shift range on the diff rows and review rail', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })) + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true }) + + const firstRow = getCodeRow('const a = 1').closest('[data-diff-row-id]') + const middleRow = getCodeRow('const b = 3').closest('[data-diff-row-id]') + const lastRow = getCodeRow('const c = 4').closest('[data-diff-row-id]') + expect(firstRow).toHaveAttribute('aria-selected', 'true') + expect(firstRow).toHaveAttribute('data-range-edge', 'start') + expect(middleRow).toHaveAttribute('aria-selected', 'true') + expect(middleRow).not.toHaveAttribute('data-range-edge') + expect(lastRow).toHaveAttribute('aria-selected', 'true') + expect(lastRow).toHaveAttribute('data-range-edge', 'end') + expect(document.querySelectorAll('[data-diff-selection-rail]')).toHaveLength(3) + document.querySelectorAll('[data-diff-selection-rail]').forEach((rail) => { + expect(rail.closest('[data-diff-number-gutter]')).not.toBeNull() + }) + expect(firstRow?.className).toContain('bg-[var(--color-info-container)]') + expect(firstRow?.className).not.toContain('bg-[var(--color-diff-added-bg)]') + expect(screen.getByTestId('workspace-code').className).toContain('text-[13px]') + const editor = screen.getByRole('textbox', { name: 'Review comment' }) + const editorContainer = editor.closest('[data-diff-editor]') + expect(editorContainer?.className).toContain('max-w-3xl') + expect(editorContainer?.className).toContain('sticky') + expect(editorContainer).toHaveStyle({ left: 'var(--workspace-diff-gutter-width)' }) + expect(editorContainer).toHaveStyle({ width: 'min(48rem, calc(100cqi - var(--workspace-diff-gutter-width) - 0.75rem))' }) + expect(editorContainer?.className).not.toContain('ml-[116px]') + expect(editorContainer?.className).not.toContain('min-w-[420px]') + expect(editorContainer).toHaveTextContent('Local comment') + expect(editor.className).toContain('min-h-0') + expect(editor.className).not.toContain('shadow-[inset_0_0_0_1px') + expect(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })).not.toHaveAttribute('data-selection-focus') + const focusedGutter = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }) + expect(focusedGutter).toHaveAttribute('data-selection-focus', 'true') + expect(focusedGutter.className).toContain('text-[var(--color-surface)]') + expect(focusedGutter.className).not.toContain('text-[var(--color-text-tertiary)]') + expect(focusedGutter.className).not.toContain('text-white') + const submit = screen.getByRole('button', { name: 'Submit review comment' }) + expect(submit.className).toContain('text-[var(--color-surface)]') + expect(submit.className).not.toContain('text-white') + }) + + it('cancels an inline review from the visible editor action', () => { + render() + const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + + fireEvent.click(anchor) + fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), { + target: { value: 'This draft should close' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument() + expect(anchor).toHaveFocus() + }) + + it('does not submit an empty review comment', () => { + const onAddComment = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Enter', ctrlKey: true }) + + expect(onAddComment).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument() + }) + + it('closes on Escape and restores focus to the anchor gutter button', () => { + render() + const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + + fireEvent.click(anchor) + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Escape' }) + + expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument() + expect(anchor).toHaveFocus() + }) + + it('resets an incompatible Shift range and announces why', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts old line 20' }), { shiftKey: true }) + + expect(screen.getByText('Selection reset: choose lines from the same side and hunk.')).toBeInTheDocument() + expect(screen.getByText('old L20')).toBeInTheDocument() + }) + + it('uses one roving tab stop and supports Arrow, Home, End, and activation keys', () => { + render() + const buttons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ }) + const firstButton = buttons[0]! + const secondButton = buttons[1]! + + expect(buttons.filter((button) => button.tabIndex === 0)).toHaveLength(1) + act(() => firstButton.focus()) + fireEvent.keyDown(firstButton, { key: 'ArrowDown' }) + expect(secondButton).toHaveFocus() + expect(secondButton).toHaveAttribute('tabindex', '0') + + fireEvent.keyDown(secondButton, { key: 'End' }) + expect(buttons.at(-1)).toHaveFocus() + fireEvent.keyDown(buttons.at(-1)!, { key: 'Home' }) + expect(firstButton).toHaveFocus() + fireEvent.keyDown(firstButton, { key: ' ' }) + expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus() + }) + + it('keeps Shift+Home selection inside the current side and hunk', () => { + render() + const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' }) + const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + + act(() => line11.focus()) + fireEvent.keyDown(line11, { key: 'Home', shiftKey: true }) + + expect(line10).toHaveFocus() + expect(screen.getByText('new L10-L11')).toBeInTheDocument() + expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true') + expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true') + }) + + it('extends the range with Shift+Arrow and returns focus after submit', () => { + const onAddComment = vi.fn() + render() + const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + + act(() => anchor.focus()) + fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true }) + expect(screen.getByText('new L11-L12')).toBeInTheDocument() + + const editor = screen.getByRole('textbox', { name: 'Review comment' }) + fireEvent.change(editor, { target: { value: 'Keep this focused' } }) + fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true }) + expect(onAddComment).toHaveBeenCalledOnce() + expect(anchor).toHaveFocus() + }) + + it('skips incompatible rows when extending with Shift+Arrow', () => { + render() + const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' }) + + act(() => anchor.focus()) + fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true }) + + expect(screen.getByText('new L10-L11')).toBeInTheDocument() + expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true') + expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true') + }) + + it('keeps gutter focus for repeatable Shift+Arrow extension and shrinking', () => { + render() + const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' }) + const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }) + const line12 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }) + + act(() => line10.focus()) + fireEvent.keyDown(line10, { key: 'ArrowDown', shiftKey: true }) + expect(line11).toHaveFocus() + fireEvent.keyDown(line11, { key: 'ArrowDown', shiftKey: true }) + expect(line12).toHaveFocus() + expect(screen.getByText('new L10-L12')).toBeInTheDocument() + + fireEvent.keyDown(line12, { key: 'ArrowUp', shiftKey: true }) + expect(line11).toHaveFocus() + expect(screen.getByText('new L10-L11')).toBeInTheDocument() + }) + + it('keeps roving navigation on mounted rows when the preview is truncated', () => { + render() + const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ }) + const lastVisibleButton = visibleButtons.at(-1)! + + act(() => lastVisibleButton.focus()) + fireEvent.keyDown(lastVisibleButton, { key: 'ArrowDown' }) + + expect(lastVisibleButton).toHaveFocus() + expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1) + expect(screen.getByText('Showing first 5 of 9 loaded lines.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Show all loaded lines' })).toBeInTheDocument() + }) + + it('invalidates a hidden selection on collapse while preserving its draft and visible roving target', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' })) + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), { + target: { value: 'Keep this collapsed draft' }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Collapse preview' })) + + expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Select visible lines again') + const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ }) + expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1) + expect(visibleButtons[0]).toHaveFocus() + + fireEvent.click(visibleButtons[0]!) + expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Keep this collapsed draft') + }) + + it('uses plain text instead of Shiki after expanding a diff beyond the large preview threshold', () => { + const additions = Array.from( + { length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 }, + (_, index) => `+const value${index} = ${index}`, + ) + const largeDiff = [ + 'diff --git a/src/large.ts b/src/large.ts', + '--- a/src/large.ts', + '+++ b/src/large.ts', + `@@ -0,0 +1,${additions.length} @@`, + ...additions, + ].join('\n') + render() + + fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' })) + + expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'plain') + expect(highlightRequestSpy).not.toHaveBeenCalled() + expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000') + }) + + it('renders parsed file headers and keeps multiple files visually separated', () => { + const multiFileDiff = [ + diff, + 'diff --git a/src/b.ts b/src/b.ts', + '--- a/src/b.ts', + '+++ b/src/b.ts', + '@@ -1 +1 @@', + '-export const before = true', + '+export const after = true', + ].join('\n') + + render() + + const headers = screen.getAllByTestId('workspace-diff-file-header') + expect(headers).toHaveLength(2) + expect(headers[0]).toHaveTextContent('diff --git a/src/a.ts b/src/a.ts') + expect(headers[1]).toHaveTextContent('diff --git a/src/b.ts b/src/b.ts') + }) + + it('renders TypeScript Shiki tokens through the compatibility export without a circular runtime failure', async () => { + highlightRequestSpy.mockImplementationOnce(async ({ files }) => createHighlightResult(files)) + render() + + await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki')) + const keyword = screen.getAllByText('const').find((element) => ( + element.getAttribute('style')?.includes('var(--color-diff-syntax-keyword)') + )) + expect(keyword).toBeDefined() + expect(document.querySelectorAll('[data-row-text="const b = 3"]')).toHaveLength(1) + }) + + it('never renders tokens from the previous diff while the next highlight is pending', async () => { + highlightRequestSpy.mockImplementationOnce(async ({ files }) => { + const result = createHighlightResult(files) + const firstRow = files.flatMap((file: { rows: Array<{ id: string; selectable: boolean }> }) => file.rows) + .find((row: { selectable: boolean }) => row.selectable)! + result.tokensByRowId[firstRow.id] = [{ + content: 'STALE_TOKEN', + color: 'var(--color-diff-syntax-keyword)', + }] + return result + }) + const view = render() + await screen.findByText('STALE_TOKEN') + + let resolveNext: (() => void) | undefined + const nextDiff = diff.replace('const a = 1', 'let fresh = 2') + highlightRequestSpy.mockImplementationOnce(({ files }) => new Promise((resolve) => { + resolveNext = () => resolve(createHighlightResult(files)) + })) + view.rerender() + + expect(screen.queryByText('STALE_TOKEN')).not.toBeInTheDocument() + expect(getCodeRow('let fresh = 2')).toHaveTextContent('let fresh = 2') + + await act(async () => resolveNext?.()) + await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki')) + }) + + it('layers word-level changes over Shiki tokens without changing the line layout', async () => { + const wordDiff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-const label = oldName', + '+const label = newName', + ].join('\n') + highlightRequestSpy.mockImplementationOnce(async ({ files }) => { + const result = createHighlightResult(files) + const rows = files.flatMap((file: { rows: Array<{ id: string; text: string }> }) => file.rows) + const oldRow = rows.find((row: { text: string }) => row.text.includes('oldName'))! + const newRow = rows.find((row: { text: string }) => row.text.includes('newName'))! + return { + ...result, + wordRangesByRowId: { + [oldRow.id]: [{ start: 14, end: 21 }], + [newRow.id]: [{ start: 14, end: 21 }], + }, + } + }) + + render() + + await waitFor(() => expect(screen.getByTestId('workspace-code')).toHaveAttribute('data-highlight-engine', 'shiki')) + expect(document.querySelector('[data-diff-word-change="deletion"]')).toHaveTextContent('oldName') + expect(document.querySelector('[data-diff-word-change="addition"]')).toHaveTextContent('newName') + expect(document.querySelector('[data-diff-word-change="deletion"]')?.className).toContain('color-diff-removed-word') + expect(document.querySelector('[data-diff-word-change="addition"]')?.className).toContain('color-diff-added-word') + }) + + it('renders the complete review flow in Chinese', () => { + useSettingsStore.setState({ locale: 'zh' }) + render() + + const gutter = screen.getByRole('button', { name: '评论 src/a.ts 的新侧第 11 行' }) + fireEvent.click(gutter) + + expect(screen.getByRole('textbox', { name: '评审评论' })).toHaveFocus() + expect(screen.getByText('新 L11')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '提交评审评论' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '评论 src/a.ts 的旧侧第 20 行' }), { shiftKey: true }) + expect(screen.getByRole('status')).toHaveTextContent('只能选择同一侧、同一变更块中的行') + }) + + it('does not request Shiki highlighting again for each controlled draft change', () => { + const additions = Array.from( + { length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 }, + (_, index) => `+const value${index + 1} = ${index + 1}`, + ) + const nearLimitDiff = [ + 'diff --git a/src/near-limit.ts b/src/near-limit.ts', + '--- a/src/near-limit.ts', + '+++ b/src/near-limit.ts', + `@@ -0,0 +1,${additions.length} @@`, + ...additions, + ].join('\n') + render() + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/near-limit.ts new line 1' })) + const highlightCountBeforeTyping = highlightRequestSpy.mock.calls.length + const editor = screen.getByRole('textbox', { name: 'Review comment' }) + + fireEvent.change(editor, { target: { value: 'a' } }) + fireEvent.change(editor, { target: { value: 'ab' } }) + fireEvent.change(editor, { target: { value: 'abc' } }) + + expect(highlightRequestSpy).toHaveBeenCalledTimes(highlightCountBeforeTyping) + expect(editor).toHaveValue('abc') + }) + + it('preserves draft text but invalidates its selection when the diff changes', () => { + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), { + target: { value: 'Draft survives refresh' }, + }) + rerender() + + expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument() + expect(screen.getByText('Diff changed. Select lines again to submit this comment.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Draft survives refresh') + }) + + it('resets the editor and draft when the path changes', () => { + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), { + target: { value: 'Discard on another file' }, + }) + + rerender() + + expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Comment on src/b.ts new line 11' })) + expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('') + }) +}) diff --git a/desktop/src/components/workspace/WorkspaceDiffSurface.tsx b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx new file mode 100644 index 00000000..5b9cb3c8 --- /dev/null +++ b/desktop/src/components/workspace/WorkspaceDiffSurface.tsx @@ -0,0 +1,771 @@ +import { + Fragment, + memo, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, +} from 'react' +import { CornerDownLeft, FileCode2, MessageSquare, Plus } from 'lucide-react' +import { Highlight, type PrismTheme } from 'prism-react-renderer' +import { useTranslation } from '../../i18n' +import { + getCompatibleDiffRange, + parseWorkspaceDiff, + type WorkspaceDiffRow, + type WorkspaceDiffSelection, +} from './workspaceDiffModel' +import { + type WorkspaceDiffHighlightResult, + type WorkspaceDiffHighlightToken, + type WorkspaceDiffWordRange, +} from './workspaceDiffHighlighter' +import { + createWorkspaceDiffHighlightCacheKey, + requestWorkspaceDiffHighlight, +} from './workspaceDiffHighlightRuntime' + +export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000 +export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000 + +export const workspacePrismTheme: PrismTheme = { + plain: { + color: 'var(--color-code-fg)', + backgroundColor: 'transparent', + }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } }, + { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, + { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['function'], style: { color: 'var(--color-code-function)' } }, + { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, + { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, + { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, + { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, + { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, + { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, + { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, + { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, + ], +} + +export function getFileExtension(name: string) { + const cleanName = name.split('/').pop() ?? name + const lastDot = cleanName.lastIndexOf('.') + if (lastDot <= 0 || lastDot === cleanName.length - 1) return '' + return cleanName.slice(lastDot + 1).toLowerCase() +} + +export function normalizePrismLanguage(language: string) { + const lower = language.toLowerCase() + const map: Record = { + text: 'text', + typescript: 'typescript', + ts: 'typescript', + tsx: 'tsx', + javascript: 'javascript', + js: 'javascript', + jsx: 'jsx', + markdown: 'markdown', + md: 'markdown', + html: 'markup', + xml: 'markup', + shell: 'bash', + sh: 'bash', + zsh: 'bash', + diff: 'diff', + } + return map[lower] ?? lower +} + +export function getLanguageFromPath(path: string) { + return normalizePrismLanguage(getFileExtension(path) || 'text') +} + +export const InlineHighlightedCode = memo(function InlineHighlightedCode({ + value, + language, +}: { + value: string + language: string +}) { + return ( + + {({ tokens, getTokenProps }) => ( + <> + {(tokens[0] ?? []).map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + + )} + + ) +}) + +function tokenStyle(token: WorkspaceDiffHighlightToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0 + return { + color: token.color, + fontStyle: fontStyle & 1 ? 'italic' : undefined, + fontWeight: fontStyle & 2 ? 700 : undefined, + textDecoration: [ + fontStyle & 4 ? 'underline' : '', + fontStyle & 8 ? 'line-through' : '', + ].filter(Boolean).join(' ') || undefined, + } +} + +function overlapsRange(start: number, end: number, ranges: WorkspaceDiffWordRange[]) { + return ranges.some((range) => start < range.end && end > range.start) +} + +const HighlightedDiffLine = memo(function HighlightedDiffLine({ + row, + tokens, + wordRanges, +}: { + row: WorkspaceDiffRow + tokens: WorkspaceDiffHighlightToken[] + wordRanges: WorkspaceDiffWordRange[] +}) { + let offset = 0 + return ( + <> + {tokens.map((token, tokenIndex) => { + const tokenStart = offset + const tokenEnd = tokenStart + token.content.length + offset = tokenEnd + const boundaries = new Set([tokenStart, tokenEnd]) + wordRanges.forEach((range) => { + if (range.start > tokenStart && range.start < tokenEnd) boundaries.add(range.start) + if (range.end > tokenStart && range.end < tokenEnd) boundaries.add(range.end) + }) + const points = [...boundaries].sort((left, right) => left - right) + return points.slice(0, -1).map((start, partIndex) => { + const end = points[partIndex + 1]! + const changed = overlapsRange(start, end, wordRanges) + return ( + + {token.content.slice(start - tokenStart, end - tokenStart)} + + ) + }) + })} + + ) +}) + +export interface WorkspaceDiffCommentSelection { + side: 'old' | 'new' + lineStart: number + lineEnd: number + quote: string + hunkId: string +} + +export interface WorkspaceDiffSurfaceProps { + value: string + path: string + className?: string + lineLimit?: number + hideSingleFileHeader?: boolean + onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void +} + +interface ReviewState { + anchorId: string | null + focusId: string | null + selection: WorkspaceDiffSelection | null + draft: string +} + +type ReviewStatus = 'selectionReset' | 'diffChanged' | 'collapsedSelection' | null + +const plainHighlightResult: WorkspaceDiffHighlightResult = { + engine: 'plain', + tokensByRowId: {}, + wordRangesByRowId: {}, +} + +const emptyReviewState: ReviewState = { + anchorId: null, + focusId: null, + selection: null, + draft: '', +} + +function rowTone(row: WorkspaceDiffRow) { + if (row.kind === 'addition') return 'bg-[var(--color-diff-added-bg)]' + if (row.kind === 'deletion') return 'bg-[var(--color-diff-removed-bg)]' + if (row.kind === 'hunk') return 'bg-[var(--color-diff-highlight-bg)]' + return 'hover:bg-[var(--color-surface-hover)]' +} + +function gutterTone(row: WorkspaceDiffRow, selected: boolean) { + if (selected) return 'bg-[var(--color-info-container)]' + if (row.kind === 'addition') return 'bg-[var(--color-diff-added-bg)]' + if (row.kind === 'deletion') return 'bg-[var(--color-diff-removed-bg)]' + if (row.kind === 'hunk') return 'bg-[var(--color-diff-highlight-bg)]' + return 'bg-[var(--color-code-bg)] group-hover:bg-[var(--color-surface-hover)]' +} + +function prefixTone(row: WorkspaceDiffRow) { + if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]' + if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]' + return 'text-[var(--color-text-tertiary)]' +} + +function codeTone(row: WorkspaceDiffRow) { + if (row.kind === 'metadata') return 'font-semibold text-[var(--color-text-secondary)]' + if (row.kind === 'hunk') return 'font-semibold text-[var(--color-warning)]' + return '' +} + +function isStructuralMetadata(row: WorkspaceDiffRow) { + if (row.kind !== 'metadata') return false + return row.text.startsWith('diff --') || row.text.startsWith('--- ') || row.text.startsWith('+++ ') +} + +export function WorkspaceDiffSurface({ + value, + path, + className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]', + lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT, + hideSingleFileHeader = false, + onAddComment, +}: WorkspaceDiffSurfaceProps) { + const t = useTranslation() + const files = useMemo(() => parseWorkspaceDiff(value), [value]) + const rows = useMemo(() => files.flatMap((file) => file.rows), [files]) + const lineNumberCharacters = useMemo( + () => rows.reduce((maximum, row) => Math.max( + maximum, + row.oldLine === null ? 0 : String(row.oldLine).length, + row.newLine === null ? 0 : String(row.newLine).length, + ), 3), + [rows], + ) + const codeStyle = { + '--workspace-diff-gutter-width': `${lineNumberCharacters + 3}ch`, + } as CSSProperties + const showFileHeaders = !hideSingleFileHeader || files.length > 1 + const displayItemIds = useMemo( + () => files.flatMap((file) => [ + ...(showFileHeaders ? [`${file.id}-header`] : []), + ...file.rows.filter((row) => !isStructuralMetadata(row)).map((row) => row.id), + ]), + [files, showFileHeaders], + ) + const [review, setReview] = useState(emptyReviewState) + const [status, setStatus] = useState(null) + const [showAllRows, setShowAllRows] = useState(false) + const visibleItemIds = useMemo( + () => new Set(showAllRows ? displayItemIds : displayItemIds.slice(0, lineLimit)), + [displayItemIds, lineLimit, showAllRows], + ) + const visibleRows = useMemo(() => rows.filter((row) => visibleItemIds.has(row.id)), [rows, visibleItemIds]) + const selectableRows = useMemo(() => visibleRows.filter((row) => row.selectable), [visibleRows]) + const usePlainLargePreview = rows.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + const highlightCacheKey = useMemo( + () => createWorkspaceDiffHighlightCacheKey(path, value), + [path, value], + ) + const [highlightState, setHighlightState] = useState<{ + cacheKey: string | null + result: WorkspaceDiffHighlightResult + }>({ + cacheKey: null, + result: plainHighlightResult, + }) + const highlightResult = !usePlainLargePreview && highlightState.cacheKey === highlightCacheKey + ? highlightState.result + : plainHighlightResult + const [rovingId, setRovingId] = useState(() => rows.find((row) => row.selectable)?.id ?? null) + const buttonRefs = useRef(new Map()) + const editorRef = useRef(null) + const shouldFocusEditor = useRef(false) + const pendingRovingFocus = useRef(null) + const previousPath = useRef(path) + const previousValue = useRef(value) + const selectedIds = new Set(review.selection?.rowIds ?? []) + const sideLabel = (side: 'old' | 'new') => t(`workspace.diffReview.side.${side}`) + + useEffect(() => { + if (usePlainLargePreview) { + setHighlightState({ cacheKey: null, result: plainHighlightResult }) + return + } + + let cancelled = false + setHighlightState({ cacheKey: null, result: plainHighlightResult }) + requestWorkspaceDiffHighlight({ cacheKey: highlightCacheKey, files, path }).then((result) => { + if (!cancelled) setHighlightState({ cacheKey: highlightCacheKey, result }) + }) + return () => { + cancelled = true + } + }, [files, highlightCacheKey, path, usePlainLargePreview]) + + useEffect(() => { + const pathChanged = previousPath.current !== path + const valueChanged = previousValue.current !== value + previousPath.current = path + previousValue.current = value + + if (pathChanged) { + setReview(emptyReviewState) + setStatus(null) + setShowAllRows(false) + setRovingId(selectableRows[0]?.id ?? null) + return + } + + if (valueChanged) { + setReview((current) => ({ + ...current, + anchorId: null, + focusId: null, + selection: null, + })) + setStatus(review.draft ? 'diffChanged' : null) + setRovingId(selectableRows[0]?.id ?? null) + } + }, [path, review.draft, selectableRows, value]) + + useEffect(() => { + if (!rovingId || !selectableRows.some((row) => row.id === rovingId)) { + setRovingId(selectableRows[0]?.id ?? null) + } + const pendingId = pendingRovingFocus.current + if (pendingId && selectableRows.some((row) => row.id === pendingId)) { + pendingRovingFocus.current = null + setRovingId(pendingId) + buttonRefs.current.get(pendingId)?.focus() + } + }, [rovingId, selectableRows]) + + useEffect(() => { + if (review.selection && shouldFocusEditor.current) { + shouldFocusEditor.current = false + editorRef.current?.focus() + } + }, [review.selection]) + + const focusButton = (id: string | null) => { + if (id) buttonRefs.current.get(id)?.focus() + } + + const selectSingleRow = (row: WorkspaceDiffRow, resetStatus: ReviewStatus = null, focusEditor = false) => { + const selection = getCompatibleDiffRange(rows, row.id, row.id) + if (!selection) return + shouldFocusEditor.current = focusEditor + setReview((current) => ({ + ...current, + anchorId: row.id, + focusId: row.id, + selection, + })) + setStatus(resetStatus) + } + + const extendSelection = (row: WorkspaceDiffRow, focusEditor = false) => { + if (!review.anchorId) { + selectSingleRow(row, null, focusEditor) + return + } + const selection = getCompatibleDiffRange(rows, review.anchorId, row.id) + if (!selection) { + selectSingleRow(row, 'selectionReset', focusEditor) + return + } + shouldFocusEditor.current = focusEditor + setReview((current) => ({ ...current, focusId: row.id, selection })) + setStatus(null) + } + + const activateRow = (row: WorkspaceDiffRow, extend: boolean, focusEditor: boolean) => { + setRovingId(row.id) + if (extend) extendSelection(row, focusEditor) + else selectSingleRow(row, null, focusEditor) + } + + const handleRowClick = (event: MouseEvent, row: WorkspaceDiffRow) => { + if (event.shiftKey) event.currentTarget.focus() + activateRow(row, event.shiftKey, !event.shiftKey) + } + + const moveRovingFocus = (row: WorkspaceDiffRow, direction: -1 | 1, extend: boolean) => { + const currentIndex = selectableRows.findIndex((candidate) => candidate.id === row.id) + const anchorRow = review.anchorId + ? selectableRows.find((candidate) => candidate.id === review.anchorId) ?? row + : row + let target = selectableRows[currentIndex + direction] + if (extend) { + let targetIndex = currentIndex + direction + while (target && (target.side !== anchorRow.side || target.hunkId !== anchorRow.hunkId)) { + targetIndex += direction + target = selectableRows[targetIndex] + } + } + if (!target) return + setRovingId(target.id) + focusButton(target.id) + if (extend && !review.anchorId) { + const selection = getCompatibleDiffRange(rows, row.id, target.id) + if (selection) { + setReview((current) => ({ + ...current, + anchorId: row.id, + focusId: target.id, + selection, + })) + setStatus(null) + } else { + selectSingleRow(target, 'selectionReset') + } + } else if (extend) { + extendSelection(target) + } + } + + const handleRowKeyDown = (event: KeyboardEvent, row: WorkspaceDiffRow) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + moveRovingFocus(row, event.key === 'ArrowDown' ? 1 : -1, event.shiftKey) + return + } + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + const navigationRows = event.shiftKey + ? selectableRows.filter((candidate) => ( + candidate.side === row.side && candidate.hunkId === row.hunkId + )) + : selectableRows + const target = event.key === 'Home' ? navigationRows[0] : navigationRows.at(-1) + if (target) { + setRovingId(target.id) + focusButton(target.id) + if (event.shiftKey && !review.anchorId) { + const selection = getCompatibleDiffRange(rows, row.id, target.id) + if (selection) { + setReview((current) => ({ + ...current, + anchorId: row.id, + focusId: target.id, + selection, + })) + setStatus(null) + } + } else if (event.shiftKey) { + extendSelection(target) + } + } + return + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + activateRow(row, event.shiftKey, true) + } + } + + const closeEditor = () => { + const restoreId = review.anchorId + setReview((current) => ({ ...current, anchorId: null, focusId: null, selection: null })) + setStatus(null) + focusButton(restoreId) + } + + const submitComment = () => { + const note = review.draft.trim() + if (!note || !review.selection) return + const { side, lineStart, lineEnd, quote, hunkId } = review.selection + onAddComment?.({ side, lineStart, lineEnd, quote, hunkId }, note) + const restoreId = review.anchorId + setReview(emptyReviewState) + setStatus(null) + focusButton(restoreId) + } + + const handleEditorKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + closeEditor() + return + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + submitComment() + } + } + + const toggleRows = () => { + if (!showAllRows) { + setShowAllRows(true) + return + } + + const collapsedItemIds = new Set(displayItemIds.slice(0, lineLimit)) + const collapsedSelectableRows = rows.filter((row) => row.selectable && collapsedItemIds.has(row.id)) + const nextRovingId = collapsedSelectableRows[0]?.id ?? null + const selectionWillBeHidden = review.selection?.rowIds.some((id) => !collapsedItemIds.has(id)) ?? false + + if (selectionWillBeHidden) { + setReview((current) => ({ + ...current, + anchorId: null, + focusId: null, + selection: null, + })) + setStatus('collapsedSelection') + } + setRovingId(nextRovingId) + pendingRovingFocus.current = nextRovingId + setShowAllRows(false) + } + + const renderEditor = () => review.selection && ( +
+
+ + +
{t('workspace.localComment')}
+
+ {sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`} +
+
+ {status && ( +
+ {t(`workspace.diffReview.${status}`)} +
+ )} +