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,