{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..38f232d638
--- /dev/null
+++ b/packages/client/ui-settings-account/src/client/account-avatar.ts
@@ -0,0 +1,74 @@
+/** 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
+ this.detach()
+ if (source === undefined) 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)
+ }
+
+ /** Release the browser image and prevent further loads or publications. */
+ dispose(): void {
+ this.disposed = true
+ this.detach()
+ }
+
+ 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 3e64599cd4..8c73ea5374 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'
@@ -56,6 +57,8 @@ export function apply(ctx: Context): void {
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
@@ -99,6 +102,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 () => {
@@ -121,6 +125,7 @@ export function apply(ctx: Context): void {
for await (const frame of stream) {
revision++
refreshing = undefined
+ avatar.setUrl(undefined)
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
&& snapshot.view?.attempt?.phase !== 'succeeded'
const { notice, ...previous } = snapshot
@@ -150,7 +155,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
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..dab97d02a0
--- /dev/null
+++ b/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts
@@ -0,0 +1,83 @@
+// @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()
+})
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..bf7461f80c 100644
--- a/packages/client/ui-settings-account/tests/account.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx
@@ -401,7 +401,7 @@ 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 operations = mount({ status: 'credential-stored', attempt: null }, en, {
profile: { status: 'ready', value: { id: null, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' } },
})
@@ -410,28 +410,33 @@ it('shows the profile image in settings and the sidebar, with independent load-e
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
+ expect(document.querySelectorAll('img')).toHaveLength(0)
+ const snapshot = operations.hooks.account.getSnapshot()
+ cleanup()
+ snapshot.avatar = { status: 'ready', url: 'https://example.test/avatar.png' }
+ operations.hooks.account.getSnapshot = () => snapshot
+ render(<>
+ {await accountMenu(operations, () => snapshot, true)}
+ selector(snapshot)} useTheme={selector => selector(themeOf('light'))}
+ close={() => {}} 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')
})
-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 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()
})
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 741d485213..accd78005a 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,39 @@ 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)
+ 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[2]!.getAttribute('src')).toBeNull()
+}, 60_000)
+
it('coalesces refreshes, publishes independent failures, and rejects stale responses after sign-out or unload', async ({ start }) => {
vi.stubGlobal('dshDesktop', {})
const c = await start()
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"
+ }
+]
From 2a7ba15ee16016427ebd03a91be619db1062cfe0 Mon Sep 17 00:00:00 2001
From: lsdsjy <1356263+lsdsjy@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:37:22 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(account):=20=E5=90=8C=E6=AD=A5=E5=A4=B4?=
=?UTF-8?q?=E5=83=8F=E6=B8=B2=E6=9F=93=E5=A4=B1=E8=B4=A5=E5=B9=B6=E5=AE=8C?=
=?UTF-8?q?=E5=96=84=E6=81=A2=E5=A4=8D=E9=AA=8C=E8=AF=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/tests/account-login.e2e.ts | 32 ++++++++++++--
.../account-login/avatar-retry.expected.md | 10 +++--
.../ui-settings-account/README.i18n.yaml | 4 +-
packages/client/ui-settings-account/README.md | 2 +-
.../client/ui-settings-account/README.zh.md | 2 +-
.../src/client/AccountAvatar.tsx | 10 +++--
.../src/client/AccountMenu.tsx | 6 ++-
.../src/client/AccountSection.tsx | 8 ++--
.../src/client/account-avatar.ts | 24 +++++++++--
.../ui-settings-account/src/client/index.ts | 7 ++--
.../tests/account-avatar.client.spec.ts | 31 ++++++++++++++
.../tests/account.client.spec.tsx | 42 +++++++++----------
.../tests/apply.client.spec.ts | 11 ++++-
.../desktop-onboarding-entry.client.spec.tsx | 2 +-
.../tests/desktop-onboarding.client.spec.tsx | 2 +-
.../tests/onboarding-state.client.spec.ts | 2 +-
.../tests/onboarding.client.spec.tsx | 3 +-
.../tests/quota-notice-hold.client.spec.tsx | 5 ++-
.../tests/quota-notice.client.spec.tsx | 2 +-
.../tests/sign-in-dialog.client.spec.tsx | 2 +-
20 files changed, 150 insertions(+), 57 deletions(-)
diff --git a/apps/web/tests/account-login.e2e.ts b/apps/web/tests/account-login.e2e.ts
index 98976dd179..a186f74c91 100644
--- a/apps/web/tests/account-login.e2e.ts
+++ b/apps/web/tests/account-login.e2e.ts
@@ -143,7 +143,18 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
await expect.poll(platform.avatarRequests).toBe(1)
await menu.locator('[data-avatar-status="failed"]').waitFor()
expect(await menu.locator('img').count()).toBe(0)
- const states = ['Initial load failed: sidebar=fallback']
+ const states: string[] = []
+ const capture = async (stage: string): Promise => {
+ const sidebar = menu.locator('[data-avatar-status]')
+ const account = page.getByRole('region', { name: 'Account', exact: true })
+ const mounted = await account.count() > 0
+ const settingsStatus = mounted
+ ? await account.locator('[data-avatar-status]').getAttribute('data-avatar-status') : 'unmounted'
+ states.push(`${stage}: sidebar=${await sidebar.getAttribute('data-avatar-status')}, settings=${settingsStatus}, `
+ + `sidebarImages=${await menu.locator('img').count()}, settingsImages=${mounted ? await account.locator('img').count() : 0}, `
+ + `requests=${platform.avatarRequests()}`)
+ }
+ await capture('Initial load')
await menu.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
@@ -152,7 +163,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
await account.locator('[data-avatar-status="failed"]').waitFor()
expect(await menu.locator('img').count()).toBe(0)
expect(await account.locator('img').count()).toBe(0)
- states.push('First Settings entry failed: sidebar=fallback, settings=fallback')
+ await capture('First Settings entry')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await menu.click()
@@ -164,7 +175,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0)).toBe(true)
expect(await image.getAttribute('src')).toBe(`${platform.origin}/avatar.svg`)
}
- states.push('Second Settings entry succeeded: sidebar=image, settings=image, same URL=true')
+ await capture('Second Settings entry')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await menu.click()
@@ -173,7 +184,20 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
expect(await menu.locator('img').count()).toBe(1)
expect(await account.locator('img').count()).toBe(1)
expect(platform.avatarRequests()).toBe(3)
- states.push('Third Settings entry reused success: sidebar=image, settings=image, requests=3')
+ await capture('Third Settings entry')
+ await menu.locator('img').dispatchEvent('error')
+ await menu.locator('[data-avatar-status="failed"]').waitFor()
+ await account.locator('[data-avatar-status="failed"]').waitFor()
+ expect(await menu.locator('img').count()).toBe(0)
+ expect(await account.locator('img').count()).toBe(0)
+ await capture('Rendered image failure')
+ await settings.getByRole('button', { name: 'Close', exact: true }).click()
+ await settings.waitFor({ state: 'detached' })
+ await menu.click()
+ await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
+ await expect.poll(() => menu.locator('img').count()).toBe(1)
+ await expect.poll(() => account.locator('img').count()).toBe(1)
+ await capture('Settings entry after rendered failure')
await compareOrRefreshGolden(AVATAR_EXPECTED, states.join('\n'), MODE)
expect(console.pageErrors).toEqual([])
await context.close()
diff --git a/apps/web/tests/expected/account-login/avatar-retry.expected.md b/apps/web/tests/expected/account-login/avatar-retry.expected.md
index b6c6554ff6..4106b7eb4b 100644
--- a/apps/web/tests/expected/account-login/avatar-retry.expected.md
+++ b/apps/web/tests/expected/account-login/avatar-retry.expected.md
@@ -1,4 +1,6 @@
-Initial load failed: sidebar=fallback
-First Settings entry failed: sidebar=fallback, settings=fallback
-Second Settings entry succeeded: sidebar=image, settings=image, same URL=true
-Third Settings entry reused success: sidebar=image, settings=image, requests=3
+Initial load: sidebar=failed, settings=unmounted, sidebarImages=0, settingsImages=0, requests=1
+First Settings entry: sidebar=failed, settings=failed, sidebarImages=0, settingsImages=0, requests=2
+Second Settings entry: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
+Third Settings entry: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
+Rendered image failure: sidebar=failed, settings=failed, sidebarImages=0, settingsImages=0, requests=3
+Settings entry after rendered failure: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml
index 6c4d75a1a0..fef67e392a 100644
--- a/packages/client/ui-settings-account/README.i18n.yaml
+++ b/packages/client/ui-settings-account/README.i18n.yaml
@@ -15,8 +15,8 @@
en: c26f81e8adf0128b
zh: e11ab371f51f66bc
/deepseek-ai-dsh-client-ui-settings-account/use-this-package:
- en: 52b65c9ada618678
- zh: c0479ad91ec38c5b
+ en: a0e4b5bbef33ca6d
+ zh: 697978822aeddf6e
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
en: c1c4db71fd09ca13
zh: a40c0a01e9951945
diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md
index 1e6f06f1fb..b392114313 100644
--- a/packages/client/ui-settings-account/README.md
+++ b/packages/client/ui-settings-account/README.md
@@ -30,7 +30,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please
Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs.
-The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. Each Settings entry retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph.
+The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph.
The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md
index f43130c236..76b39e0c00 100644
--- a/packages/client/ui-settings-account/README.zh.md
+++ b/packages/client/ui-settings-account/README.zh.md
@@ -30,7 +30,7 @@ kind: "package-reference"
Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。
-侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。每次进入设置都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。
+侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。
该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
diff --git a/packages/client/ui-settings-account/src/client/AccountAvatar.tsx b/packages/client/ui-settings-account/src/client/AccountAvatar.tsx
index b0ed23d25e..cd3d2f843b 100644
--- a/packages/client/ui-settings-account/src/client/AccountAvatar.tsx
+++ b/packages/client/ui-settings-account/src/client/AccountAvatar.tsx
@@ -5,11 +5,15 @@ import css from './AccountAvatar.module.css'
/**
* Render a decorative avatar next to the account identity.
- * @param props - shared browser load outcome; absent when no profile picture is available.
+ * @param props - shared browser load outcome and rendered-image failure reporter; absent data uses the default icon.
* @returns picture or the default account icon.
*/
-export function AccountAvatar({ avatar }: { avatar: AccountAvatarSnapshot | undefined }) {
+export function AccountAvatar({ avatar, avatarFailed }: {
+ avatar: AccountAvatarSnapshot | undefined
+ avatarFailed: (snapshot: AccountAvatarSnapshot) => void
+}) {
return avatar?.status === 'ready'
- ?
+ ?
{ avatarFailed(avatar) }} />
:
}
diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx
index adee9aafa9..cae2e1169b 100644
--- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx
+++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx
@@ -22,7 +22,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
export function AccountMenu({
subscribeSessionExpired, subscribeModelSignInRequired, wide, settingsShortcut, openSettings, openOnboarding, settingsOpen,
useAccount, useTheme, signOut, hasRunningAccountTasks, refreshAccount, bonusNoticeShown, bonusNoticeDismissed,
- contactUs, showLogin, start, cancel, t,
+ contactUs, showLogin, start, cancel, avatarFailed, t,
}: AccountMenuProps) {
const anchor = useRef(null)
// The launcher outlives the panel, so a false-to-true edge is one Settings entry:
@@ -65,7 +65,9 @@ export function AccountMenu({
anchor={}
diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx
index de03abfced..35db5a39dc 100644
--- a/packages/client/ui-settings-account/src/client/AccountSection.tsx
+++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx
@@ -23,7 +23,7 @@ export interface AccountSnapshot {
/** Sanitized profile and balance query outcomes, absent while loading. */
details: Partial | undefined
/** Shared picture load; absent until a profile supplies a picture or after sign-out. */
- avatar?: AccountAvatarSnapshot | undefined
+ avatar: AccountAvatarSnapshot | undefined
/** Whether the state stream failed. */
failed: boolean
/** Explicitly opened account dialog outside onboarding. */
@@ -40,6 +40,8 @@ export interface AccountSnapshot {
/** Host operations injected into the Cordis-free account component. */
export interface AccountSectionInjected {
+ /** @param snapshot - picture attempt whose rendered image failed; superseded attempts are ignored. */
+ avatarFailed: (snapshot: AccountAvatarSnapshot) => void
/** Subscribe to live credential-expiry notifications.
* @param listener - callback after the current credential is removed.
* @returns listener cleanup.
@@ -88,7 +90,7 @@ export interface AccountSectionInjected {
export type AccountSectionProps =
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace
/** @param props - localized actions, account subscription, and the shared Platform page channel. @returns account settings UI. */
-export function AccountSection({ t, useAccount, useTheme, start, cancel, openPlatformPage }: AccountSectionProps) {
+export function AccountSection({ t, useAccount, useTheme, start, cancel, openPlatformPage, avatarFailed }: AccountSectionProps) {
const { view: state, details, avatar, failed: streamFailed } = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
// The shared host owns the native view; this page holds only its own request,
@@ -163,7 +165,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla
-
+
{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')}
diff --git a/packages/client/ui-settings-account/src/client/account-avatar.ts b/packages/client/ui-settings-account/src/client/account-avatar.ts
index 38f232d638..6a1e5dd5db 100644
--- a/packages/client/ui-settings-account/src/client/account-avatar.ts
+++ b/packages/client/ui-settings-account/src/client/account-avatar.ts
@@ -25,8 +25,10 @@ export class AccountAvatarLoader {
if (this.disposed) return
const source = url || undefined
if (this.snapshot?.url === source) return
- this.detach()
- if (source === undefined) this.update(undefined)
+ if (source === undefined) {
+ this.detach()
+ this.update(undefined)
+ }
else this.load(source)
}
@@ -35,10 +37,26 @@ export class AccountAvatarLoader {
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.detach()
+ this.reset()
}
private load(url: string): void {
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts
index 8c73ea5374..40b03b871f 100644
--- a/packages/client/ui-settings-account/src/client/index.ts
+++ b/packages/client/ui-settings-account/src/client/index.ts
@@ -50,7 +50,7 @@ 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))
@@ -125,11 +125,11 @@ export function apply(ctx: Context): void {
for await (const frame of stream) {
revision++
refreshing = undefined
- avatar.setUrl(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()
@@ -172,6 +172,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
index dab97d02a0..2358a722ca 100644
--- a/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts
+++ b/packages/client/ui-settings-account/tests/account-avatar.client.spec.ts
@@ -81,3 +81,34 @@ it('does not start a load when publication releases its owner', () => {
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 bf7461f80c..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 }
@@ -402,41 +406,35 @@ it.each([
})
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())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
- expect(document.querySelectorAll('img')).toHaveLength(0)
- const snapshot = operations.hooks.account.getSnapshot()
- cleanup()
- snapshot.avatar = { status: 'ready', url: 'https://example.test/avatar.png' }
- operations.hooks.account.getSnapshot = () => snapshot
- render(<>
- {await accountMenu(operations, () => snapshot, true)}
- selector(snapshot)} useTheme={selector => selector(themeOf('light'))}
- close={() => {}} 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')
+ for (const image of images) fireEvent.error(image)
+ expect(operations.avatarFailed).toHaveBeenCalledTimes(2)
+ expect(operations.avatarFailed).toHaveBeenCalledWith(avatar)
})
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()
+ view.rerender()
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
- view.rerender()
+ view.rerender()
expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/avatar.png')
- view.rerender()
+ view.rerender()
expect(view.container.querySelector('img')).toBeNull()
})
@@ -561,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 accd78005a..25bb41770f 100644
--- a/packages/client/ui-settings-account/tests/apply.client.spec.ts
+++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts
@@ -234,10 +234,19 @@ it('publishes one avatar load and retries it through the shared Settings refresh
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[2]!.getAttribute('src')).toBeNull()
+ expect(images[3]!.getAttribute('src')).toBeNull()
}, 60_000)
it('coalesces refreshes, publishes independent failures, and rejects stale responses after sign-out or unload', async ({ start }) => {
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/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)