From 8d21978f241d15bea83f8281b1601969c8548007 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 13 Jan 2026 08:20:33 +0100 Subject: [PATCH] feat(frontend): add Claude Code version rollback feature (#983) * feat(frontend): add Claude Code version rollback feature Add ability for users to switch to any of the last 20 Claude Code CLI versions directly from the Claude Code popup in the sidebar. Changes: - Add IPC channels for fetching available versions and installing specific version - Add backend handlers to fetch versions from npm registry (with 1-hour cache) - Add version selector dropdown in ClaudeCodeStatusBadge component - Add warning dialog before switching versions (warns about closing sessions) - Add i18n support for English and French translations Co-Authored-By: Claude Opus 4.5 * fix: address PR review feedback for Claude Code version rollback - Add validation after semver filtering to handle empty version list - Add error state and UI feedback for installation/version switch failures - Extract magic number 5000ms to VERSION_RECHECK_DELAY_MS constant - Bind Select value prop to selectedVersion state - Normalize version comparison to handle 'v' prefix consistently - Use normalized version comparison in SelectItem disabled check Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../main/ipc-handlers/claude-code-handlers.ts | 132 ++++++++++++- .../preload/api/modules/claude-code-api.ts | 45 ++++- .../components/ClaudeCodeStatusBadge.tsx | 185 +++++++++++++++++- .../frontend/src/renderer/lib/browser-mock.ts | 10 + apps/frontend/src/shared/constants/ipc.ts | 2 + .../shared/i18n/locales/en/navigation.json | 11 +- .../shared/i18n/locales/fr/navigation.json | 11 +- apps/frontend/src/shared/types/cli.ts | 9 + apps/frontend/src/shared/types/ipc.ts | 2 + 9 files changed, 401 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 2b6a1510..a3d2cd4f 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -20,7 +20,9 @@ import semver from 'semver'; // Cache for latest version (avoid hammering npm registry) let cachedLatestVersion: { version: string; timestamp: number } | null = null; +let cachedVersionList: { versions: string[]; timestamp: number } | null = null; const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours +const VERSION_LIST_CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour for version list /** * Fetch the latest version of Claude Code from npm registry @@ -63,6 +65,74 @@ async function fetchLatestVersion(): Promise { } } +/** + * Fetch available versions of Claude Code from npm registry + * Returns versions sorted by semver descending (newest first) + * Limited to last 20 versions for performance + */ +async function fetchAvailableVersions(): Promise { + // Check cache first + if (cachedVersionList && Date.now() - cachedVersionList.timestamp < VERSION_LIST_CACHE_DURATION_MS) { + return cachedVersionList.versions; + } + + try { + const response = await fetch('https://registry.npmjs.org/@anthropic-ai/claude-code', { + headers: { + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(15000), // 15 second timeout + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const versions = Object.keys(data.versions || {}); + + if (!versions.length) { + throw new Error('No versions found in npm registry'); + } + + // Sort by semver descending (newest first) and take last 20 + const sortedVersions = versions + .filter(v => semver.valid(v)) // Only valid semver versions + .sort((a, b) => semver.rcompare(a, b)) // Sort descending + .slice(0, 20); // Limit to 20 versions + + // Validate we have versions after filtering + if (sortedVersions.length === 0) { + throw new Error('No valid semver versions found in npm registry'); + } + + // Cache the result + cachedVersionList = { versions: sortedVersions, timestamp: Date.now() }; + return sortedVersions; + } catch (error) { + console.error('[Claude Code] Failed to fetch available versions:', error); + // Return cached versions if available, even if expired + if (cachedVersionList) { + return cachedVersionList.versions; + } + throw error; + } +} + +/** + * Get the platform-specific install command for a specific version of Claude Code + * @param version - The version to install (e.g., "1.0.5") + */ +function getInstallVersionCommand(version: string): string { + if (process.platform === 'win32') { + // Windows: kill running Claude processes first, then install specific version + return `taskkill /IM claude.exe /F 2>nul; claude install --force ${version}`; + } else { + // macOS/Linux: kill running Claude processes first, then install specific version + return `pkill -x claude 2>/dev/null; sleep 1; claude install --force ${version}`; + } +} + /** * Get the platform-specific install command for Claude Code * @param isUpdate - If true, Claude is already installed and we just need to update @@ -280,7 +350,7 @@ export async function openTerminalWithCommand(command: string): Promise { 'C:\\Program Files\\Git\\git-bash.exe', 'C:\\Program Files (x86)\\Git\\git-bash.exe', ]; - let gitBashPath = gitBashPaths.find(p => existsSync(p)); + const gitBashPath = gitBashPaths.find(p => existsSync(p)); if (gitBashPath) { await runWindowsCommand(`"${gitBashPath}" -c "${escapedBashCommand}"`); } else { @@ -621,5 +691,65 @@ export function registerClaudeCodeHandlers(): void { } ); + // Get available Claude Code versions + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS, + async (): Promise> => { + try { + console.log('[Claude Code] Fetching available versions...'); + const versions = await fetchAvailableVersions(); + console.log('[Claude Code] Found', versions.length, 'versions'); + return { + success: true, + data: { versions }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Failed to fetch versions:', errorMsg, error); + return { + success: false, + error: `Failed to fetch available versions: ${errorMsg}`, + }; + } + } + ); + + // Install a specific version of Claude Code + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, + async (_event, version: string): Promise> => { + try { + // Validate version format + if (!version || typeof version !== 'string') { + throw new Error('Invalid version specified'); + } + + // Basic semver validation + if (!semver.valid(version)) { + throw new Error(`Invalid version format: ${version}`); + } + + console.log('[Claude Code] Installing version:', version); + const command = getInstallVersionCommand(version); + console.log('[Claude Code] Install command:', command); + console.log('[Claude Code] Opening terminal...'); + await openTerminalWithCommand(command); + console.log('[Claude Code] Terminal opened successfully'); + + return { + success: true, + data: { command, version }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Install version failed:', errorMsg, error); + return { + success: false, + error: `Failed to install version: ${errorMsg}`, + }; + } + } + ); + console.warn('[IPC] Claude Code handlers registered'); } diff --git a/apps/frontend/src/preload/api/modules/claude-code-api.ts b/apps/frontend/src/preload/api/modules/claude-code-api.ts index 7c39044b..b5c93590 100644 --- a/apps/frontend/src/preload/api/modules/claude-code-api.ts +++ b/apps/frontend/src/preload/api/modules/claude-code-api.ts @@ -4,10 +4,12 @@ * Provides access to Claude Code CLI management: * - Check installed vs latest version * - Install or update Claude Code + * - Get available versions for rollback + * - Install specific version */ import { IPC_CHANNELS } from '../../../shared/constants'; -import type { ClaudeCodeVersionInfo } from '../../../shared/types/cli'; +import type { ClaudeCodeVersionInfo, ClaudeCodeVersionList } from '../../../shared/types/cli'; import { invokeIpc } from './ipc-utils'; /** @@ -30,6 +32,27 @@ export interface ClaudeCodeVersionResult { error?: string; } +/** + * Result of fetching available versions + */ +export interface ClaudeCodeVersionsResult { + success: boolean; + data?: ClaudeCodeVersionList; + error?: string; +} + +/** + * Result of installing a specific version + */ +export interface ClaudeCodeInstallVersionResult { + success: boolean; + data?: { + command: string; + version: string; + }; + error?: string; +} + /** * Claude Code API interface exposed to renderer */ @@ -45,6 +68,18 @@ export interface ClaudeCodeAPI { * Opens the user's terminal with the install command */ installClaudeCode: () => Promise; + + /** + * Get available Claude Code CLI versions + * Returns list of versions sorted newest first + */ + getClaudeCodeVersions: () => Promise; + + /** + * Install a specific version of Claude Code CLI + * Opens the user's terminal with the install command for the specified version + */ + installClaudeCodeVersion: (version: string) => Promise; } /** @@ -55,5 +90,11 @@ export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({ invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION), installClaudeCode: (): Promise => - invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL) + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL), + + getClaudeCodeVersions: (): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_GET_VERSIONS), + + installClaudeCodeVersion: (version: string): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL_VERSION, version) }); diff --git a/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx index 11a98bbf..69fe2066 100644 --- a/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx +++ b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx @@ -23,6 +23,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "./ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; import { cn } from "../lib/utils"; import type { ClaudeCodeVersionInfo } from "../../shared/types/cli"; @@ -34,6 +41,8 @@ type StatusType = "loading" | "installed" | "outdated" | "not-found" | "error"; // Check every 24 hours const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +// Delay before re-checking version after install/update +const VERSION_RECHECK_DELAY_MS = 5000; /** * Claude Code CLI status badge for the sidebar. @@ -48,6 +57,14 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) const [isOpen, setIsOpen] = useState(false); const [showUpdateWarning, setShowUpdateWarning] = useState(false); + // Version rollback state + const [availableVersions, setAvailableVersions] = useState([]); + const [isLoadingVersions, setIsLoadingVersions] = useState(false); + const [versionsError, setVersionsError] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + const [showRollbackWarning, setShowRollbackWarning] = useState(false); + const [installError, setInstallError] = useState(null); + // Check Claude Code version const checkVersion = useCallback(async () => { try { @@ -78,6 +95,30 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }, []); + // Fetch available versions + const fetchVersions = useCallback(async () => { + if (!window.electronAPI?.getClaudeCodeVersions) { + return; + } + + setIsLoadingVersions(true); + setVersionsError(null); + + try { + const result = await window.electronAPI.getClaudeCodeVersions(); + if (result.success && result.data) { + setAvailableVersions(result.data.versions); + } else { + setVersionsError(result.error || "Failed to load versions"); + } + } catch (err) { + console.error("Failed to fetch versions:", err); + setVersionsError("Failed to load versions"); + } finally { + setIsLoadingVersions(false); + } + }, []); + // Initial check and periodic re-check useEffect(() => { checkVersion(); @@ -90,12 +131,21 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) return () => clearInterval(interval); }, [checkVersion]); + // Fetch versions when popover opens and Claude is installed + useEffect(() => { + if (isOpen && versionInfo?.installed && availableVersions.length === 0) { + fetchVersions(); + } + }, [isOpen, versionInfo?.installed, availableVersions.length, fetchVersions]); + // Perform the actual install/update const performInstall = async () => { setIsInstalling(true); setShowUpdateWarning(false); + setInstallError(null); try { if (!window.electronAPI?.installClaudeCode) { + setInstallError("Installation not available"); return; } @@ -105,15 +155,51 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) // Re-check after a delay setTimeout(() => { checkVersion(); - }, 5000); + }, VERSION_RECHECK_DELAY_MS); + } else { + setInstallError(result.error || "Installation failed"); } } catch (err) { console.error("Failed to install Claude Code:", err); + setInstallError(err instanceof Error ? err.message : "Installation failed"); } finally { setIsInstalling(false); } }; + // Perform version rollback/switch + const performVersionSwitch = async () => { + if (!selectedVersion) return; + + setIsInstalling(true); + setShowRollbackWarning(false); + setInstallError(null); + + try { + if (!window.electronAPI?.installClaudeCodeVersion) { + setInstallError("Version switching not available"); + return; + } + + const result = await window.electronAPI.installClaudeCodeVersion(selectedVersion); + + if (result.success) { + // Re-check after a delay + setTimeout(() => { + checkVersion(); + }, VERSION_RECHECK_DELAY_MS); + } else { + setInstallError(result.error || "Failed to switch version"); + } + } catch (err) { + console.error("Failed to switch Claude Code version:", err); + setInstallError(err instanceof Error ? err.message : "Failed to switch version"); + } finally { + setIsInstalling(false); + setSelectedVersion(null); + } + }; + // Handle install/update button click const handleInstall = () => { if (status === "outdated") { @@ -125,6 +211,22 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) } }; + // Normalize version string by removing 'v' prefix for comparison + const normalizeVersion = (v: string) => v.replace(/^v/, ''); + + // Handle version selection + const handleVersionSelect = (version: string) => { + // Don't do anything if it's the currently installed version (normalize both for comparison) + const normalizedSelected = normalizeVersion(version); + const normalizedInstalled = versionInfo?.installed ? normalizeVersion(versionInfo.installed) : ''; + if (normalizedSelected === normalizedInstalled) { + return; + } + setInstallError(null); + setSelectedVersion(version); + setShowRollbackWarning(true); + }; + // Get status indicator color const getStatusColor = () => { switch (status) { @@ -292,6 +394,60 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) + {/* Install/Update error display */} + {installError && ( +
+ + {installError} +
+ )} + + {/* Version selector - only show when Claude is installed */} + {versionInfo?.installed && ( +
+ + +
+ )} + {/* Learn more link */}