From b7aefc3d01e6e6a0190f79334e4acd5a490e9381 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?=
=?UTF-8?q?=29?=
Date: Tue, 21 Apr 2026 01:50:29 +0800
Subject: [PATCH 001/111] Make desktop updates less noisy and more truthful
The desktop updater now renders release notes as markdown, avoids fake 0%
progress when the server omits Content-Length, and remembers when the user
has dismissed a specific release prompt so reopening the app does not nag
again for the same version.
Constraint: Existing 0.1.4 clients can receive updater events without total size metadata and users still need a manual update path in About
Rejected: Keep repeating the prompt on every launch | creates avoidable noise after an explicit later decision
Rejected: Global dismiss flag for all future releases | would hide newer versions that should prompt again
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep prompt suppression keyed to availableVersion only; About page visibility and manual update actions must remain available
Tested: bun run test src/stores/updateStore.test.ts src/components/shared/UpdateChecker.test.tsx src/__tests__/generalSettings.test.tsx; bun run lint; manual local updater validation from 0.1.4 to 0.1.5 on /Applications and an extracted v0.1.4 release bundle
Not-tested: Signed and notarized macOS distribution behavior outside this local machine
---
.../src/__tests__/generalSettings.test.tsx | 71 +++++++++++++++++++
.../components/shared/UpdateChecker.test.tsx | 65 +++++++++++++++++
.../src/components/shared/UpdateChecker.tsx | 34 ++++++---
desktop/src/i18n/locales/en.ts | 1 +
desktop/src/i18n/locales/zh.ts | 1 +
desktop/src/lib/formatBytes.ts | 18 +++++
desktop/src/pages/Settings.tsx | 36 +++++++---
desktop/src/stores/updateStore.test.ts | 48 +++++++++++++
desktop/src/stores/updateStore.ts | 34 ++++++++-
9 files changed, 289 insertions(+), 19 deletions(-)
create mode 100644 desktop/src/components/shared/UpdateChecker.test.tsx
create mode 100644 desktop/src/lib/formatBytes.ts
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 && (
-
-
+ {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