diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml new file mode 100644 index 0000000000..daa3d1e95a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-18-account-quota-top-up.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md +/agent-note-account-quota-top-up-action: + en: c9f0d30bc28f815c + zh: 472505b18ee2e669 +/agent-note-account-quota-top-up-action/problem: + en: 4c0caf28d02faec2 + zh: a6cc3e175a2f9f4a +/agent-note-account-quota-top-up-action/decision: + en: d8046306e6488932 + zh: a0d29f3949c96955 +/agent-note-account-quota-top-up-action/alternatives-considered: + en: ec6731c63d9c42ee + zh: 535b67062ce0269b +/agent-note-account-quota-top-up-action/consequences: + en: 72febb3835a50018 + zh: 0125b875f35b829e diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md new file mode 100644 index 0000000000..f48b12be29 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md @@ -0,0 +1,35 @@ +# Agent Note: Account quota top-up action + +Status: implemented + +English | [中文](2026-09-18-account-quota-top-up.zh.md) + +## Problem + +A provider quota failure can mean an exhausted first-party account balance, an API-key account problem, or a custom gateway response. All require a useful explanation, but only the first case can be repaired by opening the signed-in user's embedded DeepSeek recharge page. Inferring that case from provider prose or current UI configuration would make historical failures unstable and could expose an irrelevant billing action. + +## Decision + +The `llm-deepseek-account` plugin classifies quota failures for its own route. The credential callback it hands the shared Messages transport releases the account token only for the configured inference origin, and the plugin rewrites the provider-neutral `QUOTA` failure to `ACCOUNT_QUOTA` for both HTTP 402 responses and in-band SSE errors. The API-key plugin registers `deepseek-official` independently and keeps `QUOTA`; neither route borrows the other's credentials, and a separately configured API key does not affect account-route quota actions. + +A quota failure reaches the transcript and the frame-wide notice as two separate facts. The durable terminal-error record keeps the request-time code and always renders the same provider-neutral quota copy, so the Chat transcript, history paging, and the Session log do not depend on current credentials. A newly appended `QUOTA` or `ACCOUNT_QUOTA` failure in a Session this Client has bound and materialized also publishes one frame-wide notice; the newest notice replaces the previous one, and history replacement, pagination, and Sessions the Client never opened publish none. + +Chat owns the notice host and registers it into the frame-wide `shell.overlay` slot, so a notice outlives the Chat panel that reported it. The host offers the one live notice, with `code`, `message`, `dismiss`, and `keepOpen`, to its `shell.quota-notice` chain child, and renders a generic warning Toast when no entry claims the code. The account package claims `ACCOUNT_QUOTA` only, showing a Cancel/Top up Modal; without the shared host entry, or with a snapshot that reports no stored credential or a failed account stream, the entry renders the neutral Toast itself, and with that host entry it waits for the first account snapshot instead of rendering a Toast that its own timer could discard. The plugin keeps one shared `PlatformPages` request channel and one `shell.overlay` host entry for the Desktop host's single native view: the Account settings page, the quota notice, and the Desktop onboarding recharge page request a page there instead of mounting their own container, the latest request retires the previous owner before the newer page shows, the shared host only closes the page it is given, and each owner decides what its own return re-reads: returning from a top-up page on the Account settings page or the quota notice re-reads profile, balance, and unnotified bonus, while the onboarding recharge return re-reads profile and balance only. Superseding a page, releasing a request, plugin unload, and leaving usage read nothing, and a release arriving after a newer request or a dismissal clears nothing. Back restores focus before any deferred Modal takes it. While a page requested through that shared channel is showing, the native view covers the document, so the entry renders neither Modal nor Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request rather than the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice by itself. The notice's own Top up is the retained case: it retains the notice with `keepOpen` and requests the `top-up` page; `keepOpen` returns an identity-safe release, and the entry releases both the hold and its page request when it unmounts, so a claim cannot outlive the entry that took it. Opening the top-up page therefore keeps the notice and the page mounted through later quota failures, which are dropped rather than queued while their persistent failure rows still render. Dismissal or sign-out releases the hold and the page request, so the next live quota event publishes again. Chat's subscription to a Session binding's live events ends with that binding or with the plugin, whichever goes first. + +Credentials and billing URLs never enter Chat props or the Session log. Dismissing the notice does not retry the request; signing out closes an open account Modal or embedded page. + +## Alternatives considered + +**Derive eligibility entirely in the renderer.** Current endpoint, key, and login state may differ from the facts of a historical failed request. It would also couple Chat to credentials and provider configuration rather than keeping only a stable code at the presentation boundary. + +**Parse the provider error message in the UI.** Provider prose is not a stable contract, cannot distinguish API-key and account-token billing, and would duplicate adapter error classification. + +**Offer recharge for every quota failure.** This would send API-key and custom-gateway users to unrelated first-party billing. The generic notice remains useful without claiming a repair path. + +**Infer billing from global credentials.** A configured API key may belong to another account and may be unused by the failed request. The explicit provider route owns credential selection and quota classification. + +## Consequences + +The Session log distinguishes account balance failure from generic quota exhaustion while the transcript renders both with the same neutral copy. One notice serves the whole app, so it stays visible after the reporting Chat panel closes, and a newer notice supersedes an older one instead of stacking. Because the account package claims the notice through a chain child, Web and signed-out users see the neutral Toast and no billing affordance, and the account package never imports Chat. A notice retained by Top up keeps that entry's page mounted through later quota failures at the cost of dropping those notices; the persistent failure rows remain their record. One shared request channel serves both the Account settings page and the notice, so a newer request replaces the visible page while identity-checked releases keep a superseded owner from closing it. + +Provider tests cover routing with simultaneous credentials, HTTP 402 classification, and in-band SSE errors; presentation tests cover append-only publication, replacement, dismissal, Toast fallback, and embedded top-up navigation. diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md new file mode 100644 index 0000000000..965e246ecb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 账号欠费充值操作 + +Status: implemented + +[English](2026-09-18-account-quota-top-up.md) | 中文 + +## Problem + +提供方的额度错误可能表示第一方账号余额耗尽、API Key 账号问题,或自定义网关响应。所有情况都需要清晰提示,但只有第一种情况适合打开已登录用户的内嵌 DeepSeek 充值页。若根据提供方文案或当前 UI 配置推断,会导致历史错误随当前状态变化,并可能展示无关的充值操作。 + +## Decision + +`llm-deepseek-account` 插件为本路由分类额度错误:它交给共享 Messages 传输的凭据回调只在配置的推理 origin 上释放账号 token,并把提供方中立的 `QUOTA` 错误改写为 `ACCOUNT_QUOTA`,同时覆盖 HTTP 402 响应与流内 SSE 错误。API Key 插件独立注册 `deepseek-official` 并保留 `QUOTA`;两条路由不会借用对方的凭据,单独配置的 API Key 也不影响账号路由的欠费操作。 + +额度错误分别到达消息记录和全应用提示这两条路径。持久化的终止错误记录保留请求时的错误码,并始终渲染同一段提供方中立的欠费文案,因此 Chat 消息记录、历史分页和 Session 日志都不依赖当前凭据。本 Client 已绑定并已物化的 Session 中新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 错误还会发布一条全应用提示;最新提示替换上一条,历史替换、分页以及从未打开的 Session 都不发布提示。 + +Chat 拥有提示宿主,并将其注册到全应用的 `shell.overlay` slot,因此提示的生命周期长于报告它的 Chat 面板。宿主把这条实时提示连同 `code`、`message`、`dismiss`、`keepOpen` 交给 `shell.quota-notice` chain 子 slot,没有 entry 认领该错误码时渲染通用警告 Toast。账号包只认领 `ACCOUNT_QUOTA`,显示带取消和去充值按钮的 Modal;缺少共享宿主条目,或快照报告未存储凭证、账号流失败且无快照时,由该条目自行渲染中立文案 Toast,而存在该宿主条目时它会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast。插件为 Desktop 宿主唯一的原生视图保留一个共享 `PlatformPages` 请求通道和一个 `shell.overlay` 宿主条目:Account 设置页、欠费提示与 Desktop 引导充值页都通过它申请页面,而不再各自挂载容器;最新请求会在新页面显示前退掉上一个持有者,而在更新请求或关闭之后到达的释放不会清除任何内容。共享宿主只关闭它接到的页面,每个持有者自行决定其返回时重读什么:Account 设置页或欠费提示的充值返回会重读资料、余额与未通知赠金,而引导充值返回只重读资料与余额。替换页面、释放请求、插件卸载与从用量页返回均不读取。返回时先恢复焦点,再由任何被延后的 Modal 接管。只要通过该共享请求通道请求的页面正在显示,该原生视图就会盖住本页,因此该条目既不渲染 Modal 也不渲染 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败而非账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它用 `keepOpen` 保持住提示,并申请 `top-up` 页面;`keepOpen` 返回身份安全的释放函数,且该条目在卸载时同时释放保持与页面请求,因此声明不会比取得它的条目活得更久。打开充值页因此会让该提示与其页面在后续欠费期间保持挂载,这些欠费被丢弃而不排队,其持久化失败行仍然渲染。关闭或退出登录会释放保持与页面请求,使下一条实时欠费重新发布。Chat 对某个 Session binding 实时事件的订阅在 binding 或插件先结束的一侧终止。 + +凭据和计费 URL 不会进入 Chat props 或 Session 日志。关闭提示不会重试请求;退出登录会关闭已打开的账号 Modal 或内嵌页面。 + +## Alternatives considered + +**完全在 renderer 中推导资格。** 当前端点、密钥和登录状态可能已经不同于历史失败请求的事实;这也会让 Chat 依赖凭据和提供方配置,而不是在展示边界只接收稳定错误码。 + +**在 UI 中解析提供方错误文案。** 提供方文案不是稳定契约,无法区分 API Key 和账号 token 的计费,同时会重复 adapter 的错误分类。 + +**为所有额度错误提供充值。** 这会把 API Key 和自定义网关用户带到无关的第一方计费页面。普通提示已经足够,不应虚构修复路径。 + +**根据全局凭据推断计费。** 已配置的 API Key 可能属于其他账号,也可能未被失败请求使用。明确的 provider 路由负责凭据选择和欠费分类。 + +## Consequences + +Session 日志区分账号余额失败与普通额度耗尽,消息记录对两者渲染同一段中立文案。一条提示服务整个应用,因此在报告它的 Chat 面板关闭后仍然可见,且新提示取代旧提示而不是叠加。由于账号包通过 chain 子 slot 认领提示,Web 与未登录用户只看到中立 Toast,没有计费入口,账号包也无须导入 Chat。被去充值保持住的提示会让该条目的页面在后续欠费期间继续挂载,代价是丢弃这些提示;持久化失败行仍是它们唯一的记录。一个共享请求通道同时服务 Account 设置页与提示,因此更新的请求会替换可见页面,而身份校验过的释放使被取代的持有者无法关闭它。 + +提供方测试覆盖双凭据并存时的路由、HTTP 402 分类和流内 SSE 错误;展示测试覆盖仅追加发布、替换、关闭、Toast 回退和内嵌充值页跳转。 diff --git a/apps/web/tests/expected/quota-notice/account_quota.expected.md b/apps/web/tests/expected/quota-notice/account_quota.expected.md new file mode 100644 index 0000000000..bc133b05e7 --- /dev/null +++ b/apps/web/tests/expected/quota-notice/account_quota.expected.md @@ -0,0 +1,3 @@ +- status: + - text: This turn failedRequest quota exhausted. + - code: ACCOUNT_QUOTA diff --git a/apps/web/tests/expected/quota-notice/quota.expected.md b/apps/web/tests/expected/quota-notice/quota.expected.md new file mode 100644 index 0000000000..dc42e415e8 --- /dev/null +++ b/apps/web/tests/expected/quota-notice/quota.expected.md @@ -0,0 +1,3 @@ +- status: + - text: This turn failedRequest quota exhausted. + - code: QUOTA diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index e3c91a03a4..9a5782bd96 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -240,6 +240,32 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) + it.skipIf(MODE === 'record').each(['QUOTA', 'ACCOUNT_QUOTA'])( + 'retains a %s failure after its Web notice expires and history reloads', async (code) => { + await launch(() => ({ + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'Provider quota exhausted', code } }], + })) + onTestFailed(() => saveFailureShot(page, `web-e2e-quota-${code}`)) + const { settled } = await sendPrompt() + await settled + const notice = page.getByRole('alert').filter({ hasText: 'Request quota exhausted.' }) + await notice.waitFor({ timeout: 10_000 }) + expect(await page.getByRole('dialog').count()).toBe(0) + const failure = page.getByRole('status').filter({ hasText: code }) + await failure.waitFor({ timeout: 10_000 }) + expect(await failure.textContent()).toContain('Request quota exhausted.') + await notice.waitFor({ state: 'hidden', timeout: 10_000 }) + expect(await failure.isVisible()).toBe(true) + const expected = fileURLToPath(new URL(`./expected/quota-notice/${code.toLowerCase()}.expected.md`, import.meta.url)) + await compareOrRefreshGolden(expected, await failure.ariaSnapshot(), MODE) + + await page.reload({ waitUntil: 'load' }) + await failure.waitFor({ timeout: 15_000 }) + expect(await notice.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000, + ) + it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => { await launch(() => ({ patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }], diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index bff31e47b3..13b47b7460 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -184,6 +184,7 @@ root │ └─ sidebar.right.tab.menu.item ├─ shell.leading └─ shell.overlay + └─ shell.quota-notice ``` The generated Client inspect catalog is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 7b4201ea17..42bf724545 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -184,6 +184,7 @@ root │ └─ sidebar.right.tab.menu.item ├─ shell.leading └─ shell.overlay + └─ shell.quota-notice ``` 生成的 Client inspect catalog 是每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index b7cba8646d..43fe0e4ac9 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -21,8 +21,8 @@ en: 5706ae03ceacf251 zh: 2e59d0815351349f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: - en: e2e86d9eec9130b5 - zh: 6ee831c996670630 + en: 2157fcd6b0d100b5 + zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: 29346bd0f9a5ff51 zh: 54dbed55f67aeecd diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 9949d6d50f..b3343fd9c3 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -47,7 +47,7 @@ When an Assistant attempt retires without a visible message, Chat hides its alre ## Command and failure rows -Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. A terminal Turn failure remains a separate red-dot notice; intermediate model retries do not create that notice, and an output-token limit uses the amber warning dot. +Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 35fe11dff6..b524f85580 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -47,7 +47,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, ## 指令与失败行 -通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要明确表达。终止轮次的错误仍是独立的红点提示;模型的中间重试不会创建该提示,达到输出 token 上限时使用琥珀色警告点。 +通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要表达。每个终止轮次都渲染自己的红点行;欠费失败的行使用中立的 `message.failure.quota` 文案,而不是提供方消息。新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 产生的瞬时提示来自本包注册在 `shell.overlay` 的全局条目,它不随 Chat 面板消失:该条目把唯一一条实时提示交给 `shell.quota-notice` 链,并在无人接管时回退到自己的警告 Toast,接管该错误码的条目会替换该回退。只有本 Client 已绑定并已物化的 Session 会发布提示;它从未打开的 Session 中的欠费不会发布。更新的提示会替换当前提示,除非认领条目用 `keepOpen()` 保持住当前提示:该调用返回一个由调用方持有、必须在卸载时调用的释放函数,只要存在保持,认领条目就继续挂载,后续提示被丢弃,释放后恢复后续提示但不重放已丢弃的提示。回退 Toast 自身没有延后策略:当 Desktop 账号的不透明原生 Platform 页盖住本页时,它仍会在下方运行,其显示计时器可能在不可见时走完并自行撤下提示,因此只留下持久化失败行。释放只移除自己的保持,因此它在关闭或更新的保持之后执行时不会影响更新的保持。关闭与退出登录会清空所有保持;被丢弃的提示不排队,其对应的持久化失败行仍会渲染。历史替换和分页不会发布提示。中间重试不会创建终止行;输出 token 上限使用琥珀色警告点。 ----- diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 69885d3ed4..8c86d6e918 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -21,8 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { - ChatNodeInjected, ChatScrollPosition, ChatViewInjected, - TurnTailOwnerProps, + ChatNodeInjected, ChatScrollPosition, ChatViewInjected, QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' @@ -31,6 +30,7 @@ import { ChatView } from './chat/ChatView.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { StatsPills } from './chat/StatsPills.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' +import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx' import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' @@ -63,10 +63,41 @@ export const inject = [ * @param ctx - Client root context. */ export function apply(ctx: Context): void { + const quotaNotice = createSnapshotStore(null) + let quotaNoticeSeq = 0 + // Each hold is its own token, so a release can only drop the hold it was + // issued for: one arriving after a dismissal or a later acquisition leaves + // that newer hold alone. + const quotaNoticeHolds = new Set() const chatSources = new WeakMap>() + const quotaSubscriptions = new Set<() => Promise>() + ctx.effect(() => async () => { + await Promise.all([...quotaSubscriptions].map(dispose => dispose())) + }, 'ui-chat: live quota notices') const chatSource = (binding: SessionBinding): ObservableSnapshot => { let source = chatSources.get(binding) if (source === undefined) { + // One live quota failure publishes one frame-wide notice; history + // replacement or paging never does, because an old failure scrolling + // back into view is not news. + const dispose = binding.ctx.effect(() => { + const stop = binding.eventSource.subscribe(() => { + const { change } = binding.eventSource.getSnapshot() + if (change.kind !== 'append') return + for (const { event } of change.entries) { + if (event.type !== 'turn/end' || event.data.reason.kind !== 'error') continue + const { code } = event.data.reason.error + if (quotaNoticeHolds.size > 0 || (code !== 'QUOTA' && code !== 'ACCOUNT_QUOTA')) continue + quotaNotice.set({ code, seq: ++quotaNoticeSeq }) + } + }) + return () => { + stop() + chatSources.delete(binding) + quotaSubscriptions.delete(dispose) + } + }, 'ui-chat: Provider binding quota notices') + quotaSubscriptions.add(dispose) const target = ctx.uiConversation.binding(binding).target('chat') source = { getSnapshot: () => target.getSnapshot() ?? EMPTY_CHAT_SNAPSHOT, @@ -226,6 +257,25 @@ export function apply(ctx: Context): void { return disposeView }) + // The quota notice host lives in the frame-wide layer so a notice outlives + // the Chat panel that reported it. Its chain child lets a package with a + // billing surface claim the one live notice without importing Chat. + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'chat.quota-notice', locale: NS, + children: { 'shell.quota-notice': { kind: 'chain', scope: 'root' } }, + inject: (): QuotaNoticeInjected => ({ + hooks: { notice: quotaNotice }, + dismissNotice: () => { quotaNoticeHolds.clear(); quotaNotice.set(null) }, + keepNoticeOpen: () => { + // Nothing live to retain: later failures must still publish. + if (quotaNotice.getSnapshot() === null) return () => {} + const token = Symbol('ui-chat quota notice hold') + quotaNoticeHolds.add(token) + return () => { quotaNoticeHolds.delete(token) } + }, + }), + }, QuotaNoticeHost)) + ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS, diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 3bff2ebfc5..d84a819f3c 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -54,6 +54,7 @@ function failureMessage( ): string { if (code === 'ACCOUNT_SIGNED_OUT') return t('message.failure.accountSignedOut') if (code === 'ACCOUNT_SIGN_IN_REQUIRED') return t('message.failure.accountSignInRequired') + if (code === 'QUOTA' || code === 'ACCOUNT_QUOTA') return t('message.failure.quota') return code === 'AUTH' ? t('message.failure.auth') : message } diff --git a/packages/client/ui-chat/src/client/chat/QuotaNoticeHost.tsx b/packages/client/ui-chat/src/client/chat/QuotaNoticeHost.tsx new file mode 100644 index 0000000000..51bac90a49 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/QuotaNoticeHost.tsx @@ -0,0 +1,27 @@ +/** + * The frame-wide `shell.overlay` host for quota notices. It holds the one live + * notice for the whole app, so leaving the Chat panel does not drop it. A + * `shell.quota-notice` entry may replace the generic Toast for codes it claims. + */ +import { Fragment } from 'react' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { QuotaNoticeHostProps, QuotaNoticeOwnerProps } from '../contract/slots.ts' + +/** + * @param props - the live notice, its dismissal, the chain outlet, and the locale seat. + * @returns the notice on display, or null while none is live. + */ +export function QuotaNoticeHost({ useNotice, dismissNotice, keepNoticeOpen, renderSlotChain, t }: QuotaNoticeHostProps) { + const notice = useNotice(current => current) + if (notice === null) return null + const owner: QuotaNoticeOwnerProps = { + code: notice.code, message: t('message.failure.quota'), dismiss: dismissNotice, keepOpen: keepNoticeOpen, + } + return ( + + {renderSlotChain('shell.quota-notice', owner, { + fallback: } onDone={dismissNotice} />, + })} + + ) +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 3ce381af1a..5c7a8e37cd 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -7,8 +7,8 @@ import type { MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, - SlotHookFactory, SnapshotSelectorHook, + HostObservable, InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, + PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' @@ -57,6 +57,56 @@ export interface AssistantActionOwnerProps { messageId: MessageId } +/** Stable quota failure codes retained in the Session log; both raise the frame-wide notice. */ +export type QuotaNoticeCode = 'QUOTA' | 'ACCOUNT_QUOTA' + +/** The notice on display; `seq` keys remounts so an unretained later notice restarts its transient display. */ +export interface QuotaNoticeState { + /** Stable failure code retained in the Session log. */ + code: QuotaNoticeCode + /** Per-publication sequence; the host keys its surface by it. */ + seq: number +} + +/** Owner currency of one quota notice offered to the frame-wide chain. */ +export interface QuotaNoticeOwnerProps { + /** Stable failure code retained in the Session log. */ + code: QuotaNoticeCode + /** Provider-neutral notice copy in the active locale. */ + message: string + /** Take the notice down. */ + dismiss: () => void + /** + * Prevent later quota failures from replacing this notice. Dismissal clears + * all holds; releasing the last hold resumes future notices without replay. + * Callers must release on unmount. + * @returns idempotent release that cannot clear another hold. + */ + keepOpen: () => () => void +} + +/** Quota notice host share: the notice on display and its dismissal. */ +export interface QuotaNoticeInjected { + hooks: { + /** The notice on display, or none. */ + notice: HostObservable + } + /** Take the notice down. */ + dismissNotice: () => void + /** + * Acquire a hold with the lifecycle defined by QuotaNoticeOwnerProps.keepOpen. + * @returns idempotent release, or a no-op when no notice is live. + */ + keepNoticeOpen: () => () => void +} + +/** Full props of the Chat-owned frame-wide quota notice host. */ +export type QuotaNoticeHostProps = + PropsRuntime<'shell.overlay'> + & PropsLocale<'chat'> + & PropsRenderSlots<'shell.quota-notice'> + & InjectFace + /** Optional prose file-mention provider consumed by Chat. */ export interface ChatFileMentions { /** @@ -269,5 +319,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * that entry. With no entries, the standard action row remains unchanged. */ 'conversation.chat.assistant-actions': { kind: 'list'; scope: 'session'; owner: AssistantActionOwnerProps } + /** + * Frame-wide quota notice chain. The Chat-owned host in `shell.overlay` + * offers the one live notice; the first entry whose selector claims its + * code takes over the surface, and the all-decline case renders the host's + * generic warning Toast. The host lives outside the Chat panel, so a notice + * survives switching or closing the panel that reported it. + */ + 'shell.quota-notice': { kind: 'chain'; scope: 'root'; owner: QuotaNoticeOwnerProps } } } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 0ef02f17c7..6b361f10f7 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -35,6 +35,7 @@ export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeHookContext, ChatNodeInjected, ChatNodeOwnerProps, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, + QuotaNoticeCode, QuotaNoticeHostProps, QuotaNoticeInjected, QuotaNoticeOwnerProps, QuotaNoticeState, TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UseDisclosure, UsePresentation, } from './contract/slots.ts' export type { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 2007ebbc18..53a4999b11 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -164,6 +164,7 @@ export const zh = { 'message.accountStopped': '任务已停止', 'message.failure.accountSignedOut': '任务已因退出 DeepSeek 登录而停止。', 'message.failure.accountSignInRequired': '请先登录 DeepSeek,并确认请求地址支持账号认证。', + 'message.failure.quota': '当前请求的额度已用尽', 'message.turnError': '本轮运行失败', 'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', @@ -356,6 +357,7 @@ export const en = { 'message.accountStopped': 'Task stopped', 'message.failure.accountSignedOut': 'Stopped because you signed out of DeepSeek.', 'message.failure.accountSignInRequired': 'Sign in to DeepSeek and ensure the request destination supports account authentication.', + 'message.failure.quota': 'Request quota exhausted.', 'message.turnError': 'This turn failed', 'message.maxTokens': 'Output token limit reached', 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index f73e0dbd08..d9fed8b140 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -23,6 +23,7 @@ import { import type { ChatNodeInjected, ChatSnapshot, TranscriptViewRowInjected, UseChatNodeTurnData, UseDisclosure, } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client' import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' @@ -69,6 +70,7 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) @@ -94,6 +96,29 @@ describe('Chat apply wiring', () => { expect(entry).not.toHaveProperty('presentationPolicyFor') }) + it('registers the frame-wide quota notice host and keeps the failure row injection-free', async () => { + const b = await bench() + try { + const host = b.runtime.slots.entries('shell.overlay').find(entry => entry.options.id === 'chat.quota-notice') + expect(host).toBeDefined() + expect(b.runtime.slots.spec('shell.quota-notice')).toMatchObject({ kind: 'chain', scope: 'root' }) + const inject: ((...args: never[]) => Record) | undefined = host?.inject + if (inject === undefined) throw new Error('ui-chat did not register the quota notice host') + const face = inject() + expect(face.hooks).toBeDefined() + const notice = (face.hooks as QuotaNoticeInjected['hooks']).notice + expect(notice.getSnapshot()).toBeNull() + ;(face.dismissNotice as QuotaNoticeInjected['dismissNotice'])() + expect(notice.getSnapshot()).toBeNull() + // The turn-error row carries neither a transient notice nor a chain child. + const row = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'turn-error')! + expect(row.inject).toBeUndefined() + expect(row.children).toBeUndefined() + } finally { + await b.runtime.dispose() + } + }) + it('contributes Chat View, node renderers, and stats', async () => { const b = await bench() const views = b.runtime.slots.entries('conversation.view') diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 78f45f1693..3bda5cad62 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2112,14 +2112,23 @@ describe('ChatView', () => { expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消') }) - it('renders terminal turn failures inline with their durable message and optional code', () => { - const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] }) + it('renders every terminal failure inline with neutral quota copy and no transient notice', () => { + const h = makeHarness({ nodes: [ + user(1, 'try'), turnError(2, 'AUTH'), turnError(3), turnError(4, 'QUOTA'), turnError(5, 'ACCOUNT_QUOTA'), + ] }) const view = render() const statuses = view.getAllByRole('status') expect(statuses.map(status => status.textContent)).toEqual([ '本轮运行失败API 密钥无效AUTH', '本轮运行失败plugin exploded', + '本轮运行失败当前请求的额度已用尽QUOTA', + '本轮运行失败当前请求的额度已用尽ACCOUNT_QUOTA', ]) + // The transient notice is the frame-wide host's job; the failure row keeps + // neither a recharge affordance nor a toast of its own. + expect(view.queryByRole('alert')).toBeNull() + expect(view.queryByRole('dialog')).toBeNull() + expect(view.queryByRole('button', { name: '去充值' })).toBeNull() }) it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => { diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx new file mode 100644 index 0000000000..2539c7357d --- /dev/null +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -0,0 +1,236 @@ +/** + * The frame-wide quota notice host: a live Chat failure publishes one notice + * that outlives the Chat panel, and history scrollback never does. + */ +// @vitest-environment jsdom +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { afterEach, describe, expect, it } from 'vitest' +import { act, screen } from '@testing-library/react' +import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { SessionLiveEventEntry, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' + +usePinnedBrowserLanguages('en') + +const SID = 'session-1' as SessionId +const disposers: Array<() => Promise> = [] +const references: SessionReference[] = [] + +afterEach(async () => { + // Held references keep their binding — and so its Chat source subscription — + // alive for the whole case. Release is idempotent, so a case may release too. + for (const reference of references.splice(0)) reference.release() + for (const dispose of disposers.splice(0).reverse()) await dispose() +}) + +/** One live `turn/end` failure entry for a session event window. */ +const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({ + type: 'event', event: { + type: 'turn/end', seq: seq as SessionSeq, time: seq, + data: { turn: 0, reason: { kind: 'error', error: { code, message: 'balance' } } }, + }, +}) + +/** + * Mount the real Conversation and Chat plugins over a runtime whose root frame + * renders the frame-wide `shell.overlay` seat. + * @returns the runtime, plugin handles, and the Chat standard-source descriptor. + */ +async function bench() { + const runtime = await SlotTestRuntime.create() + disposers.push(() => runtime.dispose()) + const chatSettings = stubConfigForm() + runtime.ctx.provide('configForms', { + developerTools: { enabled: createSnapshotStore(true) }, + get: (namespace: string) => namespace === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope, + } as never) + runtime.ctx.provide('layout', { openRightbar: () => {}, closeRightbar: () => {} } as never) + runtime.ctx.provide('sidebarRight', { openResource: () => {}, openTab: () => {} } as never) + runtime.ctx.provide('sidebarRightTabs', { + register: () => () => {}, get: () => ({}), subscribe: () => () => {}, + } as never) + runtime.ctx.provide('resources', { register: () => () => {} } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { beforeOpen(SID) }, + openSession: () => {}, + } as never) + runtime.remote.provideNamespaces({ session: { openWorkspacePath: async () => ({ ok: true, value: { opened: true } }) } }) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.declare({ + 'shell.overlay': { kind: 'list', scope: 'root' }, + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + }) + const conversation = await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + let descriptor: Parameters[0] | undefined + const provide = runtime.ctx.uiSession.provide.bind(runtime.ctx.uiSession) + runtime.ctx.uiSession.provide = (next) => { descriptor ??= next; return provide(next) } + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) + if (descriptor === undefined) throw new Error('ui-chat did not provide its standard source') + return { runtime, conversation, chat, sourceDescriptor: descriptor } +} + +/** The registered quota host's inject face, checked at the type-erased boundary. */ +function noticeFace(runtime: SlotTestRuntime): QuotaNoticeInjected { + const row = runtime.slots.entries('shell.overlay').find(entry => entry.options.id === 'chat.quota-notice') + const inject: ((...args: never[]) => Record) | undefined = row?.inject + if (inject === undefined) throw new Error('ui-chat did not register the quota notice host') + const face = inject() + if (face.hooks === undefined || typeof face.dismissNotice !== 'function' + || typeof face.keepNoticeOpen !== 'function') { + throw new Error('quota notice host inject face is incomplete') + } + return { + hooks: face.hooks as QuotaNoticeInjected['hooks'], + dismissNotice: face.dismissNotice as QuotaNoticeInjected['dismissNotice'], + keepNoticeOpen: face.keepNoticeOpen as QuotaNoticeInjected['keepNoticeOpen'], + } +} + +/** Retain one Session and subscribe the Chat source to its events. */ +async function attach(runtime: SlotTestRuntime, descriptor: Awaited>['sourceDescriptor']) { + await runtime.sessions.add({ id: SID }) + const reference = runtime.sessions.retain(SID) + references.push(reference) + descriptor.resolve(reference.binding) + return reference +} + +describe('frame-wide quota notices', () => { + it('publishes one notice per live append and republishes the latest', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const notice = noticeFace(b.runtime).hooks.notice + expect(notice.getSnapshot()).toBeNull() + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'ACCOUNT_QUOTA')) + expect(notice.getSnapshot()).toMatchObject({ code: 'ACCOUNT_QUOTA' }) + const first = notice.getSnapshot()?.seq + await b.runtime.sessions.appendEvent(SID, quotaEntry(1, 'QUOTA')) + expect(notice.getSnapshot()).toMatchObject({ code: 'QUOTA' }) + // An unretained later notice rekeys the surface so its display restarts. + expect(notice.getSnapshot()?.seq).not.toBe(first) + }) + + it('never publishes for history replacement or paging, or for other failures', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const notice = noticeFace(b.runtime).hooks.notice + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'QUOTA')) + const published = notice.getSnapshot() + expect(published).toMatchObject({ code: 'QUOTA' }) + await b.runtime.sessions.replaceEvents(SID, [quotaEntry(1, 'QUOTA')]) + expect(notice.getSnapshot()).toBe(published) + await b.runtime.sessions.prependEvents(SID, [quotaEntry(2, 'ACCOUNT_QUOTA')]) + expect(notice.getSnapshot()).toBe(published) + await b.runtime.sessions.appendEvent(SID, quotaEntry(3, 'AUTH')) + expect(notice.getSnapshot()).toBe(published) + await b.runtime.sessions.appendEvent(SID, { type: 'event', event: { + type: 'turn/end', seq: 4 as SessionSeq, time: 4, data: { turn: 0, reason: { kind: 'completed' } }, + } }) + expect(notice.getSnapshot()).toBe(published) + }) + + it('keeps the notice on screen after the viewed Session goes away', async () => { + const b = await bench() + const reference = await attach(b.runtime, b.sourceDescriptor) + // Mounting the host is what subscribes its store. The Toast it raises then + // portals to the body, so the query is document-wide. + b.runtime.renderSlot('shell.overlay', {}) + expect(screen.queryByRole('alert')).toBeNull() + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'ACCOUNT_QUOTA')) + // Wait for the committed notice rather than assuming the store write has + // already reached React. + expect((await screen.findByRole('alert')).textContent).toBe('Request quota exhausted.') + // Switching away releases the binding and stops new notices, but the live + // notice is host state, so it stays on screen. + reference.release() + expect(screen.getByRole('alert').textContent).toBe('Request quota exhausted.') + act(() => { noticeFace(b.runtime).dismissNotice() }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('retains the live notice until the takeover releases its hold', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const face = noticeFace(b.runtime) + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'ACCOUNT_QUOTA')) + const held = face.hooks.notice.getSnapshot() + expect(held).toMatchObject({ code: 'ACCOUNT_QUOTA' }) + const release = face.keepNoticeOpen() + // Holding means a takeover owns the surface: neither a later account + // failure nor a generic one may republish and remount it. + await b.runtime.sessions.appendEvent(SID, quotaEntry(1, 'ACCOUNT_QUOTA')) + expect(face.hooks.notice.getSnapshot()).toBe(held) + await b.runtime.sessions.appendEvent(SID, quotaEntry(2, 'QUOTA')) + expect(face.hooks.notice.getSnapshot()).toBe(held) + // Releasing resumes future failures without replaying the ones skipped. + release() + await b.runtime.sessions.appendEvent(SID, quotaEntry(3, 'QUOTA')) + expect(face.hooks.notice.getSnapshot()).toMatchObject({ code: 'QUOTA' }) + expect(face.hooks.notice.getSnapshot()?.seq).toBeGreaterThan(held!.seq) + }) + + it('suppresses nothing when a hold is acquired with no live notice', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const face = noticeFace(b.runtime) + expect(face.hooks.notice.getSnapshot()).toBeNull() + const release = face.keepNoticeOpen() + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'QUOTA')) + expect(face.hooks.notice.getSnapshot()).toMatchObject({ code: 'QUOTA' }) + // Its release clears nothing either. + release() + await b.runtime.sessions.appendEvent(SID, quotaEntry(1, 'ACCOUNT_QUOTA')) + expect(face.hooks.notice.getSnapshot()).toMatchObject({ code: 'ACCOUNT_QUOTA' }) + }) + + it('ignores a stale release that arrives after a dismissal or a newer hold', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const face = noticeFace(b.runtime) + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'ACCOUNT_QUOTA')) + const stale = face.keepNoticeOpen() + // Dismissal clears every hold, then the next failure publishes anew. + face.dismissNotice() + expect(face.hooks.notice.getSnapshot()).toBeNull() + await b.runtime.sessions.appendEvent(SID, quotaEntry(1, 'QUOTA')) + const current = face.hooks.notice.getSnapshot() + expect(current).toMatchObject({ code: 'QUOTA' }) + // The old release must not clear the notice a dismissal already cleared. + stale() + expect(face.hooks.notice.getSnapshot()).toBe(current) + // Nor may it clear a hold taken on the newer notice. + const active = face.keepNoticeOpen() + stale() + await b.runtime.sessions.appendEvent(SID, quotaEntry(2, 'ACCOUNT_QUOTA')) + expect(face.hooks.notice.getSnapshot()).toBe(current) + // Only its own release ends that newer hold. + active() + await b.runtime.sessions.appendEvent(SID, quotaEntry(3, 'QUOTA')) + expect(face.hooks.notice.getSnapshot()).toMatchObject({ code: 'QUOTA' }) + expect(face.hooks.notice.getSnapshot()?.seq).toBeGreaterThan(current!.seq) + }) + + it('detaches its subscription when the plugin unloads with a retained binding', async () => { + const b = await bench() + await attach(b.runtime, b.sourceDescriptor) + const notice = noticeFace(b.runtime).hooks.notice + await b.runtime.sessions.appendEvent(SID, quotaEntry(0, 'QUOTA')) + const published = notice.getSnapshot() + expect(published).toMatchObject({ code: 'QUOTA' }) + await b.chat.dispose() + expect(b.runtime.slots.entries('shell.overlay').some(entry => entry.options.id === 'chat.quota-notice')).toBe(false) + // A leaked callback would still write through the detached store. + await b.runtime.sessions.appendEvent(SID, quotaEntry(1, 'ACCOUNT_QUOTA')) + expect(notice.getSnapshot()).toBe(published) + }) +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a378ff63bc..5b3e9e7112 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -15,11 +15,11 @@ en: c26f81e8adf0128b zh: e11ab371f51f66bc /deepseek-ai-dsh-client-ui-settings-account/use-this-package: - en: 3025b95871518ad9 - zh: 3cb63da5c50f28d3 + en: 47f345e4d304b175 + zh: 63633ef62e324aad /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: - en: 51a3d4aba9a3828e - zh: 794884f7b616f436 + en: ff989846c5f3065c + zh: 46b284c494f1dd8c /deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation: en: 156bbaf3d44a3328 zh: 7acf7d2e8e8bf1e4 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 7adb3f08c9..cb8eb5db69 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,7 +34,9 @@ Account operations carry this UI's client identity to the Host: build version, a Balances use Platform Web currency formatting: two decimal places and digit grouping, positive amounts truncated to cents, positive sub-cent amounts shown as <0.01, and negative amounts rounded with a minimum displayed magnitude of 0.01. Raw Host balance strings remain unchanged. -Desktop usage and top-up actions open an isolated native Platform view below a 48px return bar. On Windows Electron the return bar and the native view start below the Desktop caption strip, so the Application and Edit menu text cannot cover the Back to DeepSeek Harness label. Back destroys the view and preserves the Account settings page; leaving the top-up view also reads the balance, bonus and unnotified bonus again, because a top-up may have changed them, while usage leaves them as read. Loading failures retain the return action and show a centered localized error with Retry. Retry reloads the same destination; renderer commands never receive the account token. +Desktop usage, top-up, and onboarding recharge actions open one isolated native Platform view below a 48px return bar. The Desktop host owns a single such view, and opening a page destroys the previous one, so the plugin registers one shared host entry in `shell.overlay` and the Account settings page, the account quota notice, and the Desktop onboarding recharge page all request a page through its `PlatformPages` request channel instead of mounting their own container. Each request retires the previous owner before the newer page shows, and its identity-safe release clears only that request, so a stale owner cannot close a page it no longer owns. On Windows Electron the return bar and the native view start below the Desktop caption strip, so the Application and Edit menu text cannot cover the Back to DeepSeek Harness label. Back destroys the view, restores focus before any deferred Modal takes it, and preserves the Account settings page. Leaving a top-up page waits for any read already running and then reads again, because a top-up may have changed what the account holds. The Account settings page and the account quota notice re-read profile, balance, and unnotified bonus; the onboarding recharge return re-reads profile and balance only, so it never triggers the unnotified-bonus read. Superseding a page, releasing a request, plugin unload, and leaving usage all read nothing. Loading failures retain the return action and show a centered localized error with Retry. Retry reloads the same destination; renderer commands never receive the account token. + +Chat offers each live quota failure from a bound Session to the frame-wide `shell.quota-notice` chain from its host in `shell.overlay`, so the notice outlives the Chat panel that reported it. The account entry claims `ACCOUNT_QUOTA`: while an account credential is stored and the shared host entry exists, the Modal offers Cancel and Top up. While a page requested through the shared request channel is showing, the native view covers this document, so the entry paints neither the Modal nor the Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request, not the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice on its own. The notice's own Top up is the retained case: it calls `keepOpen()` and requests the `top-up` page through the shared request channel, keeping that notice and its page mounted through later quota failures. With the shared host entry the entry waits for the first account snapshot instead of rendering a Toast that its own timer could discard; a snapshot that reports no stored credential, or a failed account stream with no snapshot, then renders the same neutral warning Toast itself, and without that host entry the entry renders that Toast immediately, instead of relying on the chat host fallback. The entry owns the hold and its page request and releases both when it unmounts, so a later quota failure publishes again without replaying the dropped ones; dismissal or sign-out clears the hold and the page request. Close, Cancel, a sign-out, or leaving the notice's own embedded page takes the notice down without retrying the request. The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation. @@ -53,7 +55,7 @@ The notice is painted as soon as the server offers it, including underneath the The Desktop preload marker enables the introduction after account credentials are stored. Progress belongs to the local Host settings document, not the Platform account: unfinished steps resume after restart, and completing or skipping the flow prevents another run on that installation. Browser clients do not mount it. API-key presence comes from the native welcome backend through the boolean-only Desktop preload bridge, using the same credential discovery as login. A signed-out installation with a configured model API key bypasses the pages and applies standard process, detailed usage, and enabled developer tools. -The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and refreshes account details in the background, whether loading or payment succeeded. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled developer tools. This flow creates no workspace or demonstration task. +The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled developer tools. This flow creates no workspace or demonstration task. ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 0f899e8184..04342ed0a7 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,7 +34,9 @@ kind: "package-reference" 余额沿用 Platform Web 的金额格式:两位小数和千分位分组,正金额截断至分,小于一分的正金额显示为 <0.01,负金额按舍入规则处理且显示绝对值至少为 0.01。Host 返回的原始余额字符串保持不变。 -Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Platform 视图。在 Windows Electron 中,返回栏与原生视图都从 Desktop 顶栏下方开始,Application 和 Edit 菜单文字不会盖住“返回 DeepSeek Harness”。返回操作销毁视图并保留 Account 设置页;从充值视图返回时还会重新读取余额、赠金与未通知赠金,因为充值可能改变这些值,从用量视图返回则保持原状。加载失败时保留返回操作,居中显示本地化错误提示和“重试”按钮。重试重新加载同一目标页面;渲染进程命令不接收账号 token。 +Desktop 的用量、充值与引导充值操作在 48px 返回栏下方打开同一个隔离的原生 Platform 视图。Desktop 宿主只有一个这样的视图,打开新页面会销毁上一个,因此插件在 `shell.overlay` 注册一个共享宿主条目,Account 设置页、账号欠费提示与 Desktop 引导充值页都通过其 `PlatformPages` 请求通道申请页面,而不再各自挂载容器。新请求会在新页面显示前退掉上一个持有者,其身份安全的释放只清除本次请求,因此过期的持有者无法关闭它已不再持有的页面。在 Windows Electron 中,返回栏与原生视图都从 Desktop 顶栏下方开始,Application 和 Edit 菜单文字不会盖住“返回 DeepSeek Harness”。返回操作销毁视图并保留 Account 设置页,且先恢复焦点,再由任何被延后的 Modal 接管。从充值页返回时会等待任何已在进行的读取结束后再读取,因为充值可能改变账号持有的内容。Account 设置页与账号欠费提示会重读资料、余额与未通知赠金;引导充值返回则只重读资料与余额,因此不会触发未通知赠金的读取。替换页面、释放请求、插件卸载与从用量页返回均不读取。加载失败时保留返回操作,居中显示本地化错误提示和“重试”按钮。重试重新加载同一目标页面;渲染进程命令不接收账号 token。 + +Chat 通过 `shell.overlay` 中的宿主把已绑定 Session 的实时欠费交给全局 `shell.quota-notice` 链,提示的生命周期因此长于上报它的 Chat 面板。账号条目认领 `ACCOUNT_QUOTA`:已存储账号凭证且存在共享宿主条目时,Modal 提供取消和去充值。只要通过该共享请求通道请求的页面正在显示,原生视图就会盖住本页,因此该条目既不绘制 Modal 也不绘制 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败,而不是账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它调用 `keepOpen()` 并通过共享请求通道申请 `top-up` 页面,使该提示与其页面在后续欠费期间保持挂载。存在共享宿主条目时,该条目会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast;快照报告未存储凭证,或账号流失败且无快照时,才由该条目自行渲染同一段中立警告 Toast,缺少该宿主条目时则立即渲染该 Toast,而不依赖 Chat 宿主回退。该条目持有保持与页面请求,并在自身卸载时释放二者,因此后续欠费会重新发布而不重放已丢弃的提示;关闭与退出登录也会清除保持和页面请求。关闭、取消、退出登录或离开该提示自己的内嵌页面都会撤下提示,且不会重试请求。 账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。 @@ -53,7 +55,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl Desktop preload 标记在账号凭证已存储后启用引导。进度属于本地 Host 设置文档,不属于 Platform 账号:重启后恢复未完成的步骤,完成或跳过后不再于当前安装中重复运行。浏览器客户端不挂载此流程。API Key 是否存在由原生欢迎后端通过仅返回布尔值的 Desktop preload 桥接提供,与登录共用凭证发现逻辑。未登录但已配置模型 API Key 的安装会绕过这些页面,应用标准过程、详细用量和开启开发者工具的配置。 -欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在后台刷新账号资料,不受页面加载或支付结果影响。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭开发者工具的默认值。此流程不创建工作区或示例任务。 +欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭开发者工具的默认值。此流程不创建工作区或示例任务。 ## 理解实现 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index c77eceb9d4..e2b92e409a 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -29,8 +29,10 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" @@ -48,13 +50,17 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", "@testing-library/react": "^16.1.0", "@types/big.js": "^6.2.2", "@types/react": "~18.3.1", @@ -63,11 +69,7 @@ "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "big.js": "^7.0.1", - "@deepseek-ai/dsh-settings": "workspace:*", - "@deepseek-ai/dsh-client-store": "workspace:*", - "@deepseek-ai/dsh-client-ui-layout": "workspace:*", - "@deepseek-ai/dsh-client-ui-chat": "workspace:*" + "big.js": "^7.0.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-account/src/client/AccountPlatformHost.tsx b/packages/client/ui-settings-account/src/client/AccountPlatformHost.tsx new file mode 100644 index 0000000000..c8e665a7c5 --- /dev/null +++ b/packages/client/ui-settings-account/src/client/AccountPlatformHost.tsx @@ -0,0 +1,33 @@ +/** The `shell.overlay` entry that renders the account feature's shared native Platform page. */ +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PlatformPageClaim } from './platform-pages.ts' +import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx' + +/** Injected share of the shared Platform host. */ +export interface AccountPlatformHostInjected { + platform: PlatformBridge + hooks: { page: HostObservable } + /** + * Viewer returned: drop the live request, notifying its owner with the reason + * the page stopped being live. Post-return reads belong to the requesting + * surface; this host closes the page and nothing else. + */ + closePage(this: void): void +} + +/** Composed props of the shared Platform host. */ +export type AccountPlatformHostProps = + PropsRuntime<'shell.overlay'> & PropsLocale<'settings.account'> & InjectFace + +/** + * @param props - the native commands, the live page request, and its dismissal. + * @returns the native Platform container, or null while no page is requested. + */ +export function AccountPlatformHost({ platform, usePage, closePage, t }: AccountPlatformHostProps) { + const claim = usePage(current => current) + if (claim === null) return null + return +} diff --git a/packages/client/ui-settings-account/src/client/AccountQuotaNotice.tsx b/packages/client/ui-settings-account/src/client/AccountQuotaNotice.tsx new file mode 100644 index 0000000000..01170a5456 --- /dev/null +++ b/packages/client/ui-settings-account/src/client/AccountQuotaNotice.tsx @@ -0,0 +1,81 @@ +/** Account-owned take-over of the frame-wide quota notice for account balances. */ +import { useEffect, useRef, useState } from 'react' +import { Button, Modal, Toast, IconWarningOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, HostObservable, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { QuotaNoticeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { AccountSnapshot } from './AccountSection.tsx' +import type { PlatformPageClaim, PlatformPages } from './platform-pages.ts' + +/** Injected share of the account route's frame-wide quota notice entry. */ +export interface AccountQuotaNoticeInjected { + /** Desktop-only: show one page in the account feature's shared native host; absent in ordinary browsers. */ + openPlatformPage?: PlatformPages['open'] + hooks: { + account: HostObservable + /** The page the shared host is showing, or null while it is closed. */ + platformPage: HostObservable + } +} + +type AccountQuotaNoticeProps = PropsRuntime<'shell.quota-notice'> + & { matched: QuotaNoticeOwnerProps } + & PropsLocale<'settings.account'> + & InjectFace + +/** + * @param props - the claimed notice, account state, and the shared Platform page channel. + * @returns the balance Modal, nothing while awaiting account state or a Platform page's return, or the fallback Toast. + */ +export function AccountQuotaNotice({ matched, openPlatformPage, useAccount, usePlatformPage, t }: AccountQuotaNoticeProps) { + const { dismiss, keepOpen, message } = matched + const accountStatus = useAccount(snapshot => snapshot.view?.status) + const accountFailed = useAccount(snapshot => snapshot.failed) + const signedIn = accountStatus === 'credential-stored' + const platformPage = usePlatformPage(current => current) + const [opened, setOpened] = useState(false) + const previousSignedIn = useRef(signedIn) + // Both claims are tied to this entry's lifetime: an unmount that the notice + // never asked for (a higher-priority chain entry, plugin reload, or a crash) + // must not strand the hold or leave the shared page mounted. + const releaseHold = useRef<(() => void) | undefined>(undefined) + const releasePage = useRef<(() => void) | undefined>(undefined) + const releaseClaims = () => { + releasePage.current?.() + releasePage.current = undefined + releaseHold.current?.() + releaseHold.current = undefined + } + useEffect(() => releaseClaims, []) + useEffect(() => { + // The notice outlives the panel that raised it, so only a real sign-out takes + // the balance surface down; the first, still unloaded snapshot must not. + if (previousSignedIn.current && !signedIn) { + releaseClaims() + dismiss() + } + previousSignedIn.current = signedIn + }, [dismiss, signedIn]) + // Await the first account snapshot without letting a Toast timer discard the + // notice before the native account action becomes available. A terminal stream + // failure ends that wait and leaves the generic warning available. + if (accountStatus === undefined && !accountFailed && openPlatformPage !== undefined) return null + // Signed out, unavailable, or without the native host, use generic notice copy. + if (!signedIn || openPlatformPage === undefined) { + return } onDone={dismiss} /> + } + // Defer the Modal until the native page closes; do not mount hidden focus or + // Escape handlers under the opaque view. + if (opened || platformPage !== null) return null + return + + + } /> +} diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index ee9d8f4a84..1404cfae2d 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -1,11 +1,11 @@ /** Account settings renders safe Host state and explicit login actions. */ import { Big } from 'big.js' -import { useEffect, useState, type MouseEvent } from 'react' +import { useEffect, useRef, useState, type MouseEvent } from 'react' import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx' +import type { PlatformPage, PlatformPages } from './platform-pages.ts' import { formatBalance } from './formatBalance.ts' import { AccountAvatar } from './AccountAvatar.tsx' import { authorizeUrlWithTheme } from './authorize-url.ts' @@ -43,8 +43,8 @@ export interface AccountSectionInjected { * @returns listener cleanup. */ subscribeModelSignInRequired?: (listener: () => void) => () => void - /** Desktop-only commands; absent in ordinary browsers. */ - platform?: PlatformBridge + /** Desktop-only: show one page in the account feature's shared native host; absent in ordinary browsers. */ + openPlatformPage?: PlatformPages['open'] /** Account stream owned by the Host and theme snapshots published by the renderer, observed through framework hooks. */ hooks: { @@ -54,7 +54,7 @@ export interface AccountSectionInjected { } /** * Read the balance, bonus wallets and unnotified bonus once. The Settings launcher - * calls it on each entry and the top-up view calls it when the user returns. + * calls it on each entry, and a page opener on return from top-up. * @returns after both reads settle. */ refreshAccount: () => Promise @@ -80,11 +80,13 @@ export interface AccountSectionInjected { /** Composed account section props. */ export type AccountSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace -/** @param props - localized actions and account subscription. @returns account settings UI. */ -export function AccountSection({ t, useAccount, useTheme, start, cancel, platform, refreshAccount }: AccountSectionProps) { +/** @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) const colorScheme = useTheme(snapshot => snapshot.active.colorScheme) - const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>() + // The shared host owns the native view; this page holds only its own request, + // and only while it is the latest owner. + const releasePage = useRef<(() => void) | undefined>(undefined) const [failed, setFailed] = useState(false) const [busy, setBusy] = useState(false) const profile = details?.profile?.status === 'ready' ? details.profile.value : undefined @@ -95,7 +97,16 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, platfor const active = attempt !== null && attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(attempt.phase) const signedIn = state?.status === 'credential-stored' - useEffect(() => { if (!signedIn) setPlatformPage(undefined) }, [signedIn]) + useEffect(() => () => { releasePage.current?.(); releasePage.current = undefined }, []) + useEffect(() => { + if (signedIn) return + releasePage.current?.() + releasePage.current = undefined + }, [signedIn]) + const showPage = (open: PlatformPages['open'], page: PlatformPage) => { + releasePage.current?.() + releasePage.current = open(page, () => { releasePage.current = undefined }) + } const run = async (action: () => Promise) => { setBusy(true) setFailed(false) @@ -106,7 +117,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, platfor * @returns nothing; on Desktop the embedded page replaces the pending navigation. */ const openUsage = (event: MouseEvent): void => { - if (platform !== undefined && signedIn) { event.preventDefault(); setPlatformPage('usage') } + if (openPlatformPage !== undefined && signedIn) { event.preventDefault(); showPage(openPlatformPage, 'usage') } } /** * The Platform entry the balance rows share with the Usage action: an embedded page on @@ -141,17 +152,6 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, platfor ) return (
- {platformPage !== undefined && platform !== undefined && signedIn && { - // Returning from top-up may have changed what the account holds, so the - // page leaves immediately and the reads settle behind it. Usage changes - // no account state, so leaving it stays as cheap as it was. - const page = platformPage - setPlatformPage(undefined) - if (page === 'top-up') void refreshAccount() - }} />}
@@ -207,7 +207,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, platfor {platformLink(t('usage'), css.linkButton)} { if (platform !== undefined && signedIn) { event.preventDefault(); setPlatformPage('top-up') } }}>{t('topUp')} + onClick={(event) => { if (openPlatformPage !== undefined && signedIn) { event.preventDefault(); showPage(openPlatformPage, 'top-up') } }}>{t('topUp')}
diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index a13744e4f6..5bfd37dfd2 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -1,10 +1,9 @@ -/** Coordinates onboarding navigation, transitions and the native recharge page. */ +/** Coordinates onboarding navigation, transitions and the shared native recharge page. */ import { useEffect, useRef, useState } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import { hasOnboardingCredit } from './onboarding-balance.ts' import { OnboardingSurface } from './OnboardingSurface.tsx' import type { DesktopOnboardingProps } from './onboarding-contract.ts' -import { PlatformOverlay } from './PlatformOverlay.tsx' import { OnboardingWelcomeStep } from './OnboardingWelcomeStep.tsx' import { OnboardingCreditStep } from './OnboardingCreditStep.tsx' import { OnboardingPurposeStep } from './OnboardingPurposeStep.tsx' @@ -13,14 +12,20 @@ import { OnboardingConfirmation, type OnboardingConfirmationKind } from './Onboa import backIcon from './assets/onboarding-back.svg' import css from './DesktopOnboarding.module.css' -/** @param props - durable choices, account state, localized copy and native recharge commands. @returns the active first-run page. */ +/** + * @param props - durable choices, account state, localized copy and the shared recharge page channel. + * @returns the active first-run page. + */ export function DesktopOnboarding({ - state, account, platform, refresh, update, complete, retry, t, locale, exiting = false, + state, account, openPlatformPage, update, complete, retry, t, locale, exiting = false, }: DesktopOnboardingProps) { const [dialog, setDialog] = useState(null) - const [topUp, setTopUp] = useState(false) const heading = useRef(null) const page = useRef(null) + // The one shared native host owns the recharge page; this flow holds only its + // own request, released when the flow unmounts. + const releaseRecharge = useRef<(() => void) | undefined>(undefined) + useEffect(() => () => { releaseRecharge.current?.(); releaseRecharge.current = undefined }, []) const progress = state.progress const targetStep = progress.step const [step, setStep] = useState(targetStep) @@ -44,12 +49,12 @@ export function DesktopOnboarding({ if (!state.visible || step === 'done') return null const go = (next: 'welcome' | 'credit' | 'purpose' | 'process') => { void update({ step: next }) } - const recharge = () => { setDialog(null); setTopUp(true) } - const returnFromRecharge = () => { - setTopUp(false) - void refresh().catch((_error: unknown) => { - // The account view owns refresh errors; returning keeps the credit page available. - }) + const canRecharge = openPlatformPage !== undefined + const recharge = () => { + setDialog(null) + releaseRecharge.current?.() + // Every control that reaches this is disabled without the callback. + releaseRecharge.current = openPlatformPage?.('top-up', () => { releaseRecharge.current = undefined }) } const later = () => { const balance = account.details?.balance @@ -65,7 +70,7 @@ export function DesktopOnboarding({
{step === 'welcome' && { go('credit') }} />} {step === 'credit' && { go('purpose') }} onRecharge={recharge} onLater={later} />} + funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { go('purpose') }} onRecharge={recharge} onLater={later} />} {step === 'purpose' && { void update({ purpose }) }} onContinue={purposeNext} />} {step === 'process' && }
- {dialog !== null && { setDialog(null) }} onContinue={() => { setDialog(null); go('purpose') }} onRecharge={recharge} onSkip={() => { void complete('skipped').then(() => { setDialog(null) }) }} />} - {topUp && platform !== undefined && } } diff --git a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx index 9375422fcb..6576a7266a 100644 --- a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx +++ b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx @@ -1,14 +1,15 @@ /** Desktop Platform viewport; the native child owns remote content and credentials. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Button, IconChevronLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PlatformPage } from './platform-pages.ts' import css from './PlatformOverlay.module.css' import { acquireOverlayInert } from './overlay-inert.ts' /** Non-secret commands supplied by the desktop application preload. */ export interface PlatformBridge { /** @param page - supported Platform destination. @param bounds - viewport rectangle. @returns after document load. */ - open(this: void, page: 'usage' | 'top-up', bounds: { x: number; y: number; width: number; height: number }): Promise + open(this: void, page: PlatformPage, bounds: { x: number; y: number; width: number; height: number }): Promise /** @param bounds - viewport rectangle. @returns after native bounds update. */ setBounds(this: void, bounds: { x: number; y: number; width: number; height: number }): Promise /** @returns after the native document is destroyed. */ @@ -18,7 +19,7 @@ export interface PlatformBridge { /** @param props - native commands, localized copy, and return action. @returns full-window Platform container. */ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failureLabel, retryLabel, onClose }: { bridge: PlatformBridge - page: 'usage' | 'top-up' + page: PlatformPage backLabel: string loadingLabel: string failureLabel: string @@ -30,12 +31,23 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure const viewport = useRef(null) const [attempt, setAttempt] = useState(0) const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading') - useEffect(() => { - const element = viewport.current as HTMLDivElement + // Restore inertness and focus in the layout cleanup, so a Modal mounted by the + // same commit records the restored focus before it takes its own. + useLayoutEffect(() => { const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null const background = Array.from(document.body.children).filter((element): element is HTMLElement => element instanceof HTMLElement && element !== layer.current).map(element => acquireOverlayInert(element)) back.current?.focus() + return () => { + for (const release of background) release() + if (previousFocus?.isConnected) previousFocus.focus() + } + }, []) + // A retry or a destination change replaces the surface holding focus with the + // loading state, so the return action takes it back on each attempt. + useLayoutEffect(() => { back.current?.focus() }, [page, attempt]) + useEffect(() => { + const element = viewport.current as HTMLDivElement let closed = false const bounds = () => { const rect = element.getBoundingClientRect() @@ -49,8 +61,6 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure return () => { closed = true observer.disconnect() - for (const release of background) release() - if (previousFocus?.isConnected) previousFocus.focus() void bridge.close().catch(() => { // Window teardown may remove the desktop IPC receiver before React unmounts. }) diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index d67f2143ea..9e4eef835c 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -5,6 +5,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { OnboardingChange } from './onboarding-contract.ts' @@ -12,11 +14,14 @@ import type { PlatformBridge } from './PlatformOverlay.tsx' import { ContactConfig, CONTACT_CONFIG_GLOBAL } from '../contact-config.ts' import { contactUrl } from './contact-url.ts' import { AccountOnboarding } from './AccountOnboarding.tsx' +import { AccountPlatformHost, type AccountPlatformHostInjected } from './AccountPlatformHost.tsx' import { AccountMenu } from './AccountMenu.tsx' +import { createPlatformPages, type PlatformPages } from './platform-pages.ts' import { AccountSection, type AccountSnapshot, type AccountSectionInjected } from './AccountSection.tsx' import { createBonusNoticeController } from './bonus-notices.ts' import { accountClientMetadata } from './client-metadata.ts' import { en, zh, type AccountKey } from './locales.ts' +import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from './AccountQuotaNotice.tsx' import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts' import { DesktopOnboardingController } from './onboarding-state.ts' import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx' @@ -128,14 +133,32 @@ export function apply(ctx: Context): void { } })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform + // One request channel for the single native Platform view. It always exists + // so every entry can observe whether a page is showing; only a native bridge + // lets an entry request one. + const platformPages = createPlatformPages() + ctx.effect(() => () => { platformPages.dispose() }, 'account: platform page request') + // 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()]) } + /** + * 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 + * never triggers account work. + * @param reRead - that surface's read after a returned top-up page. + * @returns the opener that surface injects. + */ + const platformPageOpener = (reRead: () => Promise): PlatformPages['open'] => + (page, onClose) => platformPages.open(page, (reason) => { + onClose(reason) + if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead) + }) const operations: AccountSectionInjected = { subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener), subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener), - ...nativePlatform === undefined ? {} : { platform: nativePlatform }, - // 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. - async refreshAccount() { await Promise.all([refresh(), notices.refresh()]) }, + ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, + refreshAccount, contactUs() { const url = contactUrl(config, { version: process.env.DSH_CLIENT_VERSION, @@ -208,8 +231,8 @@ export function apply(ctx: Context): void { name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account', inject: () => ({ hooks: { account: operations.hooks.account, onboarding: controller.state }, - ...nativePlatform === undefined ? {} : { platform: nativePlatform }, - refresh: () => refreshAfterReturn(refreshing, refresh), + // Onboarding's recharge return re-reads profile and balance only. + ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refresh) }, update: (change: OnboardingChange) => controller.update(change), complete: (reason: 'completed' | 'skipped') => controller.complete(reason), retry: () => controller.retry(), @@ -219,6 +242,23 @@ export function apply(ctx: Context): void { ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, }, AccountOnboarding)) + ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ + name: 'shell.quota-notice', locale: 'settings.account', + select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, + inject: (): AccountQuotaNoticeInjected => ({ + hooks: { account: operations.hooks.account, platformPage: platformPages }, + ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, + }), + }, AccountQuotaNotice)) + if (nativePlatform !== undefined) { + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account', + inject: (): AccountPlatformHostInjected => ({ + platform: nativePlatform, hooks: { page: platformPages }, + closePage: platformPages.close, + }), + }, AccountPlatformHost)) + } ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, }, AccountMenu)) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 9c7acdd5b1..92afb3a906 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -29,6 +29,9 @@ export const en = { profileUnavailable: 'Account details are not available yet.', balance: 'Topped-up balance', bonusBalance: 'Granted balance', balanceUnavailable: 'View on Platform', balanceSignedOut: 'Sign in to view', accountInfo: 'More account information', more: 'More', usage: 'View usage', topUp: 'Top up', + quotaTitle: 'No balance available', + quotaDescription: 'DeepSeek Harness cannot start a new task with this account when no balance is available. Would you like to top up? You can also top up later in Settings → Account.', + quotaTopUp: 'Top up', bonusNoticeTitle: 'Bonus credited', bonusEmpty: 'No bonus available', } as const @@ -62,6 +65,9 @@ export const zh: Record = { profileUnavailable: '账号资料暂不可用', balance: '充值余额', bonusBalance: '赠金余额', balanceUnavailable: '前往开放平台查看', balanceSignedOut: '登录后查看', accountInfo: '更多账号信息', more: '更多', usage: '查询用量', topUp: '充值', + quotaTitle: '当前无可用余额', + quotaDescription: '没有可用额度时,DeepSeek Harness 无法开始新的任务,是否前往充值?或者可以稍后前往 设置 → 账号与余额 进行充值。', + quotaTopUp: '去充值', bonusNoticeTitle: '赠金已到账', bonusEmpty: '暂无可用赠金', } diff --git a/packages/client/ui-settings-account/src/client/onboarding-contract.ts b/packages/client/ui-settings-account/src/client/onboarding-contract.ts index 387bec80a4..5c1a82081c 100644 --- a/packages/client/ui-settings-account/src/client/onboarding-contract.ts +++ b/packages/client/ui-settings-account/src/client/onboarding-contract.ts @@ -1,7 +1,7 @@ /** Pure view inputs for the desktop onboarding flow. */ import type { OnboardingProgress } from '../onboarding-settings.ts' import type { AccountSnapshot } from './AccountSection.tsx' -import type { PlatformBridge } from './PlatformOverlay.tsx' +import type { PlatformPages } from './platform-pages.ts' import type { AccountKey } from './locales.ts' /** Render state with pending choices previewed over durable Host progress. */ @@ -23,8 +23,8 @@ export interface DesktopOnboardingProps { exiting?: boolean state: DesktopOnboardingState account: AccountSnapshot - platform?: PlatformBridge - refresh(this: void): Promise + /** Desktop-only: show the shared native recharge page; absent in ordinary browsers. */ + openPlatformPage?: PlatformPages['open'] update(this: void, change: OnboardingChange): Promise complete(this: void, reason: 'completed' | 'skipped'): Promise retry(this: void): Promise diff --git a/packages/client/ui-settings-account/src/client/platform-pages.ts b/packages/client/ui-settings-account/src/client/platform-pages.ts new file mode 100644 index 0000000000..12084b8d7f --- /dev/null +++ b/packages/client/ui-settings-account/src/client/platform-pages.ts @@ -0,0 +1,95 @@ +/** + * One native Platform view for the whole account feature. The Account settings + * page, the frame-wide quota notice, and the desktop onboarding recharge step + * request a page here instead of mounting their own container, because the + * Desktop host owns a single WebContentsView and its `open` destroys whatever + * was showing before. + */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' + +/** Platform destinations the shared native host can show. */ +export type PlatformPage = 'usage' | 'top-up' + +/** The live request the shared host renders. */ +export interface PlatformPageClaim { + /** Requested destination. */ + page: PlatformPage +} + +/** Why one request stopped being the live page. */ +export type PlatformPageExit = + /** The viewer returned with the host's own back action. */ + | 'returned' + /** A newer request took the page over. */ + | 'superseded' + +/** Shared request channel between the account surfaces and their one host. */ +export interface PlatformPages { + /** @returns the live request, or null while the host is closed. */ + getSnapshot(): PlatformPageClaim | null + /** @param listener - called after every request change. @returns unsubscribes. */ + subscribe(listener: () => void): () => void + /** + * Show one Platform page. A newer request retires the previous owner before + * this call returns, and the returned release clears only this request, so a + * stale owner can never close a page it no longer owns. + * @param page - destination to show. + * @param onClose - runs at most once with the reason this request stopped being + * the live page. A release or plugin teardown ends it without calling back. + * @returns identity-safe release for this request. + */ + open(this: void, page: PlatformPage, onClose: (reason: PlatformPageExit) => void): () => void + /** Viewer returned: clear the live request and run its owner's `onClose` once. */ + close(this: void): void + /** + * Plugin teardown: drop the request without running its owner's `onClose`. + * Unmounting the host closes the native view, and the owner unmounts with it. + */ + dispose(this: void): void +} + +/** + * Create the plugin-scoped request channel shared by the account surfaces and + * the one host that renders their pages. The snapshot store supplies the + * observable and isolates a throwing subscriber from the rest. + * @returns the request channel. + */ +export function createPlatformPages(): PlatformPages { + const store = createSnapshotStore(null) + let owner: ((reason: PlatformPageExit) => void) | undefined + let token: object | null = null + return { + getSnapshot: () => store.getSnapshot(), + subscribe: listener => store.subscribe(listener), + open(page, onClose) { + const previous = owner + const own = {} + token = own + owner = onClose + store.set({ page }) + // The newer request is already installed, so the retired owner's release + // cannot clear it. + previous?.('superseded') + return () => { + if (token !== own) return + token = null + owner = undefined + store.set(null) + } + }, + close() { + if (store.getSnapshot() === null) return + const retiring = owner + token = null + owner = undefined + store.set(null) + retiring?.('returned') + }, + dispose() { + token = null + owner = undefined + if (store.getSnapshot() === null) return + store.set(null) + }, + } +} 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 31dab245d6..f73baf3e40 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen, act, within } from '@testing-library/react' +import { useMemo, useSyncExternalStore } from 'react' import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx' +import { createPlatformPages, type PlatformPages } from '../src/client/platform-pages.ts' +import { AccountPlatformHost } from '../src/client/AccountPlatformHost.tsx' import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx' import type { BonusNotice } from '../src/client/bonus-notices.ts' import type { AccountMenuProps } from '../src/client/AccountMenu.tsx' @@ -19,9 +22,9 @@ const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({ active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0, }) -function operationsOf(state: Omit, details?: Partial, platform?: PlatformBridge): AccountSectionInjected { +function operationsOf(state: Omit, details?: Partial, pages?: PlatformPages): AccountSectionInjected { return { - ...platform === undefined ? {} : { platform }, + ...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 }), @@ -38,16 +41,47 @@ function operationsOf(state: Omit, details?: Partial, copy: typeof en | typeof zh = en, details?: Partial, platform?: PlatformBridge) { - const operations = operationsOf(state, details, platform) + const pages = platform === undefined ? undefined : createPlatformPages() + const operations = operationsOf(state, details, pages) // AccountSection consumes no global hooks; the slot supplies them in the application. const globals = {} as GlobalStandardProps - render( selector(operations.hooks.account.getSnapshot())} - useTheme={selector => selector(operations.hooks.theme.getSnapshot())} - close={() => {}} t={key => key in copy ? copy[key as AccountKey] : key} />) + render(<> + selector(operations.hooks.account.getSnapshot())} + useTheme={selector => selector(operations.hooks.theme.getSnapshot())} + close={() => {}} t={key => key in copy ? copy[key as AccountKey] : key} /> + {/* The application renders this from `shell.overlay`; the settings page only requests a page. */} + {platform !== undefined && pages !== undefined + && } + ) return operations } +/** Test scaffold for the shell's host: real page subscription plus the opener's return read. */ +function SharedPlatformHost({ Globals, pages, platform, copy, refreshAccount }: { + Globals: GlobalStandardProps + pages: PlatformPages + platform: PlatformBridge + copy: typeof en | typeof zh + refreshAccount: () => Promise +}) { + const subscribe = useMemo(() => (listener: () => void) => pages.subscribe(listener), [pages]) + const usePage = useMemo( + () => (selector: (claim: ReturnType) => T): T => + selector(useSyncExternalStore(subscribe, () => pages.getSnapshot())), + [pages, subscribe], + ) + return { + // Scaffold-only emulation of the opener's return read; in the application + // the injected opener owns it and this host only closes the page. + const page = pages.getSnapshot()?.page + pages.close() + if (page === 'top-up') void refreshAccount() + }} t={key => key in copy ? copy[key as AccountKey] : key} /> +} + /** * @param operations - account face the launcher injects. * @param snapshot - reads the account state the launcher renders, so a rerender observes the latest value. @@ -237,7 +271,9 @@ it('opens usage inside Desktop and returns to the same Account settings', async vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) const platform: PlatformBridge = { open: vi.fn(async () => {}), setBounds: vi.fn(async () => {}), close: vi.fn(async () => {}) } mount({ status: 'credential-stored', attempt: null }, en, undefined, platform) - await act(async () => { fireEvent.click(screen.getByRole('link', { name: en.usage })) }) + const usage = screen.getByRole('link', { name: en.usage }) + act(() => { usage.focus() }) + await act(async () => { fireEvent.click(usage) }) expect(platform.open).toHaveBeenCalledWith('usage', { x: 0, y: 0, width: 0, height: 0 }) const back = screen.getByRole('button', { name: en.backToHarness }) await expect(`${back.parentElement!.parentElement!.textContent}\n`).toMatchFileSnapshot('./expected/platform-header-en.txt') @@ -245,6 +281,9 @@ it('opens usage inside Desktop and returns to the same Account settings', async expect(platform.close).toHaveBeenCalledOnce() expect(screen.queryByRole('button', { name: en.backToHarness })).toBeNull() expect(screen.getByRole('region', { name: en.nav })).toBeTruthy() + // The overlay's layout cleanup hands the page's return focus back to the link + // that opened it, with no dialog to take it instead. + expect(document.activeElement).toBe(usage) }) it('keeps a return action available when the native document fails to load', async () => { @@ -276,13 +315,20 @@ it.each([en, zh])('retries the failed Platform destination and removes the error .toMatchFileSnapshot(`./expected/platform-error-${copy === en ? 'en' : 'zh'}.txt`) await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.platformRetry })) }) expect(screen.getByText(copy.platformFailed)).toBeTruthy() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.platformRetry })) }) + const retry = screen.getByRole('button', { name: copy.platformRetry }) + act(() => { retry.focus() }) + await act(async () => { fireEvent.click(retry) }) expect(open.mock.calls.map(([page]) => page)).toEqual(['top-up', 'top-up', 'top-up']) expect(screen.queryByRole('button', { name: copy.platformRetry })).toBeNull() expect(screen.getByRole('status', { name: copy.loading })).toBeTruthy() + // Retrying removes the button that was focused, so the return action keeps + // keyboard focus instead of dropping it onto the document body. + const backAgain = screen.getByRole('button', { name: copy.backToHarness }) + expect(document.activeElement).toBe(backAgain) await act(async () => { loaded.resolve(undefined); await loaded.promise }) expect(screen.queryByText(copy.platformFailed)).toBeNull() expect(screen.queryByRole('status', { name: copy.loading })).toBeNull() + expect(document.activeElement).toBe(backAgain) await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.backToHarness })) }) expect(screen.queryByRole('dialog')).toBeNull() expect(platform.close).toHaveBeenCalledTimes(3) 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 18f4fe4a91..20a430fec6 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -7,6 +7,7 @@ import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-clie import type { AccountBonusBatch, AccountBonusOrderId, AccountDetails, AccountUserId, AccountView, SignInAttemptId, } from '@deepseek-ai/dsh-deepseek-account/types' +import type { QuotaNoticeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -16,6 +17,10 @@ import { DeveloperToolsSettingsSchema as SettingsConfig } from '../../ui-setting import type { DesktopOnboardingInjected } from '../src/client/DesktopOnboardingEntry.tsx' import type { AccountSectionInjected } from '../src/client/AccountSection.tsx' import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts' +import { AccountPlatformHost } from '../src/client/AccountPlatformHost.tsx' +import type { AccountPlatformHostInjected } from '../src/client/AccountPlatformHost.tsx' +import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx' +import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx' const it = createClientTest({ roster: webApp }) const SELF = '@deepseek-ai/dsh-client-ui-settings-account' @@ -39,6 +44,19 @@ function operations(c: TestClient): AccountSectionInjected { const injected: object = c.ctx.slots.entries('settings.launcher')[0]!.inject!() return injected as AccountSectionInjected } +/** The account take-over registered into the Chat-owned frame-wide quota notice chain. */ +function quotaNoticeEntry(c: TestClient) { + return c.ctx.slots.entries('shell.quota-notice').find(entry => entry.component === AccountQuotaNotice) +} +/** The account feature's one shared native Platform page host. */ +function platformHostEntry(c: TestClient) { + return c.ctx.slots.entries('shell.overlay').find(entry => entry.component === AccountPlatformHost) +} +/** One registered entry's injected share, read through the same object-narrowing the account operations use. */ +function injectedOf(entry: { inject?: (() => object) | undefined }): object { + const injected: object = entry.inject!() + return injected +} beforeEach(() => { vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') }) afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks() }) @@ -50,11 +68,26 @@ it('keeps account UI and account RPC inactive in a plain browser, including afte expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) + expect(quotaNoticeEntry(c)).toBeUndefined() + expect(platformHostEntry(c)).toBeUndefined() expect(mock.log.calls().filter(call => call.endpoint.startsWith('account/'))).toEqual([]) expect(mock.log.streams().filter(stream => stream.endpoint.startsWith('account/'))).toEqual([]) } }, 60_000) +it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + const entry = quotaNoticeEntry(c) + expect(entry).toBeDefined() + const select = entry!.select! as (owner: QuotaNoticeOwnerProps) => QuotaNoticeOwnerProps | null + const notice: QuotaNoticeOwnerProps = { + code: 'ACCOUNT_QUOTA', message: 'Request quota exhausted.', dismiss: vi.fn(), keepOpen: vi.fn(() => () => {}), + } + expect(select(notice)).toBe(notice) + expect(select({ ...notice, code: 'QUOTA' })).toBeNull() +}, 60_000) + it('shares account actions across seats, publishes dialog ownership, and opens contextual support', async ({ start }) => { vi.stubGlobal(CONTACT_CONFIG_GLOBAL, { contactFormUrl: 'https://example.test/form/', contactSource: 'harness' }) const open = vi.spyOn(window, 'open').mockReturnValue(null) @@ -167,13 +200,154 @@ it('uses the Desktop stream origin and exposes the native platform bridge', asyn vi.stubGlobal('dshPlatform', platform) await c.reload(SELF) const actions = operations(c) - expect(actions.platform).toBe(platform) + // The native commands reach the one shared host through the account page channel. + const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected + expect(host.platform).toBe(platform) + const notice = injectedOf(quotaNoticeEntry(c)!) as AccountQuotaNoticeInjected + // Both surfaces inject the page opener callback, not the channel object; the + // read policies each opener applies are asserted in their own cases. + expect(typeof actions.openPlatformPage).toBe('function') + expect(typeof notice.openPlatformPage).toBe('function') + expect('platformPages' in actions).toBe(false) + expect('platformPages' in notice).toBe(false) + expect(notice.hooks.platformPage.getSnapshot()).toBeNull() + + // One surface requests a page; the viewer returning through the host's own + // close action retires that owner exactly once and clears the observable. + const owner = vi.fn() + actions.openPlatformPage!('usage', owner) + expect(host.hooks.page.getSnapshot()).toEqual({ page: 'usage' }) + expect(notice.hooks.platformPage.getSnapshot()).toEqual({ page: 'usage' }) + host.closePage() + expect(host.hooks.page.getSnapshot()).toBeNull() + expect(owner).toHaveBeenCalledExactlyOnceWith('returned') mock.remote.account.startSignIn.mockResolvedValue(ok(view)) await actions.start() expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en' }), 'http://localhost:9876', 'desktop') }, 60_000) +it('re-reads the account when the shared host returns from top-up, and not from usage or an absent page', async ({ start, mock }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) + await c.reload(SELF) + const actions = operations(c) + const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected + c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) + c.mock.remote.account.getBalance.mockResolvedValue(ok(null)) + c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok({ accountId: 'account-user' as AccountUserId, bonuses: [] })) + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(stored) }) + // The account becoming active already ran one shared refresh; measure only the + // reads each return triggers from here. + await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalled() }) + await c.flush() + c.mock.remote.account.getUnnotifiedBonuses.mockClear() + c.mock.remote.account.getProfile.mockClear() + + // Usage changes nothing the account owns, so returning from it reads nothing. + actions.openPlatformPage!('usage', vi.fn()) + host.closePage() + await c.flush() + expect(mock.remote.account.getUnnotifiedBonuses).not.toHaveBeenCalled() + + // Closing with no page showing is not a return, so it reads nothing either. + host.closePage() + await c.flush() + expect(mock.remote.account.getUnnotifiedBonuses).not.toHaveBeenCalled() + + // Top up may have changed what the account holds, so its return runs the one + // shared refresh: the wallet reads and the unnotified-bonus read. + actions.openPlatformPage!('top-up', vi.fn()) + host.closePage() + await vi.waitFor(() => { expect(mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledOnce() }) + expect(mock.remote.account.getProfile).toHaveBeenCalledOnce() +}, 60_000) + +it('re-reads profile and balance but no bonus for the onboarding recharge return, and nothing on supersede or release', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) + await c.reload(SELF) + const actions = operations(c) + const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay') + .find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected + const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected + c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) + c.mock.remote.account.getBalance.mockResolvedValue(ok(null)) + c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok({ accountId: 'account-user' as AccountUserId, bonuses: [] })) + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(stored) }) + await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalled() }) + await c.flush() + const bonusesBefore = c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length + // The account becoming active already ran the shared refresh; measure only the + // reads each later event triggers. + c.mock.remote.account.getProfile.mockClear() + c.mock.remote.account.getBalance.mockClear() + + // A superseded page is not a return, so it reads nothing for either caller. + onboarding.openPlatformPage!('top-up', () => {}) + actions.openPlatformPage!('top-up', () => {}) + await c.flush() + expect(c.mock.remote.account.getProfile).not.toHaveBeenCalled() + + // Releasing a request is not a return either. + const release = actions.openPlatformPage!('top-up', () => {}) + release() + await c.flush() + expect(c.mock.remote.account.getProfile).not.toHaveBeenCalled() + + // The onboarding return re-reads profile and balance, and never the bonus: an + // unseen award must not be acknowledged behind the onboarding surface. + onboarding.openPlatformPage!('top-up', () => {}) + host.closePage() + await vi.waitFor(() => { expect(c.mock.remote.account.getProfile).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(c.mock.remote.account.getBalance).toHaveBeenCalledOnce() }) + await c.flush() + expect(c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length).toBe(bonusesBefore) +}, 60_000) + +it('waits for an in-flight pre-return read before the post-top-up read', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const c = await start() + vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() }) + await c.reload(SELF) + const actions = operations(c) + const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(stored) }) + await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalled() }) + await c.flush() + c.mock.remote.account.getProfile.mockClear() + c.mock.remote.account.getBalance.mockClear() + c.mock.remote.account.getUnnotifiedBonuses.mockClear() + const pending = Promise.withResolvers>>>() + c.mock.remote.account.getBalance.mockReturnValueOnce(pending.promise) + try { + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(c.mock.remote.account.getProfile).toHaveBeenCalledOnce() }) + // The baseline includes the frame's own notice read, which is not the return read. + const bonusesBeforeReturn = c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length + + actions.openPlatformPage!('top-up', vi.fn()) + host.closePage() + await c.flush() + // The return read has not started: the host is still waiting on the older one. + expect(c.mock.remote.account.getProfile).toHaveBeenCalledOnce() + expect(c.mock.remote.account.getBalance).toHaveBeenCalledOnce() + + pending.resolve(ok(null)) + await vi.waitFor(() => { expect(c.mock.remote.account.getProfile).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(c.mock.remote.account.getBalance).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length).toBeGreaterThan(bonusesBeforeReturn) }) + } finally { + // A failed assertion above must not strand the read the host is awaiting. + pending.resolve(ok(null)) + } +}, 60_000) + it('reads the unnotified bonus in the active locale and acknowledges only after the card renders', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() @@ -383,8 +557,10 @@ for (const native of [false, true]) it(`exposes desktop progress actions and dis const c = await start() const entry = c.ctx.slots.entries('shell.overlay').find(entry => entry.options.id === 'desktop-onboarding')! const injected = (entry.inject!() as object) as DesktopOnboardingInjected - expect(injected.platform).toBe(native ? platform : undefined) - await injected.refresh() + // Onboarding requests the shared native page through the one host; it holds no + // bridge of its own. + if (native) expect(injected.openPlatformPage).toBeDefined() + else expect(injected.openPlatformPage).toBeUndefined() expect(await injected.update({ step: 'credit' })).toBe(false) expect(await injected.complete('skipped')).toBe(false) expect(await injected.retry()).toBe(false) diff --git a/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts b/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts index fe27769899..160424c268 100644 --- a/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts +++ b/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts @@ -371,27 +371,40 @@ it('keeps the visible card when a read offers no bonus', async () => { }) it('keeps a pending acknowledgement for the signed-in lifecycle and drops it at sign-out', async () => { + // Advance the backoff clock explicitly so retry observation has no competing deadline. + vi.useFakeTimers() const { controller, read, acknowledge, latest } = setup() acknowledge.mockRejectedValue(new Error('offline')) read.mockResolvedValue(batch('account-a', 'order-1')) - controller.begin() - await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1' }) }) - controller.shown('order-1' as AccountBonusOrderId) - await vi.waitFor(() => { expect(acknowledge).toHaveBeenCalledWith('account-a', 'order-1') }) - await vi.waitFor(() => { expect(acknowledge.mock.calls.length).toBeGreaterThan(1) }) - // Signing out ends the retry with the lifecycle; the next sign-in starts clean. - controller.end() - const callsAtEnd = acknowledge.mock.calls.length const again = setup() - again.read.mockResolvedValue(batch('account-a', 'order-1')) - again.controller.begin() - await vi.waitFor(() => { expect(again.latest()).toMatchObject({ orderId: 'order-1' }) }) - // The next sign-in must not inherit the earlier pending acknowledgement. - expect(again.acknowledge).not.toHaveBeenCalled() - expect(acknowledge.mock.calls.length).toBe(callsAtEnd) - await new Promise((resolve) => { setTimeout(resolve, 5) }) - expect(acknowledge.mock.calls.length).toBe(callsAtEnd) - again.controller.end() + try { + controller.begin() + await vi.advanceTimersByTimeAsync(0) + expect(latest()).toMatchObject({ orderId: 'order-1' }) + controller.shown('order-1' as AccountBonusOrderId) + await vi.advanceTimersByTimeAsync(0) + expect(acknowledge).toHaveBeenCalledWith('account-a', 'order-1') + expect(acknowledge).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(TIMING.ackRetryDelayMs) + expect(acknowledge).toHaveBeenCalledTimes(2) + + // Signing out ends the retry with the lifecycle; the next sign-in starts clean. + controller.end() + const callsAtEnd = acknowledge.mock.calls.length + again.read.mockResolvedValue(batch('account-a', 'order-1')) + again.controller.begin() + await vi.advanceTimersByTimeAsync(0) + expect(again.latest()).toMatchObject({ orderId: 'order-1' }) + // The next sign-in must not inherit the earlier pending acknowledgement. + expect(again.acknowledge).not.toHaveBeenCalled() + expect(acknowledge.mock.calls.length).toBe(callsAtEnd) + // Past the next retry delay: the ended lifecycle scheduled nothing. + await vi.advanceTimersByTimeAsync(TIMING.ackRetryDelayMs * 3) + expect(acknowledge.mock.calls.length).toBe(callsAtEnd) + } finally { + controller.end() + again.controller.end() + } }) it('ignores an acknowledgement that settles after the lifecycle it belonged to', async () => { 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 31b679b775..97341518e9 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 @@ -54,7 +54,7 @@ function mount(initiallyDone = false, initiallyLoading = false) { const globals = {} as GlobalStandardProps const element = () => select(state)} useAccount={select => select(account)} - refresh={async () => {}} update={async () => true} complete={async () => true} retry={async () => true} + update={async () => true} complete={async () => true} retry={async () => true} t={key => key in zh ? zh[key as AccountKey] : key} /> const view = render(element()) act(() => { vi.runOnlyPendingTimers() }) 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 da75fb3fd7..80f0d38930 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 @@ -10,18 +10,22 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() }) function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', balance: 'zero' | 'positive' | 'bonus' | 'failed' | 'loading' = 'positive', status: DesktopOnboardingState['status'] = 'ready', copy: typeof zh = zh, creditFunded = false) { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) - const refresh = vi.fn(async () => {}) const complete = vi.fn(async () => true) const retry = vi.fn(async () => true) - const open = vi.fn(async () => {}) - const close = vi.fn(async () => {}) + // The shared host owns the native view; this flow only requests a page from it. + const release = vi.fn() + let returnFromPage: (() => void) | undefined + const openPlatformPage = vi.fn((_page: 'usage' | 'top-up', onClose: () => void) => { + returnFromPage = onClose + return release + }) const update = vi.fn(async (_change: Parameters[0]) => true) function App() { const [state, setState] = useState({ status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded, progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, }) - return copy[key]} refresh={refresh} complete={complete} retry={retry} + return copy[key]} complete={complete} retry={retry} update={async (change) => { setState(current => ({ ...current, status: 'saving' })) const saved = await update(change) @@ -30,10 +34,14 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal }} 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' }] } } }} - platform={{ open, close, setBounds: async () => {} }} /> + openPlatformPage={openPlatformPage} /> } render() - return { refresh, complete, open, close, update, retry } + return { + complete, openPlatformPage, release, update, retry, + /** The viewer returning through the shared host's Back action. */ + back: () => { returnFromPage?.() }, + } } it('keeps welcome mandatory and enters credit only through Start', async () => { @@ -66,14 +74,13 @@ it.each(['zero', 'positive', 'bonus', 'failed', 'loading'] as const)('always off expect(operations.update).toHaveBeenCalledWith({ step: 'purpose' }) }) -it('stays on credit after returning from native recharge even while balance refresh fails', async () => { +it('stays on credit after the viewer returns from the shared recharge page', async () => { const operations = mount('credit') - operations.refresh.mockRejectedValueOnce(new Error('balance unavailable')) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingTopUp })) }) - expect(operations.open).toHaveBeenCalledWith('top-up', expect.any(Object)) - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.backToHarness })) }) - expect(operations.close).toHaveBeenCalled() - expect(operations.refresh).toHaveBeenCalledOnce() + expect(operations.openPlatformPage).toHaveBeenCalledWith('top-up', expect.any(Function)) + // The shared host owns Back and the post-top-up read; this flow keeps the + // credit page available through the return. + await act(async () => { operations.back() }) expect(document.querySelector('[data-desktop-onboarding="credit"]')).toBeTruthy() }) @@ -156,8 +163,8 @@ it.each([zh, en])('offers Continue and recharge for a balance confirmed before e const operations = mount('credit', 'positive', 'ready', copy, true) expect(screen.queryByRole('button', { name: copy.onboardingLater })).toBeNull() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.onboardingFundedTopUp })) }) - expect(operations.open).toHaveBeenCalledWith('top-up', expect.any(Object)) - await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.backToHarness })) }) + expect(operations.openPlatformPage).toHaveBeenCalledWith('top-up', expect.any(Function)) + await act(async () => { operations.back() }) expect(operations.update).not.toHaveBeenCalled() expect(document.querySelector('[data-desktop-onboarding="credit"]')).toBeTruthy() }) @@ -166,7 +173,7 @@ it('continues directly from funded credit', async () => { const operations = mount('credit', 'positive', 'ready', zh, true) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) expect(operations.update).toHaveBeenCalledWith({ step: 'purpose' }) - expect(operations.open).not.toHaveBeenCalled() + expect(operations.openPlatformPage).not.toHaveBeenCalled() }) it.each([zh, en])('keeps purpose descriptions unchanged when selecting and deselecting cards', async (copy) => { @@ -180,11 +187,11 @@ it.each([zh, en])('keeps purpose descriptions unchanged when selecting and desel } }) -it('returns to credit after the native recharge page fails to open', async () => { +it('keeps the recharge request releasable so the flow can still leave credit', async () => { const operations = mount('credit') - operations.open.mockRejectedValueOnce(new Error('load failed')) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingTopUp })) }) - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.backToHarness })) }) + expect(operations.openPlatformPage).toHaveBeenCalledWith('top-up', expect.any(Function)) + await act(async () => { operations.back() }) expect(document.querySelector('[data-desktop-onboarding="credit"]')).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingBack })) }) expect(document.querySelector('[data-desktop-onboarding="welcome"]')).toBeTruthy() @@ -274,7 +281,7 @@ it('opens recharge directly from a credit warning', async () => { const h = mount('credit', 'zero') await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingLater })) }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingGoTopUp })) }) - expect(h.open).toHaveBeenCalledOnce() + expect(h.openPlatformPage).toHaveBeenCalledWith('top-up', expect.any(Function)) expect(screen.queryByRole('dialog', { name: zh.onboardingNoCreditTitle })).toBeNull() }) diff --git a/packages/client/ui-settings-account/tests/platform-pages.client.spec.ts b/packages/client/ui-settings-account/tests/platform-pages.client.spec.ts new file mode 100644 index 0000000000..0773121ee9 --- /dev/null +++ b/packages/client/ui-settings-account/tests/platform-pages.client.spec.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** The shared Platform page request channel: ownership, identity-safe release, and teardown. */ +import { expect, it, vi } from 'vitest' +import { createPlatformPages } from '../src/client/platform-pages.ts' + +it('ignores a close with no live request', () => { + const pages = createPlatformPages() + const listener = vi.fn() + pages.subscribe(listener) + pages.close() + expect(pages.getSnapshot()).toBeNull() + expect(listener).not.toHaveBeenCalled() +}) + +it('publishes each request and clears it on close, retiring the owner once', () => { + const pages = createPlatformPages() + const listener = vi.fn() + const unsubscribe = pages.subscribe(listener) + const first = vi.fn() + pages.open('usage', first) + expect(pages.getSnapshot()).toEqual({ page: 'usage' }) + pages.close() + expect(pages.getSnapshot()).toBeNull() + expect(first).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledTimes(2) + // A close after the request is gone neither republishes nor retires again. + pages.close() + expect(first).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledTimes(2) + unsubscribe() + pages.open('top-up', vi.fn()) + expect(listener).toHaveBeenCalledTimes(2) +}) + +it('retires the previous owner on a newer request and ignores its stale release', () => { + const pages = createPlatformPages() + const first = vi.fn() + const releaseFirst = pages.open('usage', first) + const second = vi.fn() + pages.open('top-up', second) + expect(first).toHaveBeenCalledOnce() + expect(pages.getSnapshot()).toEqual({ page: 'top-up' }) + // The retired owner's cleanup must not clear the page that replaced it. + releaseFirst() + expect(pages.getSnapshot()).toEqual({ page: 'top-up' }) + expect(second).not.toHaveBeenCalled() +}) + +it('clears the live request on release without retiring its owner', () => { + const pages = createPlatformPages() + const owner = vi.fn() + const release = pages.open('top-up', owner) + release() + expect(pages.getSnapshot()).toBeNull() + expect(owner).not.toHaveBeenCalled() + // A second release is a no-op. + release() + expect(pages.getSnapshot()).toBeNull() +}) + +it('drops the live request on teardown without retiring its owner', () => { + const pages = createPlatformPages() + const listener = vi.fn() + pages.subscribe(listener) + const owner = vi.fn() + pages.open('usage', owner) + pages.dispose() + expect(pages.getSnapshot()).toBeNull() + expect(owner).not.toHaveBeenCalled() + expect(listener).toHaveBeenCalledTimes(2) + // Disposing an already-empty channel stays silent. + pages.dispose() + expect(listener).toHaveBeenCalledTimes(2) +}) + +it('keeps publishing when one subscriber throws', () => { + const pages = createPlatformPages() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const healthy = vi.fn() + pages.subscribe(() => { throw new Error('bad subscriber') }) + pages.subscribe(healthy) + pages.open('usage', vi.fn()) + expect(healthy).toHaveBeenCalledOnce() + expect(pages.getSnapshot()).toEqual({ page: 'usage' }) + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() +}) 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 new file mode 100644 index 0000000000..f36e96a19b --- /dev/null +++ b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx @@ -0,0 +1,376 @@ +// @vitest-environment jsdom +/** + * The account takeover inside the shipped composition: the frame-wide notice + * chain, the account settings page, and the one shared native Platform host. + */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, screen } from '@testing-library/react' +import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { SessionLiveEventEntry, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' +import { AccountPlatformHost, type AccountPlatformHostInjected } from '../src/client/AccountPlatformHost.tsx' +import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx' +import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx' +import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from '../src/client/DesktopOnboardingEntry.tsx' +import type { DesktopOnboardingState } from '../src/client/onboarding-contract.ts' +import { createPlatformPages, type PlatformPages } from '../src/client/platform-pages.ts' +import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx' +import { en, zh } from '../src/client/locales.ts' + +usePinnedBrowserLanguages('en') + +const SID = 'session-1' as SessionId +const NOTICE = 'Request quota exhausted.' +const disposers: Array<() => Promise> = [] +const references: SessionReference[] = [] + +afterEach(async () => { + for (const reference of references.splice(0)) reference.release() + for (const dispose of disposers.splice(0).reverse()) await dispose() + vi.unstubAllGlobals() +}) + +/** One live `turn/end` failure entry for a session event window. */ +const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({ + type: 'event', event: { + type: 'turn/end', seq: seq as SessionSeq, time: seq, + data: { turn: 0, reason: { kind: 'error', error: { code, message: 'balance' } } }, + }, +}) + +const storedView: AccountView = { + status: 'credential-stored', attempt: null, + links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' }, +} +const theme: ThemeSnapshot = { + preference: 'light', fontSize: 14, active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0, +} + +/** The account settings commands, trimmed to what this composition renders. */ +function sectionOperations(account: AccountSnapshot, pages: PlatformPages): AccountSectionInjected { + return { + hooks: { + account: { getSnapshot: () => account, subscribe: () => () => {} }, + theme: { getSnapshot: () => theme, subscribe: () => () => {} }, + }, + openPlatformPage: pages.open, + refreshAccount: async () => {}, + bonusNoticeShown: () => {}, bonusNoticeDismissed: () => {}, + hasRunningAccountTasks: async () => false, + contactUs: () => {}, showLogin: () => {}, setOnboarding: () => {}, + start: async () => {}, cancel: async () => {}, signOut: async () => {}, + } +} + +/** + * Mount the real Chat quota host, this package's own shared Platform host, and + * the account settings page, registered as the account plugin registers them. + * @returns the runtime, the live account store, the page channel, and the bridge. + */ +async function bench() { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + const runtime = await SlotTestRuntime.create() + disposers.push(() => runtime.dispose()) + const forms = new Map['scope']>() + const formFor = (namespace: string) => { + let scope = forms.get(namespace) + if (scope === undefined) { + scope = stubConfigForm().scope + forms.set(namespace, scope) + } + return scope + } + runtime.ctx.provide('configForms', { + developerTools: { enabled: createSnapshotStore(true) }, + get: formFor, + } as never) + runtime.ctx.provide('layout', { openRightbar: () => {}, closeRightbar: () => {} } as never) + runtime.ctx.provide('sidebarRight', { openResource: () => {}, openTab: () => {} } as never) + runtime.ctx.provide('sidebarRightTabs', { + register: () => () => {}, get: () => ({}), subscribe: () => () => {}, + } as never) + runtime.ctx.provide('resources', { register: () => () => {} } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { beforeOpen(SID) }, + openSession: () => {}, + } as never) + runtime.remote.provideNamespaces({ session: { openWorkspacePath: async () => ({ ok: true, value: { opened: true } }) } }) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + runtime.ctx.effect(() => locale.register('settings.account', { en, zh }), 'spec: account dictionaries') + await runtime.declare({ + 'shell.overlay': { kind: 'list', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + }) + await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + let descriptor: Parameters[0] | undefined + const provide = runtime.ctx.uiSession.provide.bind(runtime.ctx.uiSession) + 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 account = { + getSnapshot: () => accountSnapshot.getSnapshot(), + subscribe: (listener: () => void) => accountSnapshot.subscribe(listener), + } + const platform = { + open: vi.fn(async () => {}), + setBounds: vi.fn(async () => {}), + close: vi.fn(async () => {}), + } + const pages = createPlatformPages() + disposers.push(async () => { pages.dispose() }) + // The onboarding recharge flow is a third caller of the same page channel. + const onboarding = createSnapshotStore({ + status: 'ready', visible: false, creditFunded: false, error: null, + progress: { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, + }) + let unregisterNotice: (() => void) | undefined + runtime.ctx.slots.inject('shell.overlay', () => runtime.ctx.slots.register({ + name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account', + inject: (): AccountPlatformHostInjected => ({ + platform, hooks: { page: pages }, closePage: () => { pages.close() }, + }), + }, AccountPlatformHost)) + runtime.ctx.slots.inject('shell.quota-notice', () => { + const dispose = runtime.ctx.slots.register({ + name: 'shell.quota-notice', locale: 'settings.account', + select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, + inject: (): AccountQuotaNoticeInjected => ({ hooks: { account, platformPage: pages }, openPlatformPage: pages.open }), + }, AccountQuotaNotice) + unregisterNotice = dispose + return dispose + }) + runtime.ctx.slots.inject('settings.section', () => runtime.ctx.slots.register({ + name: 'settings.section', id: 'account', locale: 'settings.account', + inject: () => sectionOperations(accountSnapshot.getSnapshot(), pages), + }, AccountSection)) + runtime.ctx.slots.inject('shell.overlay', () => runtime.ctx.slots.register({ + name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account', + inject: (): DesktopOnboardingInjected => ({ + hooks: { onboarding, account }, + openPlatformPage: pages.open, + update: async () => true, complete: async () => true, retry: async () => true, + }), + }, DesktopOnboardingEntry)) + // Retaining the Session subscribes the Chat source, so live failures publish. + await runtime.sessions.add({ id: SID }) + const reference = runtime.sessions.retain(SID) + references.push(reference) + descriptor.resolve(reference.binding) + runtime.renderSlot('shell.overlay', {}) + runtime.renderSlot('settings.section', { close: () => {} }) + return { + runtime, + pages, + platform, + account: accountSnapshot, + onboarding, + /** Drop the claiming account entry the way a higher-priority one would replace it. */ + removeNoticeEntry: () => { unregisterNotice?.(); unregisterNotice = undefined }, + } +} + +/** Publish one live quota failure and flush the resulting React work. */ +async function fail(runtime: SlotTestRuntime, seq: number, code: string): Promise { + await act(async () => { await runtime.sessions.appendEvent(SID, quotaEntry(seq, code)) }) +} + +/** The account settings card's top-up link, which requests the shared page. */ +function settingsTopUp() { + return screen.getAllByRole('link', { name: en.topUp }).at(-1)! +} +/** The account settings card's usage link, which requests the shared page. */ +function settingsUsage() { + return screen.getAllByRole('link', { name: en.usage }).at(-1)! +} + +describe('shared Platform page ownership', () => { + it('defers the notice while a settings page is open, then reveals it after Back', async () => { + const b = await bench() + await act(async () => { settingsUsage().click() }) + expect(b.platform.open).toHaveBeenLastCalledWith('usage', { x: 0, y: 0, width: 0, height: 0 }) + + // The opaque native view covers this document, so the arriving notice must + // not paint a Modal the user could neither see nor click. + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + expect(screen.queryByRole('dialog', { name: en.quotaTitle })).toBeNull() + expect(screen.queryByRole('button', { name: en.quotaTopUp })).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + // Deferring the notice neither reloads the page nor tears it down. + expect(b.platform.open).toHaveBeenCalledOnce() + expect(b.platform.close).not.toHaveBeenCalled() + + // Returning closes the page, and the still-live notice reveals itself. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + expect(b.platform.close).toHaveBeenCalledOnce() + expect(screen.getByRole('dialog', { name: en.quotaTitle })).toBeTruthy() + expect(b.pages.getSnapshot()).toBeNull() + + // Top up then takes the shared page over, once. + await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() }) + expect(b.platform.open.mock.calls.map(([page]) => page)).toEqual(['usage', 'top-up']) + expect(b.pages.getSnapshot()).toEqual({ page: 'top-up' }) + }) + + it('hands focus to the revealed notice Modal when Back closes the settings page', async () => { + const b = await bench() + const usage = settingsUsage() + // The user is on the Settings card when they open the page, so the link is + // the focus the page records for its own return. + act(() => { usage.focus() }) + expect(document.activeElement).toBe(usage) + await act(async () => { usage.click() }) + expect(screen.getByRole('button', { name: en.backToHarness })).toBeTruthy() + // The deferred notice paints nothing while the opaque page covers Settings. + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + expect(screen.queryByRole('dialog', { name: en.quotaTitle })).toBeNull() + + // Back mounts the Modal in the same commit that removes the page, so the + // page's focus restore must run before the Modal takes the focus it owns. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + const modal = screen.getByRole('dialog', { name: en.quotaTitle }) + expect(document.activeElement).not.toBe(usage) + expect(modal.contains(document.activeElement)).toBe(true) + expect(b.pages.getSnapshot()).toBeNull() + }) + + it('defers the notice behind the onboarding recharge page and focuses it on return', async () => { + const b = await bench() + // Onboarding is the third caller of the shared channel, here on the credit + // step that offers recharge. + await act(async () => { + b.onboarding.set({ + status: 'ready', visible: true, creditFunded: false, error: null, + progress: { version: 1, step: 'credit', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }, + }) + }) + const recharge = screen.getByRole('button', { name: en.onboardingTopUp }) + act(() => { recharge.focus() }) + await act(async () => { fireEvent.click(recharge) }) + // One native owner: onboarding asked the shared channel, so the bridge opens + // exactly one page and no second overlay exists. + expect(b.platform.open.mock.calls.map(([page]) => page)).toEqual(['top-up']) + expect(b.pages.getSnapshot()).toEqual({ page: 'top-up' }) + + // A background account failure arriving behind that page is deferred rather + // than painted under the opaque native view. + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + expect(screen.queryByRole('dialog', { name: en.quotaTitle })).toBeNull() + + // Back closes the shared page and hands focus to the revealed Modal. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + expect(b.pages.getSnapshot()).toBeNull() + const modal = screen.getByRole('dialog', { name: en.quotaTitle }) + expect(document.activeElement).not.toBe(recharge) + expect(modal.contains(document.activeElement)).toBe(true) + }) + + it('leaves an already-showing settings top-up page intact behind the deferred notice', async () => { + const b = await bench() + await act(async () => { settingsTopUp().click() }) + expect(b.platform.open).toHaveBeenLastCalledWith('top-up', { x: 0, y: 0, width: 0, height: 0 }) + + // The settings card remains mounted behind the shared overlay, and the + // notice adds neither a hidden Modal nor a second native page. + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + expect(screen.queryByRole('dialog', { name: en.quotaTitle })).toBeNull() + expect(settingsTopUp()).toBeTruthy() + expect(b.platform.open).toHaveBeenCalledOnce() + expect(b.platform.close).not.toHaveBeenCalled() + + // Returning reveals the notice; Top up reopens the destination it names. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + expect(screen.getByRole('dialog', { name: en.quotaTitle })).toBeTruthy() + await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() }) + expect(b.platform.open.mock.calls.map(([page]) => page)).toEqual(['top-up', 'top-up']) + }) + + it('replaces a deferred account notice with the newest failure behind a settings page', async () => { + const b = await bench() + await act(async () => { settingsUsage().click() }) + expect(b.platform.open).toHaveBeenLastCalledWith('usage', { x: 0, y: 0, width: 0, height: 0 }) + + // No hold exists yet, so the deferred account notice is not retained: a + // later generic quota failure replaces it as the live notice. + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + await fail(b.runtime, 1, 'QUOTA') + expect(screen.queryByRole('dialog', { name: en.quotaTitle })).toBeNull() + // The page itself is untouched by either failure. + expect(b.platform.open).toHaveBeenCalledOnce() + expect(b.platform.close).not.toHaveBeenCalled() + + // Back never revives the replaced account Modal; the newest failure owns the + // notice, and here that is the host fallback Toast. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + expect(b.pages.getSnapshot()).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('alert').textContent).toBe(NOTICE) + }) + + it('keeps the open page across later account and generic quota failures', async () => { + const b = await bench() + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + expect(screen.getByRole('dialog', { name: en.quotaTitle })).toBeTruthy() + await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() }) + expect(b.platform.open).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: en.backToHarness })).toBeTruthy() + + // The hold drops later notifications before they publish, so the chain + // neither swaps to the fallback nor remounts this entry, and the page stays. + await fail(b.runtime, 1, 'QUOTA') + await fail(b.runtime, 2, 'ACCOUNT_QUOTA') + expect(b.platform.close).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: en.backToHarness })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + + // Leaving the page dismisses, so a later failure surfaces again. + await act(async () => { screen.getByRole('button', { name: en.backToHarness }).click() }) + expect(b.platform.close).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() + await fail(b.runtime, 3, 'QUOTA') + expect(screen.getByRole('alert').textContent).toBe(NOTICE) + }) + + it('releases the hold with its entry, so a later failure publishes again', async () => { + const b = await bench() + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() }) + expect(b.platform.open).toHaveBeenCalledOnce() + + // A claiming entry can disappear without dismissing: a higher-priority + // entry, a plugin reload, or a chain crash. Its hold must go with it, or + // every later failure would publish nothing. + await act(async () => { b.removeNoticeEntry() }) + expect(b.pages.getSnapshot()).toBeNull() + expect(b.platform.close).toHaveBeenCalledOnce() + await fail(b.runtime, 1, 'ACCOUNT_QUOTA') + expect(screen.getByRole('alert').textContent).toBe(NOTICE) + }) + + it('resumes fresh notices after signing out releases the hold', async () => { + const b = await bench() + await fail(b.runtime, 0, 'ACCOUNT_QUOTA') + 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 }) }) + expect(b.platform.close).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: en.backToHarness })).toBeNull() + + // The hold ended with the dismissal, so the next failure publishes anew. + await fail(b.runtime, 1, 'ACCOUNT_QUOTA') + expect(screen.getByRole('alert').textContent).toBe(NOTICE) + }) +}) 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 new file mode 100644 index 0000000000..6e0033f806 --- /dev/null +++ b/packages/client/ui-settings-account/tests/quota-notice.client.spec.tsx @@ -0,0 +1,221 @@ +// @vitest-environment jsdom +/** Account take-over of the frame-wide quota notice, and its generic fallback. */ +import { act, cleanup, render, fireEvent, screen } from '@testing-library/react' +import { afterEach, expect, it, vi, type Mock } from 'vitest' +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { QuotaNoticeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import type { AccountSnapshot } from '../src/client/AccountSection.tsx' +import type {} from '../src/client/index.ts' +import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx' +import { createPlatformPages, type PlatformPages } from '../src/client/platform-pages.ts' +import { en, zh, type AccountKey } from '../src/client/locales.ts' + +/** Subscriptions opened by one mounted entry, released with the entry. */ +const mountedSubscriptions: Array<() => void> = [] + +afterEach(() => { + for (const unsubscribe of mountedSubscriptions.splice(0)) unsubscribe() + cleanup() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +/** Notice copy supplied by the Chat-owned host; this entry never restates it. */ +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 } +} + +/** The live page request channel with its acquisition order retained for ordering assertions. */ +interface PageSpies { + readonly pages: PlatformPages + readonly open: Mock +} + +function pageSpies(): PageSpies { + const pages = createPlatformPages() + const open = vi.spyOn(pages, 'open') + return { pages, open } +} + +/** Recorded call position of one spy, matching the ui-workspace row-action pattern. */ +function callOrder(fn: { mock: { invocationCallOrder: readonly number[] } }, nth = 0): number { + return fn.mock.invocationCallOrder[nth] ?? Number.POSITIVE_INFINITY +} + +/** + * Mount the entry the way the quota chain does: claiming owner, live account + * snapshot, the narrow page callback, and the page observable its host serves. + * Page changes re-render through the channel's own subscription, standing in + * for the renderer's framework hook. + */ +function mount(options: { account?: 'unloaded' | 'signed-out' | 'credential-stored'; pages?: PlatformPages; copy?: typeof en | typeof zh } = {}) { + const copy = options.copy ?? en + const pages = options.pages + const initial = options.account ?? 'credential-stored' + let account = snapshot(initial === 'unloaded' ? undefined : initial) + let live = true + let rerender = () => {} + const dismiss = vi.fn(() => { live = false; rerender() }) + const releaseHold = vi.fn() + const keepOpen = vi.fn(() => releaseHold) + // The chain renderer passes the owner share and the selector's match, which + // here is that same owner, so the entry receives both. + const owner: QuotaNoticeOwnerProps = { code: 'ACCOUNT_QUOTA', message: noticeMessage, dismiss, keepOpen } + const element = () => live + ? selector(account)} + usePlatformPage={selector => selector(pages?.getSnapshot() ?? null)} + t={key => copy[key as AccountKey]} /> + : null + const rendered = render(element()) + rerender = () => { rendered.rerender(element()) } + // Page changes re-render through the channel's subscription, the way the + // renderer's framework hook does; release it so no callback reaches an + // unmounted instance. + if (pages !== undefined) mountedSubscriptions.push(pages.subscribe(rerender)) + return { + dismiss, + keepOpen, + releaseHold, + rendered, + setStatus: (status: 'signed-out' | 'credential-stored') => { account = snapshot(status); rerender() }, + failStream: () => { account = { ...account, failed: true }; rerender() }, + } +} + +it.each([en, zh])('requests the shared top-up page for the claimed account notice', async (copy) => { + const spies = pageSpies() + const { dismiss, keepOpen, releaseHold } = mount({ pages: spies.pages, copy }) + expect(screen.getByRole('dialog', { name: copy.quotaTitle }).textContent).toContain(copy.quotaDescription) + expect(keepOpen).not.toHaveBeenCalled() + expect(spies.pages.getSnapshot()).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.quotaTopUp })) }) + // The notice must be retained before its page mounts: a later failure that + // replaced the notice would otherwise remount this entry and drop the page. + expect(keepOpen).toHaveBeenCalledOnce() + expect(callOrder(keepOpen)).toBeLessThan(callOrder(spies.open)) + expect(spies.open).toHaveBeenCalledWith('top-up', expect.any(Function)) + expect(spies.pages.getSnapshot()).toEqual({ page: 'top-up' }) + expect(dismiss).not.toHaveBeenCalled() + expect(releaseHold).not.toHaveBeenCalled() + // The page hides this entry's surface; the shared host owns the container. + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() +}) + +it.each([en, zh])('dismisses the account notice without requesting a page', (copy) => { + const spies = pageSpies() + const { dismiss } = mount({ pages: spies.pages, copy }) + fireEvent.click(screen.getByRole('button', { name: copy.cancel })) + expect(dismiss).toHaveBeenCalledOnce() + expect(spies.open).not.toHaveBeenCalled() + expect(spies.pages.getSnapshot()).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each([en, zh])('dismisses the account notice through its close button', (copy) => { + const spies = pageSpies() + const { dismiss } = mount({ pages: spies.pages, copy }) + fireEvent.click(screen.getByRole('button', { name: copy.close })) + expect(dismiss).toHaveBeenCalledOnce() + expect(spies.pages.getSnapshot()).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each([en, zh])('defers the balance Modal while any Platform page is showing', async (copy) => { + const spies = pageSpies() + const { dismiss } = mount({ pages: spies.pages, copy }) + expect(screen.getByRole('dialog', { name: copy.quotaTitle })).toBeTruthy() + // Another surface already shows a native page: its opaque view covers this + // document, so the notice must not paint a Modal the user cannot see or click. + await act(async () => { spies.pages.open('usage', () => {}) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button', { name: copy.quotaTopUp })).toBeNull() + expect(dismiss).not.toHaveBeenCalled() + // Returning from the page reveals the still-live notice. + await act(async () => { spies.pages.close() }) + expect(screen.getByRole('dialog', { name: copy.quotaTitle })).toBeTruthy() + expect(dismiss).not.toHaveBeenCalled() +}) + +it.each(['credential-stored', 'signed-out'] as const)('waits beyond the Toast lifetime for the first account snapshot: %s', (status) => { + vi.useFakeTimers() + const spies = pageSpies() + const { dismiss, setStatus } = mount({ account: 'unloaded', pages: spies.pages }) + act(() => { vi.advanceTimersByTime(10_000) }) + expect(dismiss).not.toHaveBeenCalled() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + act(() => { setStatus(status) }) + if (status === 'credential-stored') { + expect(screen.getByRole('dialog', { name: en.quotaTitle })).toBeTruthy() + expect(dismiss).not.toHaveBeenCalled() + } else { + expect(screen.getByRole('alert').textContent).toBe(noticeMessage) + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + } +}) + +it('shows a timed Toast when the account stream fails before its first snapshot', () => { + vi.useFakeTimers() + const { dismiss, failStream, keepOpen } = mount({ account: 'unloaded', pages: createPlatformPages() }) + act(() => { vi.advanceTimersByTime(10_000) }) + expect(screen.queryByRole('alert')).toBeNull() + expect(dismiss).not.toHaveBeenCalled() + act(() => { failStream() }) + expect(screen.getByRole('alert').textContent).toBe(noticeMessage) + expect(screen.queryByRole('dialog')).toBeNull() + expect(keepOpen).not.toHaveBeenCalled() + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + expect(screen.queryByRole('alert')).toBeNull() +}) + +it.each(['signed-out', 'unloaded'] as const)('uses a timed Toast without the shared page host: %s', (account) => { + vi.useFakeTimers() + const { dismiss } = mount({ account }) + expect(screen.getByRole('alert').textContent).toBe(noticeMessage) + expect(screen.queryByRole('button')).toBeNull() + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + expect(screen.queryByRole('alert')).toBeNull() +}) + +it.each([en, zh])('takes the balance Modal down when the account signs out', (copy) => { + const spies = pageSpies() + const { dismiss, setStatus } = mount({ pages: spies.pages, copy }) + expect(screen.getByRole('dialog', { name: copy.quotaTitle })).toBeTruthy() + act(() => { setStatus('signed-out') }) + expect(dismiss).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(spies.open).not.toHaveBeenCalled() +}) + +it('releases the shared page and its hold when the account signs out mid-page', async () => { + const spies = pageSpies() + const { dismiss, releaseHold, setStatus } = mount({ pages: spies.pages }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.quotaTopUp })) }) + expect(spies.pages.getSnapshot()).toEqual({ page: 'top-up' }) + act(() => { setStatus('signed-out') }) + expect(dismiss).toHaveBeenCalledOnce() + expect(spies.pages.getSnapshot()).toBeNull() + expect(releaseHold).toHaveBeenCalledOnce() +}) + +it('releases the hold when a higher-priority entry unmounts this one', async () => { + const spies = pageSpies() + const { releaseHold, rendered } = mount({ pages: spies.pages }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.quotaTopUp })) }) + rendered.unmount() + // An unmount the notice never asked for must not strand the hold, or later + // failures would publish nothing. + expect(releaseHold).toHaveBeenCalledOnce() + expect(spies.pages.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-settings-account/tsconfig.json b/packages/client/ui-settings-account/tsconfig.json index bd1b0cf122..a2c29cb37a 100644 --- a/packages/client/ui-settings-account/tsconfig.json +++ b/packages/client/ui-settings-account/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../locale/tsconfig.client.json" }, + { + "path": "../store" + }, { "path": "../ui-settings/tsconfig.client.json" }, @@ -45,16 +48,13 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../settings/settings" - }, - { - "path": "../store" + "path": "../ui-chat/tsconfig.client.json" }, { "path": "../ui-layout" }, { - "path": "../ui-chat/tsconfig.client.json" + "path": "../../settings/settings" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index eea14690af..1f0ffab903 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:271', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:321', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:259', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:309', }, { key: 'conversation.chat.node', @@ -294,7 +294,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:240', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:290', }, { key: 'conversation.chat.turnTail', @@ -356,7 +356,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:265', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:315', }, { key: 'conversation.composer', @@ -1169,7 +1169,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:253', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:303', }, { key: 'conversation.plan-review.actions', @@ -2800,8 +2800,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ + 'client-ui-chat QuotaNoticeHost id \'chat.quota-notice\'', 'client-ui-schedule ScheduleDeleteToast id \'schedule.delete-toast\'', 'client-ui-settings-account DesktopOnboardingEntry id \'desktop-onboarding\'', + 'client-ui-settings-account AccountPlatformHost id \'account.platform-page\'', 'client-ui-shortcuts ShortcutReference id \'shortcuts\'', 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', @@ -2811,6 +2813,46 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-layout/src/client/index.ts:98', }, + { + key: 'shell.quota-notice', + kind: 'chain', + scope: 'root', + summary: 'Frame-wide quota notice chain.', + doc: 'Frame-wide quota notice chain. The Chat-owned host in `shell.overlay`\noffers the one live notice; the first entry whose selector claims its\ncode takes over the surface, and the all-decline case renders the host\'s\ngeneric warning Toast. The host lives outside the Chat panel, so a notice\nsurvives switching or closing the panel that reported it.', + registerOptions: [ + { + name: 'select', + requirement: 'required', + type: '(owner) => unknown | null', + doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.', + }, + ], + ownerProps: [ + '/** Owner currency of one quota notice offered to the frame-wide chain. */\nexport interface QuotaNoticeOwnerProps {\n /** Stable failure code retained in the Session log. */\n code: QuotaNoticeCode\n /** Provider-neutral notice copy in the active locale. */\n message: string\n /** Take the notice down. */\n dismiss: () => void\n /**\n * Prevent later quota failures from replacing this notice. Dismissal clears\n * all holds; releasing the last hold resumes future notices without replay.\n * Callers must release on unmount.\n * @returns idempotent release that cannot clear another hold.\n */\n keepOpen: () => () => void\n}', + ], + ownerPropsReferences: [ + 'QuotaNoticeCode', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'shell.overlay\' (client-ui-chat), so it exists while that entry is mounted', + occupants: [ + 'client-ui-settings-account AccountQuotaNotice', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:329', + }, { key: 'sidebar', kind: 'single', diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml index fd1e9e0ed0..41b725fb21 100644 --- a/packages/llm/llm-deepseek-account/README.i18n.yaml +++ b/packages/llm/llm-deepseek-account/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4fde33a269d60030 zh: 22e522f4a3e25e8c /deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation: - en: 20ba5b235f0d989b - zh: b18aec33828852ea + en: 695b9c7263f6f3b5 + zh: 84d5fc9d958c4fe7 /deepseek-ai-dsh-llm-deepseek-account/further-exploration: en: e8bf99d78aa7035f zh: e4a74d2d48b6d271 diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md index 13b6df830e..539a5980eb 100644 --- a/packages/llm/llm-deepseek-account/README.md +++ b/packages/llm/llm-deepseek-account/README.md @@ -43,6 +43,8 @@ An account request HTTP 401 maps to `ACCOUNT_TOKEN_INVALID` independently of the Registrations and listeners dispose with the plugin. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route. No invariant companion is published: discovery derives directly from configuration and credentials without an independent copy. +A failed request the shared transport classifies as `QUOTA` is rewritten to `ACCOUNT_QUOTA` before it leaves this provider, covering an HTTP 402 response and an in-band SSE error alike. The shared transport and the `deepseek-official` route keep `QUOTA`, so the account route's top-up action never appears for an API-key or third-party failure. + ## Further Exploration diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md index f2ca9fe281..f436e0231d 100644 --- a/packages/llm/llm-deepseek-account/README.zh.md +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -43,6 +43,8 @@ kind: "package-reference" 注册和监听随插件生命周期释放。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由。无需 invariant companion:目录直接由同一次配置和凭据读取派生,不维护独立副本。 +共享传输判定为 `QUOTA` 的失败请求离开本提供方前会被改写为 `ACCOUNT_QUOTA`,HTTP 402 响应与流内 SSE 错误都适用。共享传输与 `deepseek-official` 路由保留 `QUOTA`,因此账号路由的充值操作不会出现在 API Key 或第三方失败上。 + ## 进一步探索 diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 847a792cd2..1244e2c549 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -1,7 +1,7 @@ /** Account-token authentication and discovery for the DeepSeek account route. */ import type {} from '@deepseek-ai/cordis-plugin-loader' import type { Context } from '@deepseek-ai/cordis' -import { LlmError } from '@deepseek-ai/dsh-llm' +import { ACCOUNT_QUOTA_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { plainOptions, resolveAdapterOptions, registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' @@ -24,7 +24,11 @@ export function apply(ctx: Context, config: Config): void { return { headers: { 'x-dsh-auth-token': token }, onRequestError: async (error) => { - if (!(error instanceof LlmError) || error.failure.status !== 401) return error + if (!(error instanceof LlmError)) return error + if (error.code === QUOTA_EXCEEDED_CODE) { + return new LlmError(error.message, ACCOUNT_QUOTA_EXCEEDED_CODE, { ...error.failure, cause: error }) + } + if (error.failure.status !== 401) return error const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) try { await account?.rejectToken(token) } catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } diff --git a/packages/llm/llm-deepseek-account/tests/account-quota.spec.ts b/packages/llm/llm-deepseek-account/tests/account-quota.spec.ts new file mode 100644 index 0000000000..c9cb8fb816 --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/account-quota.spec.ts @@ -0,0 +1,76 @@ +/** Account-route quota classification stays separate from API-key and third-party quota. */ +import { Context } from '@deepseek-ai/cordis' +import { BlockAssembler, LlmRuntime } from '@deepseek-ai/dsh-llm' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import * as ApiKey from '@deepseek-ai/dsh-llm-deepseek-api-key' +import { afterEach, expect, it, vi } from 'vitest' +import * as Account from '../src/index.ts' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +/** Drive one streamed request through the runtime and return its terminal failure. */ +async function finishOf(ctx: Context, provider: string) { + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream({ provider, model: 'deepseek-flash', messages: [] })) assembler.push(chunk) + return assembler.finish +} + +/** Mount the shared runtime plus the one credential plugin that owns `provider`. */ +async function routeFor(token: string | undefined, provider: 'deepseek-account' | 'deepseek-official'): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + ctx.provide('deepseekAccount', { + resolveToken: async (_baseURL: string): Promise => token, + rejectToken: async (_token: string): Promise => {}, + } as DeepSeekAccount) + await ctx.plugin(provider === 'deepseek-account' ? Account : ApiKey) + return ctx +} + +it.each([ + { provider: 'deepseek-account', apiKey: '', code: 'ACCOUNT_QUOTA' }, + { provider: 'deepseek-account', apiKey: 'fixture-api-key', code: 'ACCOUNT_QUOTA' }, + { provider: 'deepseek-official', apiKey: 'fixture-api-key', code: 'QUOTA' }, +] as const)('reports a 402 balance rejection on $provider with apiKey "$apiKey" as $code', async ({ provider, apiKey, code }) => { + vi.stubEnv('DEEPSEEK_API_KEY', apiKey) + const ctx = await routeFor('account-token', provider) + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + error: { code: 'insufficient_balance', message: 'Insufficient balance' }, + }), { status: 402 })) + await expect(finishOf(ctx, provider)).resolves.toMatchObject({ kind: 'error', failure: { code } }) +}) + +it('reports an in-band account balance stream error as ACCOUNT_QUOTA', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = await routeFor('account-token', 'deepseek-account') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + 'event: error\ndata: {"type":"error","error":{"type":"invalid_request_error","code":"insufficient_balance","message":"Insufficient balance"}}\n\n', + { status: 200 }, + )) + await expect(finishOf(ctx, 'deepseek-account')).resolves.toMatchObject({ + kind: 'error', failure: { code: 'ACCOUNT_QUOTA' }, + }) +}) + +it('still expires the stored credential on an account-route 401', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + const rejectToken = vi.fn(async (_token: string): Promise => {}) + ctx.provide('deepseekAccount', { + resolveToken: async (_baseURL: string): Promise => 'account-token', + rejectToken: (token: string): Promise => rejectToken(token), + } as DeepSeekAccount) + await ctx.plugin(Account) + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Unauthorized', { status: 401 })) + await expect(finishOf(ctx, 'deepseek-account')).resolves.toMatchObject({ + kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' }, + }) + expect(rejectToken.mock.calls).toEqual([['account-token']]) +}) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index d04e5ebb7c..f0ee7a8ec1 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -27,8 +27,8 @@ en: b45d7a414141734e zh: a641c8ea3827cee6 /deepseek-ai-dsh-llm/use-this-package/failures-and-recovery: - en: 4afed1d3fba1a05d - zh: 5b1727a4340b4dea + en: 311d8c92447b7094 + zh: 68cf5cb1c3853cc1 /deepseek-ai-dsh-llm/understand-the-implementation: en: 9630d7e26193a512 zh: 94222c2a487c4b8c diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 7e829ebdb2..643f2e70a5 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -71,7 +71,7 @@ After a successful mount, `ctx.llm.listProviders()` reports the registered route ### Failures and recovery -Every stream ends in exactly one terminal `finish` chunk: `{ kind: 'error', failure }` on failure, `{ kind: 'aborted', failure }` on cancellation. Failures carry stable codes such as `NO_ADAPTER`, `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`, and `CONTEXT_WINDOW_EXCEEDED`; consumers route on the code, never on message text. A request naming an unregistered provider fails with `NO_ADAPTER`, and a malformed credential fails with `INVALID_CREDENTIAL` instead of surfacing as an opaque fetch error. This service never re-runs a request: retrying is the job of `dsh-llm-retry` at the agent's failed-step extension point. +Every stream ends in exactly one terminal `finish` chunk: `{ kind: 'error', failure }` on failure, `{ kind: 'aborted', failure }` on cancellation. Failures carry stable codes such as `NO_ADAPTER`, `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`, and `CONTEXT_WINDOW_EXCEEDED`; consumers route on the code, never on message text. `QUOTA` is provider-neutral exhaustion, while `ACCOUNT_QUOTA` is reserved for a first-party account balance that the current product can replenish. A request naming an unregistered provider fails with `NO_ADAPTER`, and a malformed credential fails with `INVALID_CREDENTIAL` instead of surfacing as an opaque fetch error. This service never re-runs a request: retrying is the job of `dsh-llm-retry` at the agent's failed-step extension point. ----- diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 296a4a7cea..261a5eba1c 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -71,7 +71,7 @@ for await (const chunk of ctx.llm.stream({ ### 失败与恢复 -每个流都恰好以一个终止 `finish` 分片结束:失败为 `{ kind: 'error', failure }`,取消为 `{ kind: 'aborted', failure }`。失败携带稳定 code,如 `NO_ADAPTER`、`MISSING_CREDENTIAL`、`AUTH`、`RATE_LIMIT` 与 `CONTEXT_WINDOW_EXCEEDED`;消费方依据 code 路由,绝不解析消息文本。点名未注册提供方的请求会以 `NO_ADAPTER` 失败,格式错误的凭据会以 `INVALID_CREDENTIAL` 失败,而不是表现为不透明的 fetch 错误。本服务从不自行重跑请求:重试是 `dsh-llm-retry` 在 agent 失败步骤扩展点上的职责。 +每个流都恰好以一个终止 `finish` 分片结束:失败为 `{ kind: 'error', failure }`,取消为 `{ kind: 'aborted', failure }`。失败携带稳定 code,如 `NO_ADAPTER`、`MISSING_CREDENTIAL`、`AUTH`、`RATE_LIMIT` 与 `CONTEXT_WINDOW_EXCEEDED`;消费方依据 code 路由,绝不解析消息文本。`QUOTA` 表示提供方中立的额度耗尽,`ACCOUNT_QUOTA` 专用于当前产品能够充值的第一方账号余额不足。点名未注册提供方的请求会以 `NO_ADAPTER` 失败,格式错误的凭据会以 `INVALID_CREDENTIAL` 失败,而不是表现为不透明的 fetch 错误。本服务从不自行重跑请求:重试是 `dsh-llm-retry` 在 agent 失败步骤扩展点上的职责。 ----- diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 477735d178..430f031ea1 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -27,6 +27,9 @@ export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' /** Canonical provider-neutral code for an exhausted account quota or balance. */ export const QUOTA_EXCEEDED_CODE = 'QUOTA' +/** Account-token quota that can be replenished through the first-party billing page. */ +export const ACCOUNT_QUOTA_EXCEEDED_CODE = 'ACCOUNT_QUOTA' + /** * Canonical provider-neutral code for a response that completed normally but * carried no content blocks at all. Providers occasionally emit a degenerate