{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')}
{status}
diff --git a/packages/client/ui-settings-account/src/client/account-avatar.ts b/packages/client/ui-settings-account/src/client/account-avatar.ts
new file mode 100644
index 0000000000..6a1e5dd5db
--- /dev/null
+++ b/packages/client/ui-settings-account/src/client/account-avatar.ts
@@ -0,0 +1,92 @@
+/** One account picture load shared by the sidebar and Settings for this plugin lifetime. */
+
+/** Browser image outcome for the latest profile picture URL. */
+export interface AccountAvatarSnapshot {
+ /** Profile picture whose browser load produced this state. */
+ url: string
+ /** Both account surfaces show the picture only after its shared load succeeds. */
+ status: 'loading' | 'ready' | 'failed'
+}
+
+/** Owns the browser image and discards callbacks after URL replacement or plugin unload. */
+export class AccountAvatarLoader {
+ private snapshot: AccountAvatarSnapshot | undefined
+ private image: HTMLImageElement | undefined
+ private disposed = false
+
+ /** @param publish - publishes picture changes through the account snapshot. */
+ constructor(private readonly publish: (snapshot: AccountAvatarSnapshot | undefined) => void) {}
+
+ /**
+ * Load a changed URL once; an absent or empty URL clears the picture.
+ * @param url - latest successful profile's picture, absent when no picture is available.
+ */
+ setUrl(url: string | null | undefined): void {
+ if (this.disposed) return
+ const source = url || undefined
+ if (this.snapshot?.url === source) return
+ if (source === undefined) {
+ this.detach()
+ this.update(undefined)
+ }
+ else this.load(source)
+ }
+
+ /** Retry a failed picture once; successful and pending loads remain shared. */
+ retry(): void {
+ if (!this.disposed && this.snapshot?.status === 'failed') this.load(this.snapshot.url)
+ }
+
+ /**
+ * Return both surfaces to fallback when a rendered picture fails.
+ * @param snapshot - picture attempt rendered by the failing element; superseded attempts are ignored.
+ */
+ failed(snapshot: AccountAvatarSnapshot): void {
+ if (this.disposed || this.snapshot !== snapshot || snapshot.status !== 'ready') return
+ this.detach()
+ this.update({ url: snapshot.url, status: 'failed' })
+ }
+
+ /** Release the picture without publishing; the owner publishes an empty avatar with the replacement account frame. */
+ reset(): void {
+ this.detach()
+ this.snapshot = undefined
+ }
+
+ /** Release the browser image and prevent further loads or publications. */
+ dispose(): void {
+ this.disposed = true
+ this.reset()
+ }
+
+ private load(url: string): void {
+ this.detach()
+ const image = new Image()
+ this.image = image
+ image.referrerPolicy = 'no-referrer'
+ const settle = (status: 'ready' | 'failed'): void => {
+ if (this.image !== image) return
+ image.onload = null
+ image.onerror = null
+ this.update({ url, status })
+ }
+ image.onload = () => { settle('ready') }
+ image.onerror = () => { settle('failed') }
+ this.update({ url, status: 'loading' })
+ if (this.image === image) image.src = url
+ }
+
+ private update(snapshot: AccountAvatarSnapshot | undefined): void {
+ this.snapshot = snapshot
+ this.publish(snapshot)
+ }
+
+ private detach(): void {
+ const image = this.image
+ this.image = undefined
+ if (image === undefined) return
+ image.onload = null
+ image.onerror = null
+ image.removeAttribute('src')
+ }
+}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts
index bbb892ca91..890b61311b 100644
--- a/packages/client/ui-settings-account/src/client/index.ts
+++ b/packages/client/ui-settings-account/src/client/index.ts
@@ -30,6 +30,7 @@ import { DesktopOnboardingController } from './onboarding-state.ts'
import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx'
import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts'
import { refreshAfterReturn } from './account-refresh.ts'
+import { AccountAvatarLoader } from './account-avatar.ts'
export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx'
export type { AccountMenuProps } from './AccountMenu.tsx'
export type { AccountSnapshot } from './AccountSection.tsx'
@@ -49,13 +50,15 @@ export function apply(ctx: Context): void {
const page = globalThis as Partial
>
const config = ContactConfig(page[CONTACT_CONFIG_GLOBAL] ?? {})
let snapshot: AccountSnapshot = {
- view: undefined, details: undefined, failed: false, loginVisible: false, desktopOnboardingVisible: desktop,
+ view: undefined, details: undefined, avatar: undefined, failed: false, loginVisible: false, desktopOnboardingVisible: desktop,
}
// Desktop main opens the authorization page natively; a plain browser opens it in a tab it owns.
const browser = new BrowserLogin(url => authorizeUrlWithTheme(url, ctx.theme.getTheme().active.colorScheme))
ctx.effect(() => () => { browser.dispose() }, 'account: pending browser window')
const listeners = new Set<() => void>()
const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() }
+ const avatar = new AccountAvatarLoader((value) => { publish({ ...snapshot, avatar: value }) })
+ ctx.effect(() => () => { avatar.dispose() }, 'account: profile picture lifetime')
/** @returns the client identity for one account call, read at call time so it carries the language and zone in effect then. */
const client = () => accountClientMetadata(ctx.locale.getSnapshot().active, process.env.DSH_CLIENT_VERSION)
// The browser half of the notice lifecycle: reads when the account becomes
@@ -100,6 +103,7 @@ export function apply(ctx: Context): void {
read('profile', async () => {
const result = await ctx.remote.account.getProfile(client())
if (!result.ok) throw new Error('account profile failed')
+ if (generation === revision && result.value?.status === 'ready') avatar.setUrl(result.value.value.avatarUrl)
return result.value
}),
read('balance', async () => {
@@ -122,10 +126,11 @@ export function apply(ctx: Context): void {
for await (const frame of stream) {
revision++
refreshing = undefined
+ avatar.reset()
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
&& snapshot.view?.attempt?.phase !== 'succeeded'
const { notice, ...previous } = snapshot
- publish({ ...previous, view: frame.value, details: undefined, failed: false,
+ publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
...(frame.value.status === 'credential-stored' && notice ? { notice } : {}) })
browser.update(frame.value)
frame.accept()
@@ -151,7 +156,10 @@ export function apply(ctx: Context): void {
// One account refresh: the recharge/bonus wallet and the unnotified-bonus read,
// whichever surface asks — a Settings entry, or returning from top-up. Both
// reads are independent, and a concurrent refresh shares the in-flight request.
- const refreshAccount = async (): Promise => { await Promise.all([refresh(), notices.refresh()]) }
+ const refreshAccount = async (): Promise => {
+ avatar.retry()
+ await Promise.all([refresh(), notices.refresh()])
+ }
/**
* Narrow the shared channel to one surface's post-return read: only a viewer
* returning from top-up re-reads, so a superseded page or a plugin teardown
@@ -165,6 +173,7 @@ export function apply(ctx: Context): void {
if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead)
})
const operations: AccountSectionInjected = {
+ avatarFailed: (value) => { avatar.failed(value) },
subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener),
subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener),
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) },
diff --git a/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts b/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts
new file mode 100644
index 0000000000..2358a722ca
--- /dev/null
+++ b/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts
@@ -0,0 +1,114 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+import { AccountAvatarLoader, type AccountAvatarSnapshot } from '../src/client/account-avatar.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
+
+function imagesOf() {
+ const images: HTMLImageElement[] = []
+ vi.stubGlobal('Image', function () {
+ const image = document.createElement('img')
+ images.push(image)
+ return image
+ })
+ const states: Array = []
+ const loader = new AccountAvatarLoader((state) => { states.push(state) })
+ onTestFinished(() => { loader.dispose() })
+ return { images, states, loader }
+}
+
+it('shares successful and pending loads, and retries each failed load only on request', async () => {
+ const { images, states, loader } = imagesOf()
+ loader.retry()
+ loader.setUrl('https://example.test/avatar.png')
+ expect(images[0]!.referrerPolicy).toBe('no-referrer')
+ expect(images[0]!.src).toBe('https://example.test/avatar.png')
+ loader.setUrl('https://example.test/avatar.png')
+ loader.retry()
+ expect(images).toHaveLength(1)
+ images[0]!.dispatchEvent(new Event('error'))
+ loader.setUrl('https://example.test/avatar.png')
+ expect(images).toHaveLength(1)
+ loader.retry()
+ images[1]!.dispatchEvent(new Event('error'))
+ loader.retry()
+ images[2]!.dispatchEvent(new Event('load'))
+ loader.retry()
+ loader.setUrl('https://example.test/avatar.png')
+ expect(images).toHaveLength(3)
+ await expect(`${JSON.stringify(states, null, 2)}\n`).toMatchFileSnapshot('./expected/avatar-retry.txt')
+ loader.setUrl(null)
+ expect(states.at(-1)).toBeUndefined()
+ expect(images[2]!.getAttribute('src')).toBeNull()
+ loader.setUrl('')
+ expect(images).toHaveLength(3)
+})
+
+it('discards replaced and disposed image callbacks, including the same URL after sign-out', () => {
+ const { images, states, loader } = imagesOf()
+ loader.setUrl('https://example.test/old.png')
+ const oldLoad = images[0]!.onload!
+ const oldError = images[0]!.onerror!
+ loader.setUrl('https://example.test/new.png')
+ expect(images[0]!.onload).toBeNull()
+ expect(images[0]!.onerror).toBeNull()
+ oldLoad.call(images[0]!, new Event('load'))
+ oldError.call(images[0]!, new Event('error'))
+ expect(states.at(-1)).toEqual({ url: 'https://example.test/new.png', status: 'loading' })
+ loader.setUrl(undefined)
+ loader.setUrl('https://example.test/new.png')
+ expect(images).toHaveLength(3)
+ const lateLoad = images[2]!.onload!
+ const lateError = images[2]!.onerror!
+ loader.dispose()
+ const published = states.length
+ lateLoad.call(images[2]!, new Event('load'))
+ lateError.call(images[2]!, new Event('error'))
+ loader.setUrl('https://example.test/after-disposal.png')
+ loader.retry()
+ loader.dispose()
+ expect(states).toHaveLength(published)
+ expect(images).toHaveLength(3)
+})
+
+it('does not start a load when publication releases its owner', () => {
+ const image = document.createElement('img')
+ vi.stubGlobal('Image', function () { return image })
+ const loader = new AccountAvatarLoader(() => { loader.dispose() })
+ onTestFinished(() => { loader.dispose() })
+ loader.setUrl('https://example.test/avatar.png')
+ expect(image.getAttribute('src')).toBeNull()
+ expect(image.onload).toBeNull()
+ expect(image.onerror).toBeNull()
+})
+
+it('shares rendered-image failures and ignores reports from a superseded picture attempt', () => {
+ const { images, states, loader } = imagesOf()
+ loader.setUrl('https://example.test/avatar.png')
+ const loading = states.at(-1)!
+ loader.failed(loading)
+ expect(states.at(-1)).toBe(loading)
+ images[0]!.dispatchEvent(new Event('load'))
+ const ready = states.at(-1)!
+ loader.failed(ready)
+ expect(states.at(-1)).toEqual({ url: ready.url, status: 'failed' })
+ loader.retry()
+ images[1]!.dispatchEvent(new Event('load'))
+ const successor = states.at(-1)!
+ loader.failed(ready)
+ expect(states.at(-1)).toBe(successor)
+ loader.dispose()
+ loader.failed(successor)
+ expect(states.at(-1)).toBe(successor)
+})
+
+it('resets an account picture without publishing an intermediate account snapshot', () => {
+ const { images, states, loader } = imagesOf()
+ loader.setUrl('https://example.test/avatar.png')
+ const lateLoad = images[0]!.onload!
+ loader.reset()
+ lateLoad.call(images[0]!, new Event('load'))
+ expect(states).toHaveLength(1)
+ loader.setUrl('https://example.test/avatar.png')
+ expect(images).toHaveLength(2)
+})
diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx
index 3ac5710387..310bd3aac2 100644
--- a/packages/client/ui-settings-account/tests/account.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx
@@ -14,6 +14,7 @@ import type { AccountMenuProps } from '../src/client/AccountMenu.tsx'
import type {} from '../src/client/index.ts'
import { en, zh, type AccountKey } from '../src/client/locales.ts'
import css from '../src/client/AccountSection.module.css'
+import type { AccountAvatarSnapshot } from '../src/client/account-avatar.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
@@ -23,12 +24,13 @@ const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({
active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0,
})
-function operationsOf(state: Omit, details?: Partial, pages?: PlatformPages): AccountSectionInjected {
+function operationsOf(state: Omit, details?: Partial, pages?: PlatformPages,
+ avatar?: AccountAvatarSnapshot): AccountSectionInjected {
return {
...pages === undefined ? {} : { openPlatformPage: pages.open },
hooks: {
account: {
- getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
+ getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, avatar, failed: false }),
subscribe: () => () => {},
},
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
@@ -37,13 +39,15 @@ function operationsOf(state: Omit, details?: Partial Promise.resolve()),
+ avatarFailed: vi.fn(),
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
}
}
-function mount(state: Omit, copy: typeof en | typeof zh = en, details?: Partial, platform?: PlatformBridge) {
+function mount(state: Omit, copy: typeof en | typeof zh = en, details?: Partial,
+ platform?: PlatformBridge, avatar?: AccountAvatarSnapshot) {
const pages = platform === undefined ? undefined : createPlatformPages()
- const operations = operationsOf(state, details, pages)
+ const operations = operationsOf(state, details, pages, avatar)
// AccountSection consumes no global hooks; the slot supplies them in the application.
const globals = {} as GlobalStandardProps
render(<>
@@ -202,7 +206,7 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
cleanup()
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
const links = { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' }
- let snapshot: AccountSnapshot = { view: { status: 'signed-out', attempt: null, links }, details: undefined, failed: false }
+ let snapshot: AccountSnapshot = { view: { status: 'signed-out', attempt: null, links }, details: undefined, avatar: undefined, failed: false }
// The plugin's start publishes the failure through the account snapshot, then rejects.
const start = vi.fn((): Promise => {
snapshot = { ...snapshot, loginVisible: true, loginFailed: true }
@@ -401,10 +405,11 @@ it.each([
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
})
-it('shows the profile image in settings and the sidebar, with independent load-error fallbacks', async () => {
+it('shows the same shared picture outcome in settings and the sidebar', async () => {
+ const avatar: AccountAvatarSnapshot = { status: 'ready', url: 'https://example.test/avatar.png' }
const operations = mount({ status: 'credential-stored', attempt: null }, en, {
profile: { status: 'ready', value: { id: null, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' } },
- })
+ }, undefined, avatar)
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render( selector(operations.hooks.account.getSnapshot())}
@@ -412,26 +417,24 @@ it('shows the profile image in settings and the sidebar, with independent load-e
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
const images = document.querySelectorAll('img')
expect(images).toHaveLength(2)
- for (const image of images) {
- expect(image.getAttribute('src')).toBe('https://example.test/avatar.png')
- const parent = image.parentElement!
- fireEvent.error(image)
- expect(parent.querySelector('img')).toBeNull()
- expect(parent.querySelector('svg')).not.toBeNull()
- }
+ for (const image of images) expect(image.getAttribute('src')).toBe('https://example.test/avatar.png')
+ for (const image of images) fireEvent.error(image)
+ expect(operations.avatarFailed).toHaveBeenCalledTimes(2)
+ expect(operations.avatarFailed).toHaveBeenCalledWith(avatar)
})
-it('retries a changed avatar URL after an image fails', async () => {
+it.each(['loading', 'failed'] as const)('shows fallback until the shared avatar is ready (%s)', async (status) => {
const { AccountAvatar } = await import('../src/client/AccountAvatar.tsx')
- const view = render()
+ const avatarFailed = vi.fn()
+ const view = render()
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
- view.rerender()
- fireEvent.error(view.container.querySelector('img')!)
+ view.rerender()
+ expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
- view.rerender()
- expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/new.png')
- view.rerender()
+ view.rerender()
+ expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/avatar.png')
+ view.rerender()
expect(view.container.querySelector('img')).toBeNull()
})
@@ -556,7 +559,7 @@ it('opens the embedded Platform page from a failed balance row on Desktop', asyn
it('reports a rejected settings login and disables login while initial state is unavailable', async () => {
const operations = mount({ status: 'signed-out', attempt: null })
cleanup()
- let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: true }
+ let snapshot: AccountSnapshot = { view: undefined, details: undefined, avatar: undefined, failed: true }
const props = { ...({} as GlobalStandardProps), ...operations,
start: vi.fn(async () => { throw new Error('unavailable') }),
useAccount: (select: (value: AccountSnapshot) => T) => select(snapshot),
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts
index e34a109b77..bff9c03b3e 100644
--- a/packages/client/ui-settings-account/tests/apply.client.spec.ts
+++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts
@@ -207,6 +207,48 @@ it('reports the account sampled by the click when a native read outlasts a sign-
expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_uid')).toBe('account-user')
}, 60_000)
+it('publishes one avatar load and retries it through the shared Settings refresh action', async ({ start }) => {
+ const images: HTMLImageElement[] = []
+ vi.stubGlobal('Image', function () {
+ const image = document.createElement('img')
+ images.push(image)
+ return image
+ })
+ const c = await start()
+ const actions = operations(c)
+ const picture: AccountDetails['profile'] = {
+ status: 'ready', value: { id: 'account-user' as AccountUserId, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' },
+ }
+ c.mock.remote.account.getProfile.mockResolvedValue(ok(picture))
+ c.mock.streams.push('account/watch', stored)
+ await vi.waitFor(() => { expect(images).toHaveLength(1) })
+ images[0]!.dispatchEvent(new Event('error'))
+ expect(actions.hooks.account.getSnapshot().avatar?.status).toBe('failed')
+ await actions.refreshAccount()
+ await actions.refreshAccount()
+ expect(images).toHaveLength(2)
+ images[1]!.dispatchEvent(new Event('error'))
+ await actions.refreshAccount()
+ expect(images).toHaveLength(3)
+ images[2]!.dispatchEvent(new Event('load'))
+ expect(actions.hooks.account.getSnapshot().avatar).toEqual({ url: picture.value.avatarUrl, status: 'ready' })
+ await actions.refreshAccount()
+ expect(images).toHaveLength(3)
+ const ready = actions.hooks.account.getSnapshot().avatar!
+ actions.avatarFailed(ready)
+ expect(actions.hooks.account.getSnapshot().avatar?.status).toBe('failed')
+ await actions.refreshAccount()
+ expect(images).toHaveLength(4)
+ images[3]!.dispatchEvent(new Event('load'))
+ const successor = actions.hooks.account.getSnapshot().avatar!
+ actions.avatarFailed(ready)
+ expect(actions.hooks.account.getSnapshot().avatar).toBe(successor)
+ c.mock.streams.push('account/watch', view)
+ await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(view) })
+ expect(actions.hooks.account.getSnapshot().avatar).toBeUndefined()
+ expect(images[3]!.getAttribute('src')).toBeNull()
+}, 60_000)
+
it('keeps successful account refreshes silent and reports only failed reads', async ({ start }) => {
vi.stubGlobal('dshDesktop', {})
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding-entry.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding-entry.client.spec.tsx
index 5dcaaa5f6a..bf8394a7ef 100644
--- a/packages/client/ui-settings-account/tests/desktop-onboarding-entry.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/desktop-onboarding-entry.client.spec.tsx
@@ -48,7 +48,7 @@ function mount(initiallyDone = false, initiallyLoading = false) {
}
let account: AccountSnapshot = {
view: { status: 'credential-stored', attempt: null, links: { usageUrl: 'https://example.com/usage', topUpUrl: 'https://example.com/top_up' } },
- failed: false, details: undefined,
+ failed: false, details: undefined, avatar: undefined,
}
// This entry consumes only its injected selectors; the shell owns the global hooks.
const globals = {} as GlobalStandardProps
diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
index 8152510505..fb47dd4c22 100644
--- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
@@ -36,7 +36,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal
return saved
}}
account={{ view: { status: 'credential-stored', attempt: null, links: { usageUrl: 'https://example.com/usage', topUpUrl: 'https://example.com/top_up' } },
- failed: false, details: balance === 'loading' ? {} : { balance: balance === 'failed' ? { status: 'failed' } : { status: 'ready', bonusWallets: balance === 'bonus' ? [{ currency: 'CNY', balance: '1' }] : [], value: [{ currency: 'CNY', balance: balance === 'zero' || balance === 'bonus' ? '0E-16' : '12.34' }] } } }}
+ avatar: undefined, failed: false, details: balance === 'loading' ? {} : { balance: balance === 'failed' ? { status: 'failed' } : { status: 'ready', bonusWallets: balance === 'bonus' ? [{ currency: 'CNY', balance: '1' }] : [], value: [{ currency: 'CNY', balance: balance === 'zero' || balance === 'bonus' ? '0E-16' : '12.34' }] } } }}
openPlatformPage={openPlatformPage} />
}
render()
diff --git a/packages/client/ui-settings-account/tests/expected/avatar-retry.txt b/packages/client/ui-settings-account/tests/expected/avatar-retry.txt
new file mode 100644
index 0000000000..f631489858
--- /dev/null
+++ b/packages/client/ui-settings-account/tests/expected/avatar-retry.txt
@@ -0,0 +1,26 @@
+[
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "loading"
+ },
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "failed"
+ },
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "loading"
+ },
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "failed"
+ },
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "loading"
+ },
+ {
+ "url": "https://example.test/avatar.png",
+ "status": "ready"
+ }
+]
diff --git a/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts b/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts
index 480ab21ec7..8908c540bc 100644
--- a/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts
+++ b/packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts
@@ -17,7 +17,7 @@ afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await
function accountState(status?: 'credential-stored' | 'signed-out'): AccountSnapshot {
return {
view: status === undefined ? undefined : { status, attempt: null, links: { usageUrl: 'https://platform.example/usage', topUpUrl: 'https://platform.example/top_up' } },
- details: undefined, failed: false,
+ details: undefined, avatar: undefined, failed: false,
}
}
diff --git a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx
index 007aa59644..a66e7f1d02 100644
--- a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx
@@ -17,7 +17,7 @@ const theme: ThemeSnapshot = {
}
it('claims onboarding while loading, offers API key fallback, and completes after login', () => {
- let account: AccountSnapshot = { view: undefined, details: undefined, failed: false }
+ let account: AccountSnapshot = { view: undefined, details: undefined, avatar: undefined, failed: false }
const unused = (): never => { throw new Error('AccountOnboarding does not consume global hooks') }
const globals: GlobalStandardProps = {
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused,
@@ -33,6 +33,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
contactUs: vi.fn(), t: makeTranslate(en),
refreshAccount: vi.fn(async () => {}), bonusNoticeShown: vi.fn(), bonusNoticeDismissed: vi.fn(),
+ avatarFailed: vi.fn(),
}
const view = render()
expect(props.setOnboarding).toHaveBeenCalledExactlyOnceWith(true)
diff --git a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
index 58654ad122..11a42537fe 100644
--- a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
@@ -64,6 +64,7 @@ function sectionOperations(account: AccountSnapshot, pages: PlatformPages): Acco
},
openPlatformPage: pages.open,
refreshAccount: async () => {},
+ avatarFailed: () => {},
bonusNoticeShown: () => {}, bonusNoticeDismissed: () => {},
hasRunningAccountTasks: async () => false,
contactUs: () => {}, showLogin: () => {}, setOnboarding: () => {},
@@ -119,7 +120,7 @@ async function bench() {
runtime.ctx.uiSession.provide = (next) => { descriptor ??= next; return provide(next) }
await runtime.mount({ inject: [...injectChat], apply: applyChat })
if (descriptor === undefined) throw new Error('ui-chat did not provide its standard source')
- const accountSnapshot = createSnapshotStore({ view: storedView, details: undefined, failed: false })
+ const accountSnapshot = createSnapshotStore({ view: storedView, details: undefined, avatar: undefined, failed: false })
const account = {
getSnapshot: () => accountSnapshot.getSnapshot(),
subscribe: (listener: () => void) => accountSnapshot.subscribe(listener),
@@ -366,7 +367,7 @@ describe('shared Platform page ownership', () => {
await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() })
expect(b.platform.open).toHaveBeenCalledOnce()
- act(() => { b.account.set({ view: { ...storedView, status: 'signed-out' }, details: undefined, failed: false }) })
+ act(() => { b.account.set({ view: { ...storedView, status: 'signed-out' }, details: undefined, avatar: undefined, failed: false }) })
expect(b.platform.close).toHaveBeenCalledOnce()
expect(screen.queryByRole('button', { name: en.backToHarness })).toBeNull()
diff --git a/packages/client/ui-settings-account/tests/quota-notice.client.spec.tsx b/packages/client/ui-settings-account/tests/quota-notice.client.spec.tsx
index 6e0033f806..baab0c8003 100644
--- a/packages/client/ui-settings-account/tests/quota-notice.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/quota-notice.client.spec.tsx
@@ -26,7 +26,7 @@ const noticeMessage = 'Request quota exhausted.'
function snapshot(status?: 'signed-out' | 'credential-stored'): AccountSnapshot {
const view: AccountView = { status: status ?? 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } }
- return { view: status === undefined ? undefined : view, details: undefined, failed: false }
+ return { view: status === undefined ? undefined : view, details: undefined, avatar: undefined, failed: false }
}
/** The live page request channel with its acquisition order retained for ordering assertions. */
diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
index 9cd8ef5a37..47e25c4de6 100644
--- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
@@ -14,7 +14,7 @@ function dialogProps(attempt: AccountView['attempt'], copy: typeof en | typeof z
const close = vi.fn()
const useApiKey = vi.fn()
return { start, cancel, close, useApiKey, colorScheme, t: (key: keyof typeof en) => copy[key],
- account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, failed: false } }
+ account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, avatar: undefined, failed: false } }
}
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const props = dialogProps(attempt, copy, colorScheme)