diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 2f7ad26b..c53c2996 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -4,6 +4,8 @@ import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSettingsStore } from '../stores/settingsStore' +import { useUIStore } from '../stores/uiStore' +import { useUpdateStore } from '../stores/updateStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -69,6 +71,23 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), }) + + useUIStore.setState({ pendingSettingsTab: null }) + useUpdateStore.setState({ + status: 'idle', + availableVersion: null, + releaseNotes: null, + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: false, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) }) it('shows WebFetch preflight toggle enabled by default', () => { @@ -91,3 +110,55 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) }) }) + +describe('Settings > About tab', () => { + beforeEach(() => { + useUIStore.setState({ pendingSettingsTab: 'about' }) + useUpdateStore.setState({ + status: 'available', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5\n\n- Fixed updater rendering\n- Added markdown support', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + }) + + it('renders release notes with markdown formatting', async () => { + render() + + expect(await screen.findByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument() + expect(screen.getByText('Fixed updater rendering')).toBeInTheDocument() + expect(screen.getByText('Added markdown support')).toBeInTheDocument() + }) + + it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => { + useUpdateStore.setState({ + status: 'downloading', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5', + progressPercent: 0, + downloadedBytes: 1536, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + + render() + + expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument() + expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx new file mode 100644 index 00000000..da1b0c60 --- /dev/null +++ b/desktop/src/components/shared/UpdateChecker.test.tsx @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { UpdateChecker } from './UpdateChecker' +import { useUpdateStore } from '../../stores/updateStore' + +describe('UpdateChecker', () => { + beforeEach(() => { + Object.defineProperty(window, '__TAURI__', { + value: {}, + configurable: true, + }) + + useUpdateStore.setState({ + status: 'available', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5\n\n[Release notes](https://example.com/releases/v0.1.5)', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + }) + + it('renders markdown release notes in the update prompt', () => { + render() + + expect(screen.getByText('v0.1.5 available')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Release notes' }) + expect(link).toHaveAttribute('href', 'https://example.com/releases/v0.1.5') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('shows downloaded bytes when the updater does not provide total size', () => { + useUpdateStore.setState({ + status: 'downloading', + availableVersion: '0.1.5', + releaseNotes: '# Claude Code Haha v0.1.5', + progressPercent: 0, + downloadedBytes: 1536, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: true, + initialize: vi.fn().mockResolvedValue(undefined), + checkForUpdates: vi.fn().mockResolvedValue(null), + installUpdate: vi.fn().mockResolvedValue(undefined), + dismissPrompt: vi.fn(), + }) + + render() + + expect(screen.getByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument() + expect(screen.queryByText(/0%/)).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index 9292cd8a..d9bec6a5 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -1,7 +1,9 @@ import { useEffect } from 'react' import { useTranslation } from '../../i18n' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { isTauriRuntime } from '../../lib/desktopRuntime' import { useUpdateStore } from '../../stores/updateStore' +import { formatBytes } from '../../lib/formatBytes' export function UpdateChecker() { const t = useTranslation() @@ -9,6 +11,8 @@ export function UpdateChecker() { const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) const progressPercent = useUpdateStore((s) => s.progressPercent) + const downloadedBytes = useUpdateStore((s) => s.downloadedBytes) + const totalBytes = useUpdateStore((s) => s.totalBytes) const error = useUpdateStore((s) => s.error) const shouldPrompt = useUpdateStore((s) => s.shouldPrompt) const initialize = useUpdateStore((s) => s.initialize) @@ -26,11 +30,15 @@ export function UpdateChecker() { if (!showPopup) return null + const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 + const downloadedText = formatBytes(downloadedBytes) const statusText = status === 'restarting' ? t('update.restarting') : status === 'downloading' - ? t('update.downloading') + ? hasKnownProgress + ? t('update.downloading') + : t('update.progressBytes', { downloaded: downloadedText }) : null return ( @@ -41,22 +49,30 @@ export function UpdateChecker() {

{releaseNotes && ( -

- {releaseNotes} -

+
+ +
)} {(status === 'downloading' || status === 'restarting') && (
-
+ {hasKnownProgress || status === 'restarting' ? ( +
+ ) : ( +
+ )}
{statusText && (

- {statusText} {status === 'downloading' ? `${progressPercent}%` : ''} + {statusText} + {status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}

)}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 78751bd5..63e738e3 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -625,6 +625,7 @@ export const en = { 'update.now': 'Update now', 'update.later': 'Later', 'update.progress': 'Downloading update... {progress}%', + 'update.progressBytes': 'Downloading update... {downloaded} downloaded', 'update.releaseNotes': 'Release Notes', 'update.restarting': 'Restarting to finish update...', 'update.upToDate': 'You are up to date on v{version}.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ce10bff0..6e353804 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -627,6 +627,7 @@ export const zh: Record = { 'update.now': '立即更新', 'update.later': '稍后', 'update.progress': '正在下载更新... {progress}%', + 'update.progressBytes': '正在下载更新... 已下载 {downloaded}', 'update.releaseNotes': '更新说明', 'update.restarting': '正在重启以完成更新...', 'update.upToDate': '当前已是最新版本 v{version}。', diff --git a/desktop/src/lib/formatBytes.ts b/desktop/src/lib/formatBytes.ts new file mode 100644 index 00000000..2e7cdaef --- /dev/null +++ b/desktop/src/lib/formatBytes.ts @@ -0,0 +1,18 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unitIndex = 0 + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const maximumFractionDigits = value >= 10 || unitIndex === 0 ? 0 : 1 + + return `${new Intl.NumberFormat(undefined, { + maximumFractionDigits, + }).format(value)} ${units[unitIndex]}` +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 972459c2..3e7e6469 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -22,6 +22,7 @@ import { ComputerUseSettings } from './ComputerUseSettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' +import { formatBytes } from '../lib/formatBytes' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -1279,6 +1280,8 @@ function AboutSettings() { const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) const progressPercent = useUpdateStore((s) => s.progressPercent) + const downloadedBytes = useUpdateStore((s) => s.downloadedBytes) + const totalBytes = useUpdateStore((s) => s.totalBytes) const error = useUpdateStore((s) => s.error) const checkedAt = useUpdateStore((s) => s.checkedAt) const checkForUpdates = useUpdateStore((s) => s.checkForUpdates) @@ -1307,11 +1310,15 @@ function AboutSettings() { }) : null + const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 + const downloadedText = formatBytes(downloadedBytes) const updateDescription = updateStatus === 'checking' ? t('update.checking') : updateStatus === 'downloading' - ? t('update.progress', { progress: String(progressPercent) }) + ? hasKnownProgress + ? t('update.progress', { progress: String(progressPercent) }) + : t('update.progressBytes', { downloaded: downloadedText }) : updateStatus === 'restarting' ? t('update.restarting') : updateStatus === 'available' && availableVersion @@ -1400,22 +1407,33 @@ function AboutSettings() { {(updateStatus === 'downloading' || updateStatus === 'restarting') && (
-
+ {hasKnownProgress || updateStatus === 'restarting' ? ( +
+ ) : ( +
+ )}
+ {!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && ( +

+ {downloadedText} +

+ )}
)} {releaseNotes && availableVersion && ( -
+
{t('update.releaseNotes')}
-

- {releaseNotes} -

+
)} diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index c5eb22e9..b4ccd78d 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -15,6 +15,7 @@ describe('updateStore', () => { beforeEach(() => { check.mockReset() relaunch.mockReset() + window.localStorage.clear() Object.defineProperty(window, '__TAURI_INTERNALS__', { configurable: true, value: {}, @@ -41,6 +42,53 @@ describe('updateStore', () => { expect(useUpdateStore.getState().shouldPrompt).toBe(true) }) + it('does not re-prompt for the same version after dismissing once', async () => { + check.mockResolvedValue({ + version: '0.2.0', + body: 'Bug fixes and performance improvements', + close: vi.fn().mockResolvedValue(undefined), + }) + + vi.resetModules() + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().checkForUpdates() + useUpdateStore.getState().dismissPrompt() + + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + expect(window.localStorage.getItem('cc-haha-dismissed-update-version')).toBe('0.2.0') + + await useUpdateStore.getState().checkForUpdates({ silent: true }) + + expect(useUpdateStore.getState().status).toBe('available') + expect(useUpdateStore.getState().availableVersion).toBe('0.2.0') + expect(useUpdateStore.getState().shouldPrompt).toBe(false) + }) + + it('prompts again when a newer version is available after dismissing an older one', async () => { + check + .mockResolvedValueOnce({ + version: '0.2.0', + body: 'Bug fixes and performance improvements', + close: vi.fn().mockResolvedValue(undefined), + }) + .mockResolvedValueOnce({ + version: '0.3.0', + body: 'New release', + close: vi.fn().mockResolvedValue(undefined), + }) + + vi.resetModules() + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().checkForUpdates() + useUpdateStore.getState().dismissPrompt() + await useUpdateStore.getState().checkForUpdates({ silent: true }) + + expect(useUpdateStore.getState().availableVersion).toBe('0.3.0') + expect(useUpdateStore.getState().shouldPrompt).toBe(true) + }) + it('computes download progress from content length and relaunches after install', async () => { const downloadAndInstall = vi.fn(async (onEvent?: (event: unknown) => void) => { onEvent?.({ event: 'Started', data: { contentLength: 200 } }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index 90d5efa1..e72b5733 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -15,6 +15,8 @@ type CheckOptions = { silent?: boolean } +const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version' + type UpdateStore = { status: UpdateStatus availableVersion: string | null @@ -34,6 +36,30 @@ type UpdateStore = { let pendingUpdate: Update | null = null let startupCheckPromise: Promise | null = null +function readDismissedUpdateVersion(): string | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage.getItem(DISMISSED_UPDATE_VERSION_KEY) + } catch { + return null + } +} + +function writeDismissedUpdateVersion(version: string | null) { + if (typeof window === 'undefined') return + + try { + if (version) { + window.localStorage.setItem(DISMISSED_UPDATE_VERSION_KEY, version) + } else { + window.localStorage.removeItem(DISMISSED_UPDATE_VERSION_KEY) + } + } catch { + // Ignore storage write failures. + } +} + async function setPendingUpdate(next: Update | null) { const previous = pendingUpdate pendingUpdate = next @@ -93,6 +119,7 @@ export const useUpdateStore = create((set, get) => ({ const checkedAt = Date.now() if (!update) { + writeDismissedUpdateVersion(null) set((state) => ({ ...state, status: 'up-to-date', @@ -108,6 +135,9 @@ export const useUpdateStore = create((set, get) => ({ return null } + const dismissedVersion = readDismissedUpdateVersion() + const shouldPrompt = dismissedVersion !== update.version + set((state) => ({ ...state, status: 'available', @@ -118,7 +148,7 @@ export const useUpdateStore = create((set, get) => ({ totalBytes: null, checkedAt, error: null, - shouldPrompt: true, + shouldPrompt, })) return update } catch (error) { @@ -160,6 +190,7 @@ export const useUpdateStore = create((set, get) => ({ })) try { + writeDismissedUpdateVersion(null) const { relaunch } = await import('@tauri-apps/plugin-process') let totalBytes: number | null = null let downloadedBytes = 0 @@ -213,6 +244,7 @@ export const useUpdateStore = create((set, get) => ({ }, dismissPrompt: () => { + writeDismissedUpdateVersion(get().availableVersion) set((state) => ({ ...state, shouldPrompt: false,