diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts
index e3143835b6e..2faa8abd8a7 100644
--- a/src/renderer/src/components/UpdateCard.test.ts
+++ b/src/renderer/src/components/UpdateCard.test.ts
@@ -317,8 +317,8 @@ type VisibilityInput = {
status: UpdateStatus
dismissedVersion: string | null
cachedVersion: string | null
- hasStartedDownload: boolean
updateUserInitiatedCycle?: boolean
+ collapsed?: boolean
}
type VisibilityResult = 'hidden' | 'visible'
@@ -338,8 +338,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'idle' },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('hidden')
})
@@ -353,8 +352,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'checking' },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('hidden')
})
@@ -364,8 +362,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'checking', userInitiated: true },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -375,8 +372,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'not-available' },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('hidden')
})
@@ -386,8 +382,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'not-available', userInitiated: true },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -397,8 +392,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -408,8 +402,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: RICH_CHANGELOG },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -419,8 +412,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: '1.2.0',
- cachedVersion: '1.2.0',
- hasStartedDownload: false
+ cachedVersion: '1.2.0'
})
).toBe('hidden')
})
@@ -431,7 +423,6 @@ describe('UpdateCard visibility gates', () => {
status: { state: 'available', version: '1.2.0', changelog: null },
dismissedVersion: '1.2.0',
cachedVersion: '1.2.0',
- hasStartedDownload: false,
updateUserInitiatedCycle: true
})
).toBe('visible')
@@ -442,8 +433,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloading', percent: 42, version: '1.2.0' },
dismissedVersion: '1.2.0',
- cachedVersion: '1.2.0',
- hasStartedDownload: true
+ cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -453,19 +443,22 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloaded', version: '1.2.0' },
dismissedVersion: '1.2.0',
- cachedVersion: '1.2.0',
- hasStartedDownload: false
+ cachedVersion: '1.2.0'
})
).toBe('hidden')
})
it('hides background errors silently', () => {
+ const store = createTestStore()
+ setState(store, { state: 'checking' })
+ setState(store, { state: 'error', message: 'network' })
+
expect(
computeVisibility({
- status: { state: 'error', message: 'network' },
+ status: store.getState().updateStatus,
+ collapsed: store.getState().updateCardCollapsed,
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('hidden')
})
@@ -475,8 +468,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'network', userInitiated: true },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -486,8 +478,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'ENOSPC' },
dismissedVersion: null,
- cachedVersion: '1.2.0',
- hasStartedDownload: true
+ cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -497,8 +488,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'ENOSPC' },
dismissedVersion: null,
- cachedVersion: '1.2.0',
- hasStartedDownload: false
+ cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -517,8 +507,7 @@ describe('UpdateCard visibility gates', () => {
}
},
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -528,8 +517,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'invalid metadata', version: '1.2.0' },
dismissedVersion: null,
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('visible')
})
@@ -539,8 +527,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'downloaded', version: '1.2.0' },
dismissedVersion: null,
- cachedVersion: '1.2.0',
- hasStartedDownload: true
+ cachedVersion: '1.2.0'
})
).toBe('visible')
})
@@ -550,8 +537,7 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'available', version: '1.3.0', changelog: null },
dismissedVersion: '1.2.0',
- cachedVersion: '1.3.0',
- hasStartedDownload: false
+ cachedVersion: '1.3.0'
})
).toBe('visible')
})
@@ -561,19 +547,23 @@ describe('UpdateCard visibility gates', () => {
computeVisibility({
status: { state: 'error', message: 'fail', userInitiated: true },
dismissedVersion: '1.2.0',
- cachedVersion: '1.2.0',
- hasStartedDownload: false
+ cachedVersion: '1.2.0'
})
).toBe('visible')
})
it('hides check errors once a new checking cycle cleared the cached version', () => {
+ const store = createTestStore()
+ setState(store, { state: 'available', version: '1.2.0', changelog: null })
+ setState(store, { state: 'checking' })
+ setState(store, { state: 'error', message: 'network timeout' })
+
expect(
computeVisibility({
- status: { state: 'error', message: 'network timeout' },
+ status: store.getState().updateStatus,
+ collapsed: store.getState().updateCardCollapsed,
dismissedVersion: '1.2.0',
- cachedVersion: null,
- hasStartedDownload: false
+ cachedVersion: null
})
).toBe('hidden')
})
@@ -649,8 +639,7 @@ describe('full update lifecycle through setUpdateStatus', () => {
computeVisibility({
status: store.getState().updateStatus,
dismissedVersion: store.getState().dismissedUpdateVersion,
- cachedVersion: '1.3.0',
- hasStartedDownload: false
+ cachedVersion: '1.3.0'
})
).toBe('visible')
})
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx
index e2023ae21e1..1094fce2439 100644
--- a/src/renderer/src/components/UpdateCard.tsx
+++ b/src/renderer/src/components/UpdateCard.tsx
@@ -108,7 +108,6 @@ export function UpdateCard(): React.JSX.Element | null {
status,
dismissedVersion,
cachedVersion,
- hasStartedDownload: hasStartedDownload.current,
updateUserInitiatedCycle,
autoDismissed,
collapsed
diff --git a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts
index f18629a4fbe..665e732cdc5 100644
--- a/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts
+++ b/src/renderer/src/components/maintenance/update-card/update-card-visibility.ts
@@ -4,7 +4,6 @@ export function isUpdateCardVisible({
status,
dismissedVersion,
cachedVersion,
- hasStartedDownload,
updateUserInitiatedCycle,
autoDismissed = false,
collapsed = false
@@ -12,18 +11,11 @@ export function isUpdateCardVisible({
status: UpdateStatus
dismissedVersion: string | null
cachedVersion: string | null
- hasStartedDownload: boolean
updateUserInitiatedCycle: boolean
autoDismissed?: boolean
collapsed?: boolean
}): boolean {
const isUserInitiated = 'userInitiated' in status && Boolean(status.userInitiated)
- const shouldShowDetailedErrorCard =
- status.state === 'error' &&
- (hasStartedDownload ||
- cachedVersion !== null ||
- status.version !== undefined ||
- status.recovery?.kind === 'linux-package-install')
if (status.state === 'checking' && !isUserInitiated) {
return false
@@ -34,9 +26,6 @@ export function isUpdateCardVisible({
if (status.state === 'idle') {
return false
}
- if (status.state === 'error' && !shouldShowDetailedErrorCard && !isUserInitiated) {
- return false
- }
if (cachedVersion && dismissedVersion === cachedVersion && !updateUserInitiatedCycle) {
if (status.state !== 'downloading' && status.state !== 'error') {
return false
diff --git a/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx
new file mode 100644
index 00000000000..085795246cf
--- /dev/null
+++ b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx
@@ -0,0 +1,156 @@
+// @vitest-environment happy-dom
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { UpdateStatus } from '../../../../shared/update-status-types'
+import { useAppStore } from '../../store'
+import { UpdateCard } from '../UpdateCard'
+import { TooltipProvider } from '../ui/tooltip'
+import { UpdateStatusSegment } from './UpdateStatusSegment'
+
+const check = vi.fn()
+const message = 'Could not reach the update server: net::ERR_CONNECTION_REFUSED'
+const error: UpdateStatus = { state: 'error', message }
+const actionableErrors: UpdateStatus[] = [
+ { ...error, userInitiated: true },
+ { ...error, version: '1.4.200' },
+ {
+ ...error,
+ recovery: {
+ kind: 'linux-package-install',
+ packageType: 'deb',
+ reason: 'manual-install-required',
+ version: '1.4.200'
+ }
+ }
+]
+
+function setStatus(status: UpdateStatus): void {
+ act(() => useAppStore.getState().setUpdateStatus(status))
+}
+
+function renderUpdateControls(): void {
+ render(
+
+
+
+
+ )
+}
+
+function errorToggle(): HTMLElement {
+ return screen.getByRole('button', { name: 'Update failed. Click to expand.' })
+}
+
+beforeEach(() => {
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ check.mockReset().mockResolvedValue(undefined)
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockReturnValue({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ })
+ )
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { updater: { check } }
+ })
+})
+
+afterEach(() => {
+ cleanup()
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ vi.unstubAllGlobals()
+})
+
+describe('update status disclosure', () => {
+ it('opens background check failure details on the first click and offers a re-check', () => {
+ renderUpdateControls()
+ setStatus({ state: 'checking' })
+ setStatus(error)
+
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ fireEvent.click(errorToggle())
+
+ expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
+ expect(errorToggle().getAttribute('aria-expanded')).toBe('true')
+ fireEvent.click(screen.getByRole('button', { name: 'Show details' }))
+ expect(screen.getByText(message)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'Re-check' }))
+ expect(check).toHaveBeenCalledWith({ includePrerelease: false })
+
+ fireEvent.click(errorToggle())
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('announces a quiet automatic check failure as collapsed', () => {
+ setStatus(error)
+ renderUpdateControls()
+
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('preserves the disclosure choice until the next automatic check', () => {
+ renderUpdateControls()
+ setStatus(error)
+ fireEvent.click(errorToggle())
+ setStatus({ ...error, message: 'Still unavailable' })
+ expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
+
+ fireEvent.click(errorToggle())
+ setStatus(error)
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ fireEvent.click(errorToggle())
+
+ setStatus({ state: 'checking' })
+ setStatus(error)
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ expect(errorToggle().getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it.each([
+ { state: 'checking', userInitiated: true },
+ { state: 'available', version: '1.4.200', changelog: null },
+ { state: 'downloading', version: '1.4.200', percent: 50 },
+ { state: 'downloaded', version: '1.4.200' }
+ ])('opens an error after $state without requiring a status click', (previousStatus) => {
+ setStatus(previousStatus)
+ setStatus(error)
+ renderUpdateControls()
+
+ expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
+ expect(errorToggle().getAttribute('aria-expanded')).toBe('true')
+ })
+
+ it.each(actionableErrors)(
+ 'opens an explicit actionable error on initial receipt: %j',
+ (status) => {
+ setStatus(status)
+ renderUpdateControls()
+
+ expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
+ expect(
+ screen.getByRole('button', { name: /Click to expand/ }).getAttribute('aria-expanded')
+ ).toBe('true')
+ }
+ )
+
+ it.each(actionableErrors)(
+ 'opens a newly actionable error and preserves dismissal on repeat: %j',
+ (status) => {
+ renderUpdateControls()
+ setStatus(error)
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+
+ setStatus(status)
+ expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: /Click to expand/ }))
+ setStatus({ ...status })
+ expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull()
+ }
+ )
+})
diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
index d913f998d2c..9cbdcdf4d3e 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
@@ -182,7 +182,7 @@ export type UISlicePersistence = {
/** Dev-only channel override; null follows the running build's own channel. */
releaseChannelOverride: ReleaseChannel | null
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
- // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus).
+ // Ephemeral disclosure state; setUpdateStatus initializes it when the phase or error actionability changes.
updateCardCollapsed: boolean
setUpdateCardCollapsed: (collapsed: boolean) => void
updateReassuranceSeen: boolean
diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
index 942ff9610a5..c80e08157cf 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
@@ -8,7 +8,7 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
return {
updateStatus: { state: 'idle' },
setUpdateStatus: (status) => {
- const prevState = get().updateStatus.state
+ const { updateStatus: previousStatus, updateUserInitiatedCycle } = get()
const update: Partial<
Pick<
UISlice,
@@ -34,9 +34,22 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
update.updateChangelog = null
}
// 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content.
- if (status.state !== prevState) {
- // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`.
- update.updateCardCollapsed = false
+ const errorBecameActionable =
+ status.state === 'error' &&
+ previousStatus.state === 'error' &&
+ ((status.userInitiated === true && !previousStatus.userInitiated) ||
+ (status.version !== undefined && previousStatus.version === undefined) ||
+ (status.recovery?.kind === 'linux-package-install' &&
+ previousStatus.recovery?.kind !== 'linux-package-install'))
+ if (status.state !== previousStatus.state || errorBecameActionable) {
+ // Quiet check failures start collapsed so the status bar can still disclose them.
+ update.updateCardCollapsed =
+ status.state === 'error' &&
+ !status.userInitiated &&
+ !updateUserInitiatedCycle &&
+ status.version === undefined &&
+ status.recovery?.kind !== 'linux-package-install' &&
+ !('version' in previousStatus && previousStatus.version !== undefined)
}
set(update)
},
diff --git a/tests/e2e/update-status-error-details.spec.ts b/tests/e2e/update-status-error-details.spec.ts
new file mode 100644
index 00000000000..a007a10de13
--- /dev/null
+++ b/tests/e2e/update-status-error-details.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from './helpers/orca-app'
+import { waitForSessionReady } from './helpers/store'
+
+const CHECK_ERROR = 'E2E update check failed: connection refused'
+
+test.use({ seedTestRepo: false })
+
+for (const theme of ['dark', 'light'] as const) {
+ test(`automatic update failure opens details from the status bar (${theme})`, async ({
+ orcaPage
+ }, testInfo) => {
+ await waitForSessionReady(orcaPage)
+ await orcaPage.setViewportSize({ width: 1200, height: 800 })
+ await orcaPage.evaluate(async (theme) => {
+ const state = window.__store!.getState()
+ await state.updateSettingsOrThrow({ theme })
+ state.setUpdateStatus({ state: 'checking', userInitiated: false })
+ }, theme)
+ await expect(orcaPage.locator('html')).toHaveClass(theme === 'dark' ? /\bdark\b/ : /\blight\b/)
+ await orcaPage.evaluate((message) => {
+ window.__store!.getState().setUpdateStatus({
+ state: 'error',
+ message,
+ userInitiated: false
+ })
+ }, CHECK_ERROR)
+
+ const statusButton = orcaPage.getByRole('button', {
+ name: 'Update failed. Click to expand.',
+ exact: true
+ })
+ const card = orcaPage.getByRole('complementary', { name: 'Update error', exact: true })
+ await expect(statusButton).toBeVisible()
+ await expect(card).toBeHidden()
+
+ await statusButton.click()
+ // Capture before the assertion so the broken build provides the same visual evidence.
+ const statusClickScreenshot = testInfo.outputPath(
+ `update-error-after-status-click-${theme}.png`
+ )
+ await orcaPage.screenshot({ path: statusClickScreenshot, animations: 'disabled' })
+ await testInfo.attach(`update-error-after-status-click-${theme}`, {
+ path: statusClickScreenshot,
+ contentType: 'image/png'
+ })
+ await expect(card).toBeVisible()
+ await expect(statusButton).toHaveAttribute('aria-expanded', 'true')
+ await expect(card.getByRole('heading', { name: 'Update Check Failed' })).toBeVisible()
+ await expect(card.getByRole('button', { name: 'Re-check', exact: true })).toBeVisible()
+
+ await card.getByRole('button', { name: 'Show details', exact: true }).click()
+ await expect(card.getByText(CHECK_ERROR, { exact: true })).toBeVisible()
+ const detailsScreenshot = testInfo.outputPath(`update-error-expanded-details-${theme}.png`)
+ await orcaPage.screenshot({ path: detailsScreenshot, animations: 'disabled' })
+ await testInfo.attach(`update-error-expanded-details-${theme}`, {
+ path: detailsScreenshot,
+ contentType: 'image/png'
+ })
+
+ await card.getByRole('button', { name: 'Minimize to status bar', exact: true }).click()
+ await expect(card).toBeHidden()
+ await expect(statusButton).toHaveAttribute('aria-expanded', 'false')
+ await statusButton.click()
+ await expect(card).toBeVisible()
+ await expect(statusButton).toHaveAttribute('aria-expanded', 'true')
+ })
+}