diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml
new file mode 100644
index 0000000000..cc3873f137
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md
+2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d
+2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284
diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md
new file mode 100644
index 0000000000..1f247a6c93
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md
@@ -0,0 +1,38 @@
+# Agent Note: First-run readiness reads every provider, and the setup card closes
+
+Status: implemented
+
+English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md)
+
+## Problem
+
+The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading.
+
+A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured.
+
+On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself.
+
+## Decision
+
+One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key.
+
+`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering).
+
+`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card.
+
+Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it.
+
+## Alternatives considered
+
+- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding.
+- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable.
+- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form.
+- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local.
+
+## Consequences
+
+Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface.
+
+## Testing
+
+Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state.
diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md
new file mode 100644
index 0000000000..fc6e43195a
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md
@@ -0,0 +1,38 @@
+# Agent Note: First-run readiness reads every provider, and the setup card closes
+
+Status: implemented
+
+[English](2026-08-12-onboarding-reads-every-provider.md) | 中文
+
+## Problem
+
+首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。
+
+配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。
+
+在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。
+
+## Decision
+
+一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。
+
+`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。
+
+`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。
+
+现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。
+
+## Alternatives considered
+
+- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。
+- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。
+- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。
+- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。
+
+## Consequences
+
+引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。
+
+## Testing
+
+包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。
diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
index 4b44acecd9..2c276afafe 100644
--- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
-2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab
-2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd
+2026-08-10-durable-workflow-runs-in-chat.md: 817fd4debd93a4768904e3934456ebdd4bdaa896
+2026-08-10-durable-workflow-runs-in-chat.zh.md: 7b09708d94783de5aff9a9fd59757120c661775a
diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
index 791a81e9e3..817fd4debd 100644
--- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
+++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
@@ -20,7 +20,7 @@ The workflow package exposes browser-safe run and observation vocabulary through
`ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state.
-The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present.
+The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. [Status-driven workflow disclosure](2026-08-11-workflow-run-status-driven-disclosure.md) owns which run and phase content remains visible as those facts change.
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
@@ -42,4 +42,4 @@ Package tests cover top-level and nested eligibility, zero-member and concurrent
## Consequences
-Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening.
+Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, the status-driven disclosure lifecycle keeps review choices local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening.
diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
index e6c87f61a1..7b09708d94 100644
--- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
@@ -20,7 +20,7 @@ workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运
`ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`;run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。
-renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。
+renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。[状态驱动的工作流 disclosure](2026-08-11-workflow-run-status-driven-disclosure.md)拥有这些事实变化时运行与阶段内容的可见性。
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
@@ -42,4 +42,4 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
## 后果
-工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。
+工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,状态驱动的 disclosure 生命周期把复盘选择留在本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。
diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml
new file mode 100644
index 0000000000..1f7ecb98d3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md
+2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781
+2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b
diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md
new file mode 100644
index 0000000000..2f452d25a8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md
@@ -0,0 +1,43 @@
+# Agent Note: Status-driven disclosure for workflow runs
+
+Status: implemented
+
+English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md)
+
+## Problem
+
+A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review.
+
+The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes.
+
+## Decision
+
+Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible.
+
+A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action.
+
+A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice.
+
+For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes.
+
+The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change.
+
+## Verification
+
+Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory.
+
+The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts.
+
+## Alternatives considered
+
+**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work.
+
+**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review.
+
+**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need.
+
+## Consequences
+
+Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction.
+
+The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly.
diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md
new file mode 100644
index 0000000000..12cc106fea
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md
@@ -0,0 +1,43 @@
+# Agent Note: 工作流运行的状态驱动 disclosure
+
+Status: implemented
+
+[English](2026-08-11-workflow-run-status-driven-disclosure.md) | 中文
+
+## 问题
+
+持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。
+
+renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。
+
+## 决策
+
+每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。
+
+强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。
+
+干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。
+
+例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。
+
+renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。
+
+## 验证
+
+组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。
+
+shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。
+
+## 曾考虑的替代方案
+
+**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。
+
+**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。
+
+**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。
+
+## 后果
+
+工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。
+
+代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。
diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml
new file mode 100644
index 0000000000..ef5f11a4f8
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md
+2026-08-12-documentation-site-navigation-and-chrome.md: 03cd44b94f853725da33800e8c89886b1a657a0b
+2026-08-12-documentation-site-navigation-and-chrome.zh.md: d0972f909e648278cb3cecb7788705b228f4b675
diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md
new file mode 100644
index 0000000000..03cd44b94f
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md
@@ -0,0 +1,41 @@
+# Agent Note: Documentation-site navigation and repository chrome
+
+Status: implemented
+
+English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md)
+
+## Problem
+
+The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for the subsystem groups, nor for the group holding the Python SDK page, so `indexOf` returned `-1` and sorted them ahead of the ordered sections. Clicking the `参考` navigation item landed on the architecture page whose own sidebar entry was link 44 of 62, 1549px down a 2478px sidebar — outside the viewport. Four subsystem pages carried `order` values already taken by other pages in the same section, resolved only by `Array.prototype.sort` stability and the order the manifest's arrays happened to be concatenated.
+
+The navigation bar named `/guide/` while the manifest published the guide's first page at `guide/quickstart.md`, so that item served a 404: written-down navigation targets drift from the routes the manifest publishes.
+
+Separately, every canonical page carries lines written for its GitHub reader — a language switcher under the heading, and for some, a repository badge — which the site projected verbatim even though its navigation bar already offers both.
+
+## Decision
+
+[website/docs.ts](../../../../website/docs.ts) owns section placement. `sections` declares the groups per locale, and `sectionSpec(locale, label)` returns a group's position and collapse behavior, throwing when a locale declares no placement for a label. A group absent from the declaration now fails the build instead of sorting silently to the top. Placement is per locale because the two sidebars name their groups independently, and a label both use — `SDK` — cannot hold one rank against `入门` and against `Guide` at once.
+
+Subsystem pages are grouped by concern — overview, core and scopes, sessions and persistence, model and context, execution and tools, policy and interaction, platform and access — and the six topical groups render collapsed until one holds the page being read. The groups sort last within the reference sidebar: expanded, they outnumber every other group combined, so anything placed after them is reachable only by scrolling past the whole list. Page `order` derives from array position rather than a hand-written number.
+
+`landingLink(locale, collection)` derives each navigation item's target from `orderedPages`, the same ordering the sidebar renders, so an item always opens its collection's first published page.
+
+`projectedPageContent` in [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) drops the language-switcher line and the repository badge. The switcher match is confined to the first eight lines so a tutorial that shows the convention still renders its example.
+
+The navigation-bar title is the DeepSeek wordmark inlined into `siteTitle`, which VitePress renders as HTML. Inlining is what lets the mark's `currentColor` fills follow the active theme; `themeConfig.logo` renders an ``, which freezes the mark at the colors its file declares and would need one asset per theme. The sidebar scrollbar rests invisible and appears while scrolling, marked by a `data-` attribute rather than a class because Vue rewrites `class` wholesale when it patches the element.
+
+## Alternatives considered
+
+**A search tokenizer for Chinese queries.** Built and reverted. The premise — that MiniSearch leaves Chinese prose as untokenizable whole sentences — was tested against a term (`子代理`) that appears nowhere in the corpus; the Chinese pages write `Subagent` and `子 agent`. Measured against the unmodified index, `插件配置` returns 120 hits, `会话持久化` 85, `工作流` 28, `沙箱` 12, each ranking its own page first: `prefix: true` already reaches Chinese terms through the short tokens punctuation produces. Adjacent-character pairs grew the Chinese index from 1.23MB to 2.12MB for no gain. The attempt also surfaced a trap worth keeping: VitePress ships search-option functions to the browser through `Function.prototype.toString` and rebuilds them with `new Function`, so any such function that closes over a module-level constant throws in an empty scope and silently returns no results.
+
+**Placing the subsystem groups directly after `概念`.** Rejected: it restores the architecture page to the top but leaves generated reference, the Cordis API, and the cookbook below 43 rows.
+
+**Rewriting filename link text during projection.** The subsystem index table writes `[core.md](core.md)`, which reads as a repository file index on the site. `scripts/project-doc-site.spec.ts` asserts that exact row format, so the filenames are a deliberate convention rather than an oversight; changing what the site displays means changing the convention and its gate together, not working around them in the projector.
+
+## Consequences
+
+The reference sidebar measures 1452px with every subsystem group collapsed, against 2478px before, and the architecture page is its first entry. Section placement and collapse are declared in one manifest instead of split between the manifest and the config, and `scripts/project-doc-site.spec.ts` pins three invariants: every sidebar-owning page resolves a placement, an undeclared section is refused, and no two pages share an `order` within a section.
+
+Canonical Markdown is unchanged by the chrome stripping — the switcher and badge still serve GitHub readers. The cost is that the projector now knows two presentation conventions of the source corpus, which a page written with a different switcher wording would not match.
+
+The wordmark is a second copy of a mark that also lives in `apps/web/public/favicon.svg` and `packages/client/ui-primitives/src/FishLogo.tsx`, each carrying its own presentation. A change to the DeepSeek wordmark reaches the documentation site only by updating this copy.
diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md
new file mode 100644
index 0000000000..d0972f909e
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md
@@ -0,0 +1,41 @@
+# Agent Note: 文档站导航与仓库 chrome
+
+Status: implemented
+
+[English](2026-08-12-documentation-site-navigation-and-chrome.md) | 中文
+
+## 问题
+
+参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为子系统分组、也没有为承载 Python SDK 页的分组声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。
+
+顶栏把 `入门` 指向 `/guide/`,而 manifest 已把入门首页发布在 `guide/quickstart.md`,该导航项因此返回 404:写死的导航目标会与 manifest 实际发布的路由脱节。
+
+另外,每个规范页面都带有写给 GitHub 读者的行——标题下的语言切换行,部分页面还有仓库徽章——站点原样投影了它们,尽管其导航栏已经提供了这两者。
+
+## 决定
+
+[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组,而两侧共用的标签 `SDK` 无法同时相对 `入门` 和相对 `Guide` 取同一位次。
+
+子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。
+
+`landingLink(locale, collection)` 依据 `orderedPages`——即侧边栏所用的同一套排序——推导每个导航项的目标,因此导航项始终打开该分区已发布的首个页面。
+
+[scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) 中的 `projectedPageContent` 会丢弃语言切换行和仓库徽章。切换行的匹配被限制在前八行内,因此展示该约定的教程仍能渲染出它的示例。
+
+导航栏标题是内联进 `siteTitle` 的 DeepSeek 字标,VitePress 会将其按 HTML 渲染。内联正是让字标的 `currentColor` 填充跟随当前主题的原因;`themeConfig.logo` 渲染为 ``,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `data-` 属性而非 class 标记,因为 Vue 在 patch 该元素时会整体重写 `class`。
+
+## 考虑过的替代方案
+
+**为中文查询定制搜索分词器。** 已实现并撤回。其前提——MiniSearch 会把中文散文留作无法切分的整句——是用一个语料中根本不存在的词(`子代理`)验证的;中文页面写的是 `Subagent` 和 `子 agent`。在未改动的索引上实测,`插件配置` 返回 120 条命中、`会话持久化` 85 条、`工作流` 28 条、`沙箱` 12 条,且各自的页面均排在首位:`prefix: true` 已经能通过标点切出的短 token 命中中文词。相邻字符二元组把中文索引从 1.23MB 增至 2.12MB,却没有带来收益。该尝试还暴露出一个值得保留的陷阱:VitePress 通过 `Function.prototype.toString` 把搜索选项中的函数送到浏览器,再用 `new Function` 重建,因此任何闭包引用了模块级常量的此类函数都会在空作用域中抛错,并静默地返回零结果。
+
+**把子系统分组直接放在 `概念` 之后。** 已否决:这样能让架构页回到顶部,但生成参考、Cordis API 和开发手册仍处在 43 行之下。
+
+**在投影时重写文件名链接文字。** 子系统索引表写的是 `[core.md](core.md)`,在站点上读起来像仓库文件索引。`scripts/project-doc-site.spec.ts` 断言了该行的确切格式,因此这些文件名是刻意的约定而非疏漏;要改变站点显示的内容,就要连同该约定及其门禁一起改,而不是在投影器里绕开它们。
+
+## 影响
+
+在所有子系统分组折叠时,参考侧边栏高度为 1452px,此前为 2478px,且架构页是它的第一个条目。分区位置与折叠行为声明在同一份 manifest 中,不再分散于 manifest 与配置之间;`scripts/project-doc-site.spec.ts` 固定了三条不变式:每个拥有侧边栏的页面都能解析到位置、未声明的分区会被拒绝、同一分区内没有两个页面共用 `order`。
+
+剥离 chrome 不改动规范 Markdown——切换行与徽章仍服务于 GitHub 读者。代价是投影器现在知晓源语料的两项呈现约定,而采用不同切换行措辞的页面将不会被匹配到。
+
+字标是同一图形的第二份副本,另两份位于 `apps/web/public/favicon.svg` 和 `packages/client/ui-primitives/src/FishLogo.tsx`,各自承载自己的呈现方式。DeepSeek 字标的变更只有通过更新这份副本才能到达文档站。
diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts
new file mode 100644
index 0000000000..09e3923064
--- /dev/null
+++ b/apps/web/tests/onboarding-usable-provider.e2e.ts
@@ -0,0 +1,128 @@
+// Keyless browser e2e: a user who configures some OTHER provider is not asked
+// for the official DeepSeek key again, and the first-run setup card is a card
+// they can close. The shipped DeepSeek adapter stays mounted without a
+// credential throughout, so the only thing that ends onboarding here is the
+// pi-ai route the user configures through the real wire. Zero model calls:
+// configuration is pure settings/credentials/llm-domain traffic.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url))
+const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
+const MODE = webSnapshotMode()
+const CREDENTIAL_STEP = '添加一个 API Key 开始使用'
+
+describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+ browser = await chromium.launch()
+ // The scenario asserts the shipped Chinese copy, so the browser asks for it.
+ page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('closes the setup card without discarding the add card beside it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
+ const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
+ await credentialStep.waitFor({ timeout: 15_000 })
+ await credentialStep.getByRole('button', { name: '前往配置' }).click()
+ await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
+
+ const settings = page.getByRole('dialog', { name: '设置' })
+ await settings.waitFor({ timeout: 10_000 })
+ // Nothing is reachable yet, so DeepSeek presents itself as its open card.
+ const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
+ await setupKey.waitFor({ timeout: 10_000 })
+
+ const add = settings.getByRole('button', { name: '添加提供方' })
+ await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
+ await add.click()
+ const pick = settings.getByLabel('提供方')
+ await pick.waitFor({ timeout: 10_000 })
+ await pick.selectOption('minimax-cn')
+ await expect.poll(
+ async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
+ { timeout: 10_000 },
+ ).toBe(2)
+
+ // Cancelling the setup card is the regression: it used to leave itself open
+ // and close the add card, discarding that draft.
+ await settings.getByRole('button', { name: '取消', exact: true }).first().click()
+ expect(await settings.getByLabel('提供方').count()).toBe(1)
+ await expect.poll(
+ async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
+ { timeout: 10_000 },
+ ).toBe(1)
+ // DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
+ await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+ const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
+
+ expect(tripwire.warnings).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('stops prompting for DeepSeek once the other provider can serve requests', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider'))
+ const settings = page.getByRole('dialog', { name: '设置' })
+ await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
+ await settings.getByRole('button', { name: '保存', exact: true }).click()
+ await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 })
+
+ // Only minimax-cn is reachable; DeepSeek still holds no credential.
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
+ const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
+ expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
+ expect(credentials).not.toContain('DEEPSEEK_API_KEY')
+
+ const warningsBefore = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+ await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
+ // The regression: the step read only the official route's credential, so a
+ // fully configured user was taken over on every blank session.
+ await expect.poll(
+ async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
+ expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
+ expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
+
+ // The Models page agrees: DeepSeek stays a row rather than reopening its
+ // setup card over a user who already has somewhere to send a request.
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ await settings.waitFor({ timeout: 10_000 })
+ await settings.getByRole('button', { name: '模型' }).click()
+ await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+ expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
+
+ expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps the fixture inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md'])
+ })
+})
diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md
new file mode 100644
index 0000000000..182fadf973
--- /dev/null
+++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md
@@ -0,0 +1,71 @@
+- dialog "设置":
+ - navigation:
+ - text: 设置
+ - button "通用设置":
+ - img
+ - text: 通用设置
+ - button "模型":
+ - img
+ - text: 模型
+ - button "Agent 预设":
+ - img
+ - text: Agent 预设
+ - button "插件配置":
+ - img
+ - text: 插件配置
+ - button "打开配置文件"
+ - button "关闭":
+ - img
+ - text: 关闭
+ - heading "模型" [level=2]
+ - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+ - list:
+ - listitem:
+ - text: DeepSeek
+ - img "API 密钥缺失"
+ - button "编辑 DeepSeek (deepseek-official)": 编辑
+ - text: 提供方
+ - combobox "提供方":
+ - option "amazon-bedrock"
+ - option "ant-ling"
+ - option "anthropic"
+ - option "azure-openai-responses"
+ - option "cerebras"
+ - option "cloudflare-ai-gateway"
+ - option "cloudflare-workers-ai"
+ - option "deepseek"
+ - option "fireworks"
+ - option "github-copilot"
+ - option "google"
+ - option "google-vertex"
+ - option "groq"
+ - option "huggingface"
+ - option "kimi-coding"
+ - option "minimax"
+ - option "minimax-cn" [selected]
+ - option "mistral"
+ - option "moonshotai"
+ - option "moonshotai-cn"
+ - option "nvidia"
+ - option "openai"
+ - option "openai-codex"
+ - option "opencode"
+ - option "opencode-go"
+ - option "openrouter"
+ - option "qwen-token-plan"
+ - option "qwen-token-plan-cn"
+ - option "together"
+ - option "vercel-ai-gateway"
+ - option "xai"
+ - option "xiaomi"
+ - option "xiaomi-token-plan-ams"
+ - option "xiaomi-token-plan-cn"
+ - option "xiaomi-token-plan-sgp"
+ - option "zai"
+ - option "zai-coding-cn"
+ - text: API 密钥
+ - textbox "API 密钥":
+ - /placeholder: 输入 API 密钥,或留空使用环境认证
+ - group: 自定义设置
+ - button "取消"
+ - button "保存"
diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts
index eafb78223f..4cbae8e6e2 100644
--- a/apps/web/tests/workflow-run.e2e.ts
+++ b/apps/web/tests/workflow-run.e2e.ts
@@ -74,12 +74,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await input.fill(prompt)
await input.press('Enter')
- const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
+ const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
await workflow.waitFor({ timeout: 30_000 })
- expect(await workflow.getAttribute('aria-expanded')).toBe('true')
- const phase = page.getByRole('button', { name: /^Run/ })
- await phase.waitFor({ timeout: 15_000 })
- await phase.click()
+ const disclosures = workflow.locator('[data-disclosure-row]')
+ await disclosures.nth(1).waitFor({ timeout: 15_000 })
+ expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
+ expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
+ expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
+ expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
+ expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
+ expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
@@ -139,15 +143,20 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
+ await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
- if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
+ expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
+ expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
+ await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
- if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
+ expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
+ expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
+ await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
@@ -165,6 +174,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
+ expect(await phase.getAttribute('aria-expanded')).toBe('false')
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index b1153f4b8e..e656b099ea 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -42,6 +42,7 @@
"tests/default-model.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
+ "tests/onboarding-usable-provider.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml
index 7d0e7d62a7..bbf7490428 100644
--- a/docs/cordis-tutorial/index.i18n.yaml
+++ b/docs/cordis-tutorial/index.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md
-index.md: a10a0f93fde4f710af2ab14f74b854ee07d7c03f
-index.zh.md: 3388d9c1b799ffb7d698027bd1bc6bcdc989aa49
+index.md: dc9bc13c80885857d42bbc32532f678d8942a40d
+index.zh.md: b626fd8676a3d3b3277cb58a6bcfc32b7cdf4beb
diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md
index a10a0f93fd..dc9bc13c80 100644
--- a/docs/cordis-tutorial/index.md
+++ b/docs/cordis-tutorial/index.md
@@ -8,6 +8,8 @@ The audience is agent developers. You do not need deep TypeScript experience; th
If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) and the [Cordis core API](../cordis-api/context.md) pages.
+To write plugins for the harness itself — loaded from a `cordis.yml` and driven from the Web UI rather than the launcher below — start from [your first Harness plugin](../user/develop/basic/index.md).
+
## Setup
You need a clone of this repository with dependencies installed; the [development guide](../development.md#setup-tutorial) lists the prerequisites. No API key is needed for this tutorial; every example runs keylessly.
diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md
index 3388d9c1b7..b626fd8676 100644
--- a/docs/cordis-tutorial/index.zh.md
+++ b/docs/cordis-tutorial/index.zh.md
@@ -8,6 +8,8 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行
如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见[子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块,以及 [Cordis 核心 API](../cordis-api/context.md) 页面。
+如果你要为 harness 本身编写插件——由 `cordis.yml` 加载、在 Web UI 中驱动,而不是下面这个启动器——请从[第一个 Harness 插件](../user/develop/basic/index.md)开始。
+
## 准备工作
你需要克隆本仓库并安装依赖;[开发指南](../development.md#setup-tutorial)列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。
diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml
index 516ce82324..0dd8723e1b 100644
--- a/docs/user/develop/basic/index.i18n.yaml
+++ b/docs/user/develop/basic/index.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md
-index.md: 71b5bd5ef5d296999420c40d3b8c9cf46c918841
-index.zh.md: f774f26e5da144e6f86a371f351bdbca630d0474
+index.md: 494b7869be6ffdf5767fac260b36b2585305b516
+index.zh.md: a55b8e31151c5cfa5445069974be9fa022fea1eb
diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md
index 71b5bd5ef5..494b7869be 100644
--- a/docs/user/develop/basic/index.md
+++ b/docs/user/develop/basic/index.md
@@ -139,3 +139,4 @@ Function form is sufficient in most cases. Use class form when the plugin provid
- [Build a tool](./tool.md) — learn the tool definition DSL
- [Plugin configuration](./config.md) — accept user configuration
+- [Cordis tutorial](../../../cordis-tutorial/index.md) — the plugin framework underneath, built from a scratch directory with no API key
diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md
index f774f26e5d..a55b8e3115 100644
--- a/docs/user/develop/basic/index.zh.md
+++ b/docs/user/develop/basic/index.zh.md
@@ -139,3 +139,4 @@ export default class MyService extends Service {
- [开发一个工具](./tool.md) — 了解工具定义 DSL
- [插件配置](./config.md) — 让插件接受用户配置
+- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 底层的插件框架,在临时目录中动手构建,无需 API 密钥
diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml
index 1c8dc3dae4..3e27e77e5a 100644
--- a/docs/user/develop/framework/index.i18n.yaml
+++ b/docs/user/develop/framework/index.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md
-index.md: 85701ce281d92da0c805b39291179df73eb65f51
-index.zh.md: 871aa55ef81a7dcbfe3cbde5986244220ee32f98
+index.md: 8cc673148d7fec4f7d9b994907e17293bc3a6a97
+index.zh.md: 1a1f7feb8685e124babb182544bde332b52da42c
diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md
index 85701ce281..8cc673148d 100644
--- a/docs/user/develop/framework/index.md
+++ b/docs/user/develop/framework/index.md
@@ -134,3 +134,4 @@ effect cleaned up
- [Services and dependencies](./service.md) — expose a capability to other plugins
- [Event system](./events.md) — communicate between plugins
+- [Cordis tutorial](../../../cordis-tutorial/index.md) — the same lifecycle, services, and events built step by step against the Cordis runtime
diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md
index 871aa55ef8..1a1f7feb86 100644
--- a/docs/user/develop/framework/index.zh.md
+++ b/docs/user/develop/framework/index.zh.md
@@ -134,3 +134,4 @@ effect cleaned up
- [服务与依赖](./service.md) — 让插件向其他插件提供能力
- [事件系统](./events.md) — 在插件之间通信
+- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 在 Cordis 运行时上逐步搭出同一套生命周期、服务与事件
diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml
index cb9905a0cb..41c53b2c8c 100644
--- a/packages/client/ui-models/README.i18n.yaml
+++ b/packages/client/ui-models/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
-README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff
-README.zh.md: 3bd9ce41a6949bc38123cce2846242405e0d6279
+README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b
+README.zh.md: 197d0a6a8e4761bd84832d9ae6482b1452753fbd
diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md
index f6604f8224..a8d030b767 100644
--- a/packages/client/ui-models/README.md
+++ b/packages/client/ui-models/README.md
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
-Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
+Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
-The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
+The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md
index 3bd9ce41a6..197d0a6a8e 100644
--- a/packages/client/ui-models/README.zh.md
+++ b/packages/client/ui-models/README.zh.md
@@ -4,9 +4,9 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret slot)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
-行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
+行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
-前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
+前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx
index c8668c3700..302d4592f8 100644
--- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx
+++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx
@@ -1,7 +1,9 @@
/**
* Official-DeepSeek first-run step. Readiness comes from the same
- * provider/settings/credential join as the Models page; the prompt only
- * routes the user to that page's single credential editor.
+ * provider/settings/credential join as the Models page: any provider the user
+ * can already talk to ends the step, and only a user with none is offered the
+ * official DeepSeek route. The prompt itself only routes to that page's single
+ * credential editor.
*/
import { useEffect, useRef } from 'react'
@@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
-import { deepSeekReadiness } from './store.ts'
+import { onboardingReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
@@ -34,15 +36,15 @@ function assertNever(_value: never): never {
}
/**
- * Prompt a first-run user to open Models while the official adapter exists
- * and its effective credential is not configured.
+ * Prompt a first-run user to open Models while no provider can serve requests
+ * and the official adapter exists with an unconfigured effective credential.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
- const readiness = deepSeekReadiness(state)
+ const readiness = onboardingReadiness(state)
const titleRef = useRef(null)
useEffect(() => {
@@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
- || readiness.kind === 'configured'
+ || readiness.kind === 'provider-ready'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
@@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
- case 'configured':
+ case 'provider-ready':
case 'unavailable':
return null
case 'credential-missing':
diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx
index 1eba48903e..5fe5647b88 100644
--- a/packages/client/ui-models/src/client/ModelsSection.tsx
+++ b/packages/client/ui-models/src/client/ModelsSection.tsx
@@ -3,11 +3,13 @@
* directory, settings namespaces, and credential states, with one editor
* card at a time. Rows expose only confirmed API-key state through accessible
* solid configured or missing dots. A whole-section provider without a
- * configured key (the unconfigured DeepSeek posture) renders as its open setup
- * card instead of a row; the add flow is a card carrying the dormant-provider
- * select. Every mutation writes through the wire, while a provider removal first requires
- * confirmation; the page re-renders from pushed invalidations or the
- * post-apply reload.
+ * configured key renders as its open setup card instead of a row, but only in
+ * the first-run posture — no provider on the page can serve requests yet — and
+ * only until the user closes that card; the add flow is a card carrying the
+ * dormant-provider select. Each card kind owns its own open state, so closing
+ * one never discards a draft in another. Every mutation writes through the
+ * wire, while a provider removal first requires confirmation; the page
+ * re-renders from pushed invalidations or the post-apply reload.
*/
import { useState } from 'react'
@@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { CustomProviderCard } from './CustomProviderCard.tsx'
-import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
+import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -116,11 +118,15 @@ export async function removeProviderProfile(
/**
* Whether a whole-section provider still needs its first key: an unconfigured
- * credential opens the setup card instead of showing a row.
+ * credential opens the setup card instead of showing a row. This is the
+ * first-run posture alone — a user who can already reach some provider gets an
+ * ordinary row with the missing-key dot, since nothing here is blocking them.
* @param row - the joined provider row.
+ * @param anyUsable - whether any joined row can already serve requests.
* @returns whether to render the setup card.
*/
-export function needsSetup(row: ProviderRow): boolean {
+export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
+ if (anyUsable) return false
if (row.entry.settingsPath.length > 0) return false
return row.credential?.configured !== true
}
@@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [deleteFailure, setDeleteFailure] = useState(undefined)
const [savedTarget, setSavedTarget] = useState(undefined)
const [declaring, setDeclaring] = useState(false)
+ const [dismissedSetup, setDismissedSetup] = useState>(() => new Set())
+
+ const announceSaved = (target: ProviderIdentity): void => {
+ // Announced only once the refreshed directory is in the snapshot the
+ // notice reads its name from: an apply can rename the route, and the
+ // target captured when the card opened still carries the old name.
+ void controller.load().then(() => { setSavedTarget(target) })
+ }
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
- if (changed) {
- // Announced only once the refreshed directory is in the snapshot the
- // notice reads its name from: an apply can rename the route, and the
- // target captured when the card opened still carries the old name.
- void controller.load().then(() => { setSavedTarget(target) })
- }
+ if (changed) announceSaved(target)
+ }
+
+ /**
+ * Close a setup card, which owns none of the state above: the row-editor,
+ * add, and declare cards each own one of those, so clearing them here would
+ * discard a draft the user opened beside this card. Dismissal is this card's
+ * own — the provider falls back to an ordinary row for the rest of the
+ * session, and reopens through Edit.
+ */
+ const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
+ setDismissedSetup(previous => new Set([...previous, target.provider]))
+ if (changed) announceSaved(target)
}
const closeDelete = (): void => {
@@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
? savedTarget
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
+ // One fact decides both first-run postures on this page and the onboarding
+ // step: whether the user already has a provider to talk to.
+ const anyUsable = state.rows.some(providerUsable)
const configured = state.rows.filter(row => row.configured)
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
@@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const namespace = state.namespaces.get(target.settingsNs)
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
if (namespace === undefined) return null
- if (needsSetup(row)) {
+ if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
// First-run posture: the provider exists but has no key — the
- // setup card IS its presence on the page.
+ // setup card IS its presence on the page, until the user closes it.
return (