diff --git a/apps/web/tests/account-login.e2e.ts b/apps/web/tests/account-login.e2e.ts index e20a18c94b..a186f74c91 100644 --- a/apps/web/tests/account-login.e2e.ts +++ b/apps/web/tests/account-login.e2e.ts @@ -8,14 +8,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { saveFailureShot } from './support.ts' const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/account-login/cordis.patch.yml', import.meta.url)) const MODE = webSnapshotMode() const TOKEN = 'dsh_web_login_test' const CODE = 'web-login-code' +const AVATAR_EXPECTED = fileURLToPath(new URL('./expected/account-login/avatar-retry.expected.md', import.meta.url)) /** Loopback Platform double: authorization handoff, code exchange, and the signed-in profile reads. */ async function mockPlatform() { @@ -23,6 +24,7 @@ async function mockPlatform() { const pages: string[] = [] let state: string | undefined let redirectUri: string | undefined + let avatarRequests = 0 const server: Server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1') const reply = (payload: unknown): void => { @@ -64,10 +66,17 @@ async function mockPlatform() { }) return } + if (url.pathname === '/avatar.svg') { + avatarRequests++ + if (avatarRequests < 3) { res.writeHead(503, { 'cache-control': 'no-store' }).end(); return } + res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'max-age=3600' }) + .end('') + return + } if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return } if (url.pathname === '/auth-api/v0/users/current') { // `email` is required by the provider's profile schema even when the account has none. - reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: null } }) + reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: `${origin}/avatar.svg` } }) return } if (url.pathname === '/api/v0/users/get_user_summary') { @@ -82,7 +91,7 @@ async function mockPlatform() { if (address === null || typeof address === 'string') throw new Error('account login: missing mock listener') const origin = `http://127.0.0.1:${String(address.port)}` return { - origin, inits, pages, + origin, inits, pages, avatarRequests: () => avatarRequests, close: () => new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }), } } @@ -109,8 +118,9 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain if (root !== undefined) await rm(root, { recursive: true, force: true }) }) - it('signs in through a preopened themed tab and reports the Web login source', async () => { + it('signs in through a themed tab and recovers both avatars on a later Settings entry', async () => { const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: 'en-US', colorScheme: 'dark' }) + onTestFinished(() => context.close()) const page = await context.newPage() const console = watchConsole(page) onTestFailed(() => saveFailureShot(page, 'web-e2e-account-login')) @@ -130,6 +140,65 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain const [authorize, authorized] = platform.pages.map(href => new URL(href)) expect(authorize?.searchParams.get('theme')).toBe('dark') expect(authorized?.searchParams.get('login_source')).toBe('web') + 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: 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 }) + const account = settings.getByRole('region', { name: 'Account', exact: true }) + await expect.poll(platform.avatarRequests).toBe(2) + 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('First Settings entry') + 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) + for (const image of [menu.locator('img'), account.locator('img')]) { + await expect.poll(() => image.evaluate(element => + element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0)).toBe(true) + expect(await image.getAttribute('src')).toBe(`${platform.origin}/avatar.svg`) + } + await capture('Second Settings entry') + 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 account.waitFor() + expect(await menu.locator('img').count()).toBe(1) + expect(await account.locator('img').count()).toBe(1) + expect(platform.avatarRequests()).toBe(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() }, 90_000) diff --git a/apps/web/tests/expected/account-login/avatar-retry.expected.md b/apps/web/tests/expected/account-login/avatar-retry.expected.md new file mode 100644 index 0000000000..4106b7eb4b --- /dev/null +++ b/apps/web/tests/expected/account-login/avatar-retry.expected.md @@ -0,0 +1,6 @@ +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 644aee7ecd..049c12e594 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: 8fabc9c4fb214857 - zh: a5d4c085f4812e75 + 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 9a5abacae5..382667cb68 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, with the account icon as fallback when the URL is absent or the image fails to load. 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 128f491fb9..4d92e6fcf8 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 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 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 9c8b4cd6cc..cd3d2f843b 100644 --- a/packages/client/ui-settings-account/src/client/AccountAvatar.tsx +++ b/packages/client/ui-settings-account/src/client/AccountAvatar.tsx @@ -1,16 +1,19 @@ /** Shared account picture with an icon fallback for missing or unavailable images. */ -import { useState } from 'react' import { IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AccountAvatarSnapshot } from './account-avatar.ts' import css from './AccountAvatar.module.css' /** * Render a decorative avatar next to the account identity. - * @param props - profile picture URL; absent while signed out or loading. + * @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({ url }: { url?: string | null | undefined }) { - const [failedUrl, setFailedUrl] = useState() - return url && url !== failedUrl - ? { setFailedUrl(url) }} /> +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 49f3dc2ff6..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 4f66843294..35db5a39dc 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -8,6 +8,7 @@ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import type { PlatformPage, PlatformPages } from './platform-pages.ts' import { formatBalance } from './formatBalance.ts' import { AccountAvatar } from './AccountAvatar.tsx' +import type { AccountAvatarSnapshot } from './account-avatar.ts' import { authorizeUrlWithTheme } from './authorize-url.ts' import type { BonusNotice } from './bonus-notices.ts' import css from './AccountSection.module.css' @@ -21,6 +22,8 @@ export interface AccountSnapshot { view: AccountView | undefined /** 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 /** Whether the state stream failed. */ failed: boolean /** Explicitly opened account dialog outside onboarding. */ @@ -37,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. @@ -57,9 +62,9 @@ export interface AccountSectionInjected { theme: HostObservable } /** - * Read the balance, bonus wallets and unnotified bonus once. The Settings launcher + * Retry a failed avatar and read the profile, balance, bonus wallets and unnotified bonus once. The Settings launcher * calls it on each entry, and a page opener on return from top-up. - * @returns after both reads settle. + * @returns after the account queries settle; image loading completes independently. */ refreshAccount: () => Promise /** Open the external support questionnaire with the account, build and environment sampled by this click. */ @@ -85,8 +90,8 @@ 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) { - const { view: state, details, failed: streamFailed } = useAccount(value => value) +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, // and only while it is the latest owner. @@ -159,7 +164,9 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla
- + + +
{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)