Browse Source

Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.zh.md
xjt 3 weeks ago
parent
commit
19c48b2ae3
46 changed files with 1407 additions and 402 deletions
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml
  2. 38 0
      .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md
  3. 38 0
      .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
  6. 2 2
      .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml
  8. 43 0
      .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md
  9. 43 0
      .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md
  10. 6 0
      .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml
  11. 41 0
      .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md
  12. 41 0
      .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md
  13. 128 0
      apps/web/tests/onboarding-usable-provider.e2e.ts
  14. 71 0
      apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md
  15. 17 7
      apps/web/tests/workflow-run.e2e.ts
  16. 1 0
      apps/web/tsconfig.json
  17. 2 2
      docs/cordis-tutorial/index.i18n.yaml
  18. 2 0
      docs/cordis-tutorial/index.md
  19. 2 0
      docs/cordis-tutorial/index.zh.md
  20. 2 2
      docs/user/develop/basic/index.i18n.yaml
  21. 1 0
      docs/user/develop/basic/index.md
  22. 1 0
      docs/user/develop/basic/index.zh.md
  23. 2 2
      docs/user/develop/framework/index.i18n.yaml
  24. 1 0
      docs/user/develop/framework/index.md
  25. 1 0
      docs/user/develop/framework/index.zh.md
  26. 2 2
      packages/client/ui-models/README.i18n.yaml
  27. 0 0
      packages/client/ui-models/README.md
  28. 0 0
      packages/client/ui-models/README.zh.md
  29. 10 8
      packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx
  30. 41 17
      packages/client/ui-models/src/client/ModelsSection.tsx
  31. 29 30
      packages/client/ui-models/src/client/store.ts
  32. 130 71
      packages/client/ui-models/tests/components.client.spec.tsx
  33. 58 25
      packages/client/ui-models/tests/readiness.client.spec.ts
  34. 2 2
      packages/client/ui-workflow-run/README.i18n.yaml
  35. 1 1
      packages/client/ui-workflow-run/README.md
  36. 1 1
      packages/client/ui-workflow-run/README.zh.md
  37. 0 2
      packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css
  38. 53 26
      packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
  39. 144 78
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  40. 77 1
      scripts/project-doc-site.spec.ts
  41. 32 1
      scripts/project-doc-site.ts
  42. 1 0
      tsconfig.host.json
  43. 129 50
      website/.vitepress/config.ts
  44. 186 68
      website/docs.ts
  45. 1 0
      website/public/favicon.svg
  46. 11 0
      website/public/wordmark.svg

+ 6 - 0
.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

+ 38 - 0
.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.

+ 38 - 0
.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。

+ 2 - 2
.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

+ 2 - 2
.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.

+ 2 - 2
.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 生命周期把复盘选择留在本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。

+ 6 - 0
.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

+ 43 - 0
.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.

+ 43 - 0
.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`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。
+
+**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。
+
+## 后果
+
+工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。
+
+代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。

+ 6 - 0
.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

+ 41 - 0
.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 `<img>`, 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.

+ 41 - 0
.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` 渲染为 `<img>`,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `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 字标的变更只有通过更新这份副本才能到达文档站。

+ 128 - 0
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<typeof watchConsole>
+
+  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'])
+  })
+})

+ 71 - 0
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 "保存"

+ 17 - 7
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)

+ 1 - 0
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",

+ 2 - 2
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

+ 2 - 0
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.

+ 2 - 0
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 密钥;所有示例均可在无密钥环境中运行。

+ 2 - 2
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

+ 1 - 0
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

+ 1 - 0
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 密钥

+ 2 - 2
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

+ 1 - 0
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

+ 1 - 0
docs/user/develop/framework/index.zh.md

@@ -134,3 +134,4 @@ effect cleaned up
 
 - [服务与依赖](./service.md) — 让插件向其他插件提供能力
 - [事件系统](./events.md) — 在插件之间通信
+- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 在 Cordis 运行时上逐步搭出同一套生命周期、服务与事件

+ 2 - 2
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

File diff suppressed because it is too large
+ 0 - 0
packages/client/ui-models/README.md


File diff suppressed because it is too large
+ 0 - 0
packages/client/ui-models/README.zh.md


+ 10 - 8
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<HTMLHeadingElement | null>(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':

+ 41 - 17
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<string | undefined>(undefined)
   const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
   const [declaring, setDeclaring] = useState(false)
+  const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => 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 (
               <li key={row.entry.provider} className={styles['setupCard']}>
                 {renderProviderEditor({
@@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
                   api,
                   t,
                   readOnly: !state.writable,
-                  onClose: (changed) => { closeEditor(changed, target) },
+                  onClose: (changed) => { closeSetup(changed, target) },
                 })}
               </li>
             )

+ 29 - 30
packages/client/ui-models/src/client/store.ts

@@ -189,32 +189,49 @@ export class ModelsSettingsStore {
   }
 }
 
-/** DeepSeek onboarding readiness derived only from the shared Models join. */
-export type DeepSeekReadiness =
+/**
+ * Whether a joined row can serve model requests as it stands: the route is
+ * registered with the adapter registry, and whatever credential its resolved
+ * profile names is stored. A profile naming no reference authenticates through
+ * the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs
+ * nothing), as does a live route with no settings address at all, so neither
+ * owes this page a key.
+ * @param row - one joined provider row.
+ * @returns whether the user already has this provider to talk to.
+ */
+export function providerUsable(row: ProviderRow): boolean {
+  if (!row.entry.active) return false
+  if (row.apiKeyEnv === undefined) return true
+  return row.credential?.configured === true
+}
+
+/** First-run onboarding readiness derived only from the shared Models join. */
+export type OnboardingReadiness =
   | { kind: 'loading' }
   | { kind: 'adapter-absent' }
-  | { kind: 'configured' }
+  | { kind: 'provider-ready' }
   | { kind: 'credential-missing' }
   | {
     kind: 'unavailable'
     reason:
       | 'load-failed'
       | 'provider-inactive'
-      | 'settings-unavailable'
-      | 'credential-ref-unavailable'
       | 'credentials-unavailable'
       | 'settings-read-only'
       | 'credential-read-only'
   }
 
 /**
- * Project official-DeepSeek readiness from the provider/settings/credential
- * join used by the Models page. A missing official configurable-provider
+ * Project first-run readiness from the provider/settings/credential join used
+ * by the Models page. The step exists to leave the user with a model to talk
+ * to, so ANY usable provider ends it; only when none exists does the official
+ * DeepSeek route — the one route the prompt can offer a key field for — decide
+ * whether prompting can help. A missing official configurable-provider
  * declaration means the adapter is not repairable by navigating to Models.
  * @param state - current shared Models join snapshot.
  * @returns the onboarding state without reading a parallel fact source.
  */
-export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
+export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness {
   if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
     return { kind: 'loading' }
   }
@@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
       reason: 'load-failed',
     }
   }
+  if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
   const row = state.rows.find(candidate =>
     candidate.entry.provider === 'deepseek-official'
     && candidate.entry.settingsNs === 'llm-deepseek'
@@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
       reason: 'provider-inactive',
     }
   }
-  if (!row.configured) {
-    return {
-      kind: 'unavailable',
-      reason: 'settings-unavailable',
-    }
-  }
-  if (row.apiKeyEnv === undefined) {
-    return {
-      kind: 'unavailable',
-      reason: 'credential-ref-unavailable',
-    }
-  }
-  if (state.credentialError !== null) {
+  // Past the usable gate an active route names a reference it has no stored
+  // credential for, so the remaining questions are all about that credential.
+  if (state.credentialError !== null || row.credential === undefined) {
     return {
       kind: 'unavailable',
       reason: 'credentials-unavailable',
     }
   }
-  if (row.credential === undefined) {
-    return {
-      kind: 'unavailable',
-      reason: 'credentials-unavailable',
-    }
-  }
-  if (row.credential.configured) {
-    return { kind: 'configured' }
-  }
   if (!state.writable) {
     return {
       kind: 'unavailable',

+ 130 - 71
packages/client/ui-models/tests/components.client.spec.tsx

@@ -23,6 +23,8 @@ afterEach(cleanup)
 const t: ModelsSectionInjected['t'] = key => en[key]
 const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
 const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
+const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' }
+const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET)
 
 /** Open one row's capacity disclosure (1-based, as the labels read). */
 function expandRow(position: number): void {
@@ -181,8 +183,8 @@ function scriptedFace(overrides: {
 
 type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
 
-async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
-  const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
+async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
+  const { face, update, replace, mutate, set, unset } = scripted
   const controller = new ModelsSettingsStore(face as unknown as WireFace)
   await controller.load()
   const injected: ModelsSectionInjected = {
@@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
   return { view, face, update, replace, mutate, set, unset, controller }
 }
 
+async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  return mountFace(scriptedFace(overrides))
+}
+
+/**
+ * Mount for a user who cannot reach any provider yet: no credential is stored
+ * anywhere, so the whole-section DeepSeek route owns the first-run setup card.
+ */
+async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  const scripted = scriptedFace(overrides)
+  scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
+    Promise.resolve(ok({
+      credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
+    })))
+  return mountFace(scripted)
+}
+
+/**
+ * Mount and open the DeepSeek editor. The shared fixture already has a usable
+ * openai route, so DeepSeek is an ordinary row whose card opens through Edit
+ * rather than by itself.
+ */
+async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] = {}) {
+  const mounted = await mountSection(overrides)
+  fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+  return mounted
+}
+
 describe('ModelsSection', () => {
   it('renders nothing before the slot injects its dependencies', () => {
     const uninjected = {} as ModelsSectionProps
@@ -202,20 +232,32 @@ describe('ModelsSection', () => {
     expect(document.body.textContent).toBe('')
   })
 
-  it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
-    await mountSection()
-    // DeepSeek has no configured credential and no stored apiKey → setup card.
+  it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
+    await mountFirstRun()
+    // Nothing is reachable yet, and DeepSeek has no configured credential and
+    // no stored apiKey → setup card.
     expect(screen.getByText('DeepSeek')).toBeTruthy()
     expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
     expect(screen.getByText('openai')).toBeTruthy()
     expect(screen.queryByText('Active')).toBeNull()
     expect(screen.queryByText('Inactive')).toBeNull()
+    expect(screen.getByText(en.add)).toBeTruthy()
+  })
+
+  it('leaves the unkeyed provider a plain row once another provider is usable', async () => {
+    await mountSection()
+    // openai's key is stored, so the user is not blocked and nothing on the
+    // page opens itself over them.
+    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
     const configured = screen.getByRole('img', { name: en.credentialConfigured })
     expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
     expect(configured.className).toContain('credentialDotConfigured')
     expect(configured.closest('li')?.textContent).toContain('openai')
-    expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
-    expect(screen.getByText(en.add)).toBeTruthy()
+    const missing = screen.getByRole('img', { name: en.credentialMissing })
+    expect(missing.closest('li')?.textContent).toContain('DeepSeek')
+    // The card is still one click away.
+    fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+    expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
   })
 
   it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
@@ -241,7 +283,7 @@ describe('ModelsSection', () => {
   })
 
   it('turns the setup card into a row once the credential reports configured', async () => {
-    const { face } = await mountSection()
+    const { face } = await mountFirstRun()
     face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
       credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
     })))
@@ -259,7 +301,7 @@ describe('ModelsSection', () => {
     expect(screen.queryByLabelText(en.keyInput)).toBeNull()
   })
 
-  it('decides setup need from the joined credential state', () => {
+  it('decides setup need from the joined credential state and the first-run posture', () => {
     const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
     const row = (credential: ProviderRow['credential']): ProviderRow => ({
       entry,
@@ -268,10 +310,13 @@ describe('ModelsSection', () => {
       apiKeyEnv: 'X',
       credential,
     })
-    expect(needsSetup(row(undefined))).toBe(true)
-    expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
+    expect(needsSetup(row(undefined), false)).toBe(true)
+    expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false)
     const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
-    expect(needsSetup(nested)).toBe(false)
+    expect(needsSetup(nested, false)).toBe(false)
+    // A user who can already reach some provider is not in the first-run
+    // posture, so nothing on the page opens itself.
+    expect(needsSetup(row(undefined), true)).toBe(false)
   })
 
   it('derives conventional credential references from route ids', () => {
@@ -296,7 +341,7 @@ describe('ModelsSection', () => {
   })
 
   it('stores a typed key write-only from the setup card without touching settings', async () => {
-    const { set, update, face } = await mountSection()
+    const { set, update, face } = await mountFirstRun()
     const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
     fireEvent.change(key, { target: { value: '  sk-live  ' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -311,7 +356,7 @@ describe('ModelsSection', () => {
   })
 
   it('applies customized deepseek fields as path ops', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -332,7 +377,7 @@ describe('ModelsSection', () => {
   })
 
   it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -366,7 +411,7 @@ describe('ModelsSection', () => {
   })
 
   it('rejects duplicate DeepSeek model ids before writing', async () => {
-    const { mutate } = await mountSection()
+    const { mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.click(screen.getByText(en.addModel))
     const ids = screen.getAllByLabelText(new RegExp(en.modelId))
@@ -436,7 +481,7 @@ describe('ModelsSection', () => {
   })
 
   it('accepts a suffixed context window and stores the plain count', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -476,7 +521,7 @@ describe('ModelsSection', () => {
   })
 
   it('keeps unreadable context-window text on screen and refuses the write', async () => {
-    const { mutate } = await mountSection()
+    const { mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     expandRow(1)
     expandRow(2)
@@ -539,7 +584,7 @@ describe('ModelsSection', () => {
     // The regression: one active buffer meant editing a second row displaced
     // the first, which then fell back to rendering its stored NaN as `NaN` —
     // losing the text the user was told they could still correct.
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     expandRow(1)
     expandRow(2)
@@ -553,7 +598,7 @@ describe('ModelsSection', () => {
   })
 
   it('re-keys the typed text around a removed row', async () => {
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
     const removeRow = (at: number): void => {
@@ -587,7 +632,7 @@ describe('ModelsSection', () => {
     // The regression: reset removed the override but left the buffer, so an
     // inherited row displayed text no settings layer stores — and because an
     // unreadable buffer never settles, it stayed there indefinitely.
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -605,12 +650,12 @@ describe('ModelsSection', () => {
     // Reset put the draft back where it started, so Apply writes nothing at
     // all rather than persisting whatever the stale text had parsed to.
     fireEvent.click(screen.getByText(en.apply))
-    await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
+    await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
     expect(mutate).not.toHaveBeenCalled()
   })
 
   it('edits an output cap per model and carries its text across a removal', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -644,7 +689,7 @@ describe('ModelsSection', () => {
   })
 
   it('settles a pasted id and refuses whitespace that would never match', async () => {
-    await mountSection()
+    await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
     fireEvent.change(ids[0] as HTMLInputElement, { target: { value: '  deepseek-v4-flash  ' } })
@@ -681,7 +726,7 @@ describe('ModelsSection', () => {
   })
 
   it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
-    const { mutate } = await mountSection({
+    const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -715,7 +760,7 @@ describe('ModelsSection', () => {
 
   it('clears an inherited override with an unset op, never a whole-section replace', async () => {
     // A whole-section replace would clobber sibling overrides to clear one field.
-    const { replace, update, mutate } = await mountSection()
+    const { replace, update, mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
     expect(url.value).toBe('https://base')
@@ -762,7 +807,7 @@ describe('ModelsSection', () => {
   })
 
   it('rejects an invalid draft before writing', async () => {
-    const { update } = await mountSection()
+    const { update } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -772,19 +817,17 @@ describe('ModelsSection', () => {
 
   it('edits a pi-ai profile with the curated fields only', async () => {
     const { mutate } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
     // The configured credential shows as the stored placeholder.
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
     // pi-ai carries Base URL too: the stored override shows as the value and
     // the effective profile endpoint as its placeholder source.
-    fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
-    const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
-    expect(urls).toHaveLength(2)
-    expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
-    fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.customized))
+    const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(url.value).toBe('https://proxy')
+    fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
     // Only the edited field travels: apiKeyEnv and headers were already stored
     // with these values, so no op restates them.
@@ -803,14 +846,12 @@ describe('ModelsSection', () => {
     expect(pick.value).toBe('anthropic')
     // A dormant profile has no endpoint anywhere: the pi-ai placeholder
     // falls back to the provider-default wording.
-    fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
-    const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
-    expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    const addKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByText(en.customized))
+    expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
+    const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
     expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
     fireEvent.change(addKey, { target: { value: 'sk-ant' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
@@ -824,7 +865,7 @@ describe('ModelsSection', () => {
     const { mutate, set } = await mountSection()
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
@@ -855,9 +896,8 @@ describe('ModelsSection', () => {
     const { face, controller } = await mountSection({ mutate, set })
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
+    fireEvent.click(screen.getByText(en.apply))
     await screen.findByText('credential store unavailable')
     expect(mutate).toHaveBeenCalledOnce()
     face.settings.describe.mockResolvedValue(ok({
@@ -867,7 +907,7 @@ describe('ModelsSection', () => {
     }))
     await act(async () => { await controller.load() })
     expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
     expect(mutate).toHaveBeenCalledOnce()
     expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
@@ -883,10 +923,9 @@ describe('ModelsSection', () => {
     await waitFor(() => {
       expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
     })
-    // The hint-only card cannot apply anything.
-    const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
-    expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    // The hint-only card cannot apply anything, and offers no key field.
+    expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
   })
 
   it('surfaces a rejected settings write and never stores the key after it', async () => {
@@ -895,9 +934,8 @@ describe('ModelsSection', () => {
     })
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
+    fireEvent.click(screen.getByText(en.apply))
     await screen.findByText(/unknown pi-ai provider/)
     expect(set).not.toHaveBeenCalled()
   })
@@ -930,7 +968,7 @@ describe('ModelsSection', () => {
   it('tells the user to reopen when another writer moved the namespace first', async () => {
     // The stale-draft overwrite: two tabs open the same card, the other saves,
     // and this one must be refused rather than replay its opening snapshot.
-    const { set } = await mountSection({
+    const { set } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
     })
     fireEvent.click(screen.getByText(en.customized))
@@ -944,7 +982,7 @@ describe('ModelsSection', () => {
     // A transport failure (disconnect, or the 403 a non-loopback browser now
     // gets on the whole configuration plane) rejects rather than returning a
     // failed envelope: without a catch the card would stay busy forever.
-    await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
+    await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
     fireEvent.click(screen.getByText(en.customized))
     fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
     fireEvent.click(screen.getByText(en.apply))
@@ -954,7 +992,7 @@ describe('ModelsSection', () => {
   })
 
   it('surfaces a shadowed credential write on the card', async () => {
-    await mountSection({
+    await mountFirstRun({
       set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
     })
     const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
@@ -971,9 +1009,8 @@ describe('ModelsSection', () => {
         configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
       }])),
     })))
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
     expect(editorKey.disabled).toBe(true)
   })
@@ -981,12 +1018,11 @@ describe('ModelsSection', () => {
   it('keeps a failed credential describe silent and the input usable', async () => {
     const { face, set } = await mountSection()
     face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
-    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
+    const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
     expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
     fireEvent.change(editorKey, { target: { value: 'sk-live' } })
-    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
   })
 
@@ -1085,15 +1121,15 @@ describe('ModelsSection', () => {
 
   it('toggles the row editor closed on a second edit click and on cancel', async () => {
     const { update } = await mountSection()
-    const edit = screen.getAllByText(en.edit)[0] as HTMLElement
+    const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
     fireEvent.click(edit)
-    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
+    await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
     fireEvent.click(edit)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
     fireEvent.click(edit)
-    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
-    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
-    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
+    fireEvent.click(screen.getByText(en.cancel))
+    expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
     expect(update).not.toHaveBeenCalled()
   })
 
@@ -1101,11 +1137,34 @@ describe('ModelsSection', () => {
     await mountSection()
     fireEvent.click(screen.getByText(en.add))
     await screen.findByLabelText(en.provider)
-    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
+    fireEvent.click(screen.getByText(en.cancel))
     await screen.findByText(en.add)
     expect(screen.queryByLabelText(en.provider)).toBeNull()
   })
 
+  it('collapses the setup card on cancel without disturbing another open card', async () => {
+    // The regression: the setup card shared the row/add/declare close handler,
+    // so cancelling it discarded the add card's draft while staying open itself.
+    await mountFirstRun()
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    fireEvent.click(screen.getByText(en.add))
+    await screen.findByLabelText(en.provider)
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
+
+    // The setup card is the first one on the page, above the add block.
+    fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
+    // The add card kept its draft…
+    expect(screen.getByLabelText(en.provider)).toBeTruthy()
+    // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.getAllByRole('img', { name: en.credentialMissing })
+      .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
+    // Its card reopens through Edit, which closes the add card as any row does.
+    fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
+    expect(screen.queryByLabelText(en.provider)).toBeNull()
+  })
+
   it('loads on first render of an idle controller', async () => {
     const { face } = scriptedFace()
     const controller = new ModelsSettingsStore(face as unknown as WireFace)

+ 58 - 25
packages/client/ui-models/tests/readiness.client.spec.ts

@@ -1,8 +1,8 @@
-/** Pure official-DeepSeek readiness projection over the shared Models join. */
+/** Pure first-run readiness projection over the shared Models join. */
 import { describe, expect, it } from 'vitest'
 import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
 import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
-import { deepSeekReadiness } from '../src/client/store.ts'
+import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
 
 const missingCredential: CredentialView = { configured: false, writable: true }
 
@@ -23,6 +23,24 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
   }
 }
 
+/** A second provider the user configured themselves. */
+function otherRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
+  return {
+    entry: {
+      provider: 'hfai',
+      displayName: 'HFAI',
+      settingsNs: 'llm-pi-ai',
+      settingsPath: ['providers', 'hfai'],
+      active: true,
+    },
+    configured: true,
+    removable: true,
+    apiKeyEnv: 'HFAI_API_KEY',
+    credential: { configured: true, source: 'file', writable: true },
+    ...overrides,
+  }
+}
+
 function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
   return {
     status: 'ready',
@@ -35,12 +53,25 @@ function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsStat
   }
 }
 
-describe('deepSeekReadiness', () => {
+describe('providerUsable', () => {
+  it('requires a registered route and a stored key for every named reference', () => {
+    expect(providerUsable(otherRow())).toBe(true)
+    expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false)
+    expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false)
+    expect(providerUsable(otherRow({ credential: undefined }))).toBe(false)
+  })
+
+  it('treats a reference-free registered route as provider-native authentication', () => {
+    expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true)
+  })
+})
+
+describe('onboardingReadiness', () => {
   it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
-    expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
-    expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
-    expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
+    expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
+    expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
+    expect(onboardingReadiness(state({
       rows: [row({
         entry: {
           ...row().entry,
@@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => {
   })
 
   it('reports a missing writable effective credential', () => {
-    expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
+    expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' })
+  })
+
+  it('ends onboarding once any other registered provider can serve requests', () => {
+    expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' })
+    // A provider the user cannot reach yet leaves the prompt in place.
+    expect(onboardingReadiness(state({
+      rows: [row(), otherRow({ credential: missingCredential })],
+    }))).toEqual({ kind: 'credential-missing' })
   })
 
   it('accepts file and process-environment credentials without prompting', () => {
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
-    }))).toEqual({ kind: 'configured' })
-    expect(deepSeekReadiness(state({
+    }))).toEqual({ kind: 'provider-ready' })
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
-    }))).toEqual({ kind: 'configured' })
+    }))).toEqual({ kind: 'provider-ready' })
   })
 
-  it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
-    expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
+  it('turns missing capabilities into diagnostics that never block the product', () => {
+    expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
       kind: 'unavailable',
       reason: 'load-failed',
     })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ entry: { ...row().entry, active: false } })],
     }))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
-    expect(deepSeekReadiness(state({
-      rows: [row({ configured: false })],
-    }))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
-    expect(deepSeekReadiness(state({
-      rows: [row({ apiKeyEnv: undefined })],
-    }))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       credentialError: 'credentials service is absent',
     }))).toEqual({
       kind: 'unavailable',
       reason: 'credentials-unavailable',
     })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: undefined })],
     }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
-    expect(deepSeekReadiness(state({
+    expect(onboardingReadiness(state({
       rows: [row({ credential: { configured: false, writable: false } })],
     }))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
-    expect(deepSeekReadiness(state({ writable: false }))).toEqual({
+    expect(onboardingReadiness(state({ writable: false }))).toEqual({
       kind: 'unavailable',
       reason: 'settings-read-only',
     })

+ 2 - 2
packages/client/ui-workflow-run/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-workflow-run/README.md
-README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
-README.zh.md: 881e67c707f86cd988dd160bb91d71fb70bd2e67
+README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd
+README.zh.md: e4fffd3f627bb2742d28db961e0f2870b13f0b59

+ 1 - 1
packages/client/ui-workflow-run/README.md

@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
 
 ## Presentation and navigation
 
-The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` 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. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
+The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` 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.
 
 A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
 

+ 1 - 1
packages/client/ui-workflow-run/README.zh.md

@@ -12,7 +12,7 @@
 
 ## 展示与导航
 
-运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
+运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
 
 只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
 

+ 0 - 2
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css

@@ -14,7 +14,6 @@
   padding: 0 8px;
   border-radius: 8px;
   background: var(--dsw-alias-bg-module-platform);
-  cursor: pointer;
 }
 
 .runHeader:focus-visible {
@@ -78,7 +77,6 @@
   width: 100%;
   min-width: 0;
   height: 32px;
-  cursor: pointer;
 }
 
 .phaseHeader:focus-visible {

+ 53 - 26
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx

@@ -1,6 +1,7 @@
-import { useState } from 'react'
+import { useState, type ReactNode } from 'react'
 import {
-  DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState,
+  DisclosureRow, IconChevronRightOutline14, StateDot,
+  type DisclosureRowProps, type StateDotState,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
@@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
   return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
 }
 
+function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
+  return phase.members.some(member => member.status !== 'completed')
+}
+
+type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
+
+/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
+const forcedOpenToggle = (): void => {}
+
+function ManualDisclosure(props: StatusDisclosureProps) {
+  const [open, setOpen] = useState(false)
+  return (
+    <DisclosureRow
+      {...props}
+      open={open}
+      expandable
+      onToggle={() => { setOpen(value => !value) }}
+    />
+  )
+}
+
+function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
+  /** Remount a clean Phase when its append-only member count changes between batched renders. */
+  readonly cleanCycleKey?: number | undefined
+  readonly requiresExpansion: boolean
+}) {
+  if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
+  return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
+}
+
 function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
   const counts = new Map<WorkflowRunStatus, number>()
   for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
@@ -97,21 +128,19 @@ function navigableMembers(
   return result
 }
 
-function RunHeader({ count, name, onToggle, open, status, t }: {
+function RunHeader({ children, count, name, requiresExpansion, status, t }: {
+  readonly children: ReactNode
   readonly count: number
   readonly name: string
-  readonly onToggle: () => void
-  readonly open: boolean
+  readonly requiresExpansion: boolean
   readonly status: WorkflowRunStatus
   readonly t: WorkflowRunPanelProps['t']
 }) {
   return (
-    <DisclosureRow
+    <StatusDisclosure
       icon={<IconChevronRightOutline14 />}
       title={t('run.title', { name })}
-      open={open}
-      expandable
-      onToggle={onToggle}
+      requiresExpansion={requiresExpansion}
       expandOnRowClick
       previewChevron={false}
       keepContentWhenOpen
@@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: {
           </span>
         </>
       )}
-    />
+    >
+      {children}
+    </StatusDisclosure>
   )
 }
 
@@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: {
   readonly openSession: WorkflowRunInjected['openSession']
   readonly t: WorkflowRunPanelProps['t']
 }) {
-  const [open, setOpen] = useState(false)
-  const toggle = (): void => { setOpen(value => !value) }
   return (
-    <DisclosureRow
+    <StatusDisclosure
       icon={<IconChevronRightOutline14 />}
       title={readablePhase(phase.phase, t)}
-      open={open}
-      expandable
-      onToggle={toggle}
+      cleanCycleKey={phase.members.length}
+      requiresExpansion={phaseRequiresExpansion(phase)}
       expandOnRowClick
       previewChevron={false}
       keepContentWhenOpen
@@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: {
           />
         ))}
       </div>
-    </DisclosureRow>
+    </StatusDisclosure>
   )
 }
 
-/** Render one durable workflow run with independent run and phase disclosure. */
+/** Render one durable workflow run with status-driven run and phase disclosure. */
 export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
-  const [open, setOpen] = useState(() => node.data.status === 'running')
-  const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
+  const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
+  const requiresExpansion = node.data.status !== 'completed'
+    || node.data.phases.some(phaseRequiresExpansion)
   const navigable = useSessions(
     sessions => navigableMembers(sessions, node.data.phases, sessionId),
     shallowEqual,
@@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
   return (
     <section className={css.root} data-workflow-run data-run-status={node.data.status}>
       <RunHeader
-        count={memberCount}
+        count={totalMembers}
         name={node.data.name}
-        open={open}
+        requiresExpansion={requiresExpansion}
         status={node.data.status}
         t={t}
-        onToggle={() => { setOpen(value => !value) }}
-      />
-      {open && (
+      >
         <div className={css.phaseList}>
           {node.data.phases.length === 0
             ? <span className={css.empty}>{t('run.empty')}</span>
@@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
               />
             ))}
         </div>
-      )}
+      </RunHeader>
     </section>
   )
 }

+ 144 - 78
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
 }
 
 describe('WorkflowRunPanel', () => {
-  it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
+  it('forces running run and phase content open without false disclosure controls', () => {
+    const view = render(<WorkflowRunPanel {...panelProps({
+      name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
+    })} />)
+    expect(screen.getByText('worker')).toBeTruthy()
+    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
+    expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
+    const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
+    expect(rows).toHaveLength(2)
+    for (const row of rows) {
+      expect(row.getAttribute('role')).toBeNull()
+      expect(row.getAttribute('tabindex')).toBeNull()
+      expect(row.getAttribute('aria-expanded')).toBeNull()
+      expect(row.getAttribute('data-expandable')).toBeNull()
+    }
+  })
+
+  it('folds each clean transition once and preserves review choices until activity returns', () => {
     const running: WorkflowRunChatData = {
       name: 'audit', status: 'running', phases: [phase()],
     }
     const view = render(<WorkflowRunPanel {...panelProps(running)} />)
-    expect(screen.getByText('未分阶段')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
-    expect(screen.queryByText('未分阶段')).toBeNull()
-
-    const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
-    view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
+    const phaseCompleted: WorkflowRunChatData = {
+      ...running,
+      phases: [phase({
+        members: [{
+          seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed',
+        }],
+      })],
+    }
+    view.rerender(<WorkflowRunPanel {...panelProps(phaseCompleted)} />)
+    const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
+    expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText('done')).toBeNull()
+    fireEvent.click(phaseHeader)
+    expect(screen.getByText('done')).toBeTruthy()
+
+    const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
+    view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
+    const runHeader = screen.getByRole('button', { name: /^audit/ })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
     expect(screen.queryByText('未分阶段')).toBeNull()
+    fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(runHeader, { key: 'Enter' })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
+    fireEvent.keyDown(completedPhase, { key: 'Enter' })
+    expect(screen.getByText('done')).toBeTruthy()
+    fireEvent.keyDown(runHeader, { key: ' ' })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(runHeader, { key: ' ' })
+    expect(runHeader.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    expect(screen.getByText('done')).toBeTruthy()
 
-    cleanup()
-    render(<WorkflowRunPanel {...panelProps(terminal)} />)
+    const cleanUpdate: WorkflowRunChatData = {
+      ...completed,
+      phases: [phase({
+        members: [{
+          seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
+        }],
+      })],
+    }
+    view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
+    expect(screen.getByText('reviewed')).toBeTruthy()
+
+    view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
+    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
+    expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
+    expect(screen.getByText('worker')).toBeTruthy()
+    view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
+    expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
     expect(screen.queryByText('未分阶段')).toBeNull()
   })
 
-  it('supports root keyboard disclosure and renders a zero-member running state', () => {
-    render(<WorkflowRunPanel {...panelProps({
-      name: 'keyboard', status: 'running',
-      phases: [phase({ key: 'research', phase: 'Research' })],
+  it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
+    const firstMember = {
+      seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const,
+    }
+    const phaseClean: WorkflowRunChatData = {
+      name: 'phase-cycle', status: 'running',
+      phases: [phase({ members: [firstMember] })],
+    }
+    const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
+    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
+    expect(screen.getByText('first')).toBeTruthy()
+    phaseView.rerender(<WorkflowRunPanel {...panelProps({
+      ...phaseClean,
+      phases: [phase({ members: [firstMember, {
+        seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
+      }] })],
     })} />)
-    const header = screen.getByRole('button', { name: /^keyboard/ })
-    expect(header.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.keyDown(header, { key: 'ArrowDown' })
-    expect(header.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.keyDown(header, { key: 'Enter' })
-    expect(header.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.keyDown(header, { key: ' ' })
-    expect(header.getAttribute('aria-expanded')).toBe('true')
-    expect(screen.getByText('Research')).toBeTruthy()
-    expect(screen.getByText('运行中 1')).toBeTruthy()
-    const phaseHeader = screen.getByRole('button', { name: /Research/ })
-    fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
-    expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.keyDown(phaseHeader, { key: 'Enter' })
-    expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.keyDown(phaseHeader, { key: ' ' })
-    expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText('first')).toBeNull()
+    expect(screen.queryByText('second')).toBeNull()
+  })
 
-    cleanup()
-    render(<WorkflowRunPanel {...panelProps({
-      name: 'empty', status: 'running', phases: [],
-    })} />)
+  it('derives the zero-member running and completed states from the current run status', () => {
+    const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
+    const view = render(<WorkflowRunPanel {...panelProps(running)} />)
+    expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
+    expect(screen.getByText('没有启动成员')).toBeTruthy()
+    view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
+    const header = screen.getByRole('button', { name: /^empty/ })
+    expect(header.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText('没有启动成员')).toBeNull()
+    fireEvent.click(header)
     expect(screen.getByText('没有启动成员')).toBeTruthy()
   })
 
-  it('keeps phase disclosure independent and preserves empty versus absent names', () => {
+  it.each(['failed', 'cancelled', 'interrupted'] as const)(
+    'bubbles a %s member to the run and keeps a matching run outcome open',
+    (status) => {
+      const memberView = render(<WorkflowRunPanel {...panelProps({
+        name: 'member-outcome', status: 'completed',
+        phases: [phase({
+          members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
+        })],
+      })} />)
+      expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
+      expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
+      expect(screen.getByText(status)).toBeTruthy()
+      memberView.unmount()
+
+      render(<WorkflowRunPanel {...panelProps({
+        name: 'run-outcome', status,
+        phases: [phase({
+          members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
+        })],
+      })} />)
+      expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
+      expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
+      expect(screen.queryByText('done')).toBeNull()
+    },
+  )
+
+  it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
     render(<WorkflowRunPanel {...panelProps({
-      name: 'audit', status: 'running',
+      name: 'audit', status: 'completed',
       phases: [
         phase({ key: 'value:0:', phase: '', members: [{
-          seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
+          seq: 1, label: '', childId: 'child-1' as SessionId, status: 'completed',
         }] }),
         phase({ key: 'missing', phase: null, members: [{
           seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
         }] }),
       ],
     })} />)
-    fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
+    expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
+    const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
+    expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
+    expect(screen.queryByText('空成员名')).toBeNull()
+    expect(screen.getByText('second')).toBeTruthy()
+    fireEvent.click(cleanPhase)
     expect(screen.getByText('空成员名')).toBeTruthy()
-    expect(screen.queryByText('second')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
     expect(screen.getByText('second')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
+    fireEvent.click(cleanPhase)
     expect(screen.queryByText('空成员名')).toBeNull()
     expect(screen.getByText('second')).toBeTruthy()
   })
 
-  it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
-    const completed: WorkflowRunChatData = {
-      name: 'repo-audit', status: 'completed',
-      phases: [phase({
-        members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
-      })],
-    }
-    const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
-    const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
-    expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.click(completedHeader)
-    expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
-    completedView.unmount()
-
+  it('renders mixed and interrupted aggregate status while attention stays visible', () => {
     const mixed: WorkflowRunChatData = {
       name: 'repo-audit', status: 'failed',
       phases: [phase({
@@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => {
       })],
     }
     const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
-    fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
-    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
     expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
     expect([...mixedView.container.querySelectorAll('[data-member-status]')]
       .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
@@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => {
     expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
     mixedView.unmount()
 
-    const interrupted: WorkflowRunChatData = {
+    const interruptedView = render(<WorkflowRunPanel {...panelProps({
       name: 'repo-audit', status: 'interrupted',
-      phases: [
-        phase({
-          members: [
-            { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
-            { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
-          ],
-        }),
-        phase({
-          key: 'interrupted-only', phase: 'Interrupted only',
-          members: [{
-            seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
-          }],
-        }),
-      ],
-    }
-    const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
-    fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
+      phases: [phase({
+        members: [
+          { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
+          { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
+        ],
+      })],
+    })} />)
     expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
     expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
-    expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
+    expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
   })
 
   it('opens only a running ordinary-list subagent proven to have this parent', () => {
@@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => {
     }
     const openSession = vi.fn()
     render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
-    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
     fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
     expect(openSession).toHaveBeenCalledWith('child-1')
   })
@@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => {
       })],
     }
     render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
-    fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
     expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
     cleanup()
   })

+ 77 - 1
scripts/project-doc-site.spec.ts

@@ -5,7 +5,7 @@ import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSyn
 import { tmpdir } from 'node:os'
 import { basename, join, resolve } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
-import { docsPages, type DocsPage } from '../website/docs.ts'
+import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
 import {
   addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown,
 } from './project-doc-site.ts'
@@ -364,6 +364,63 @@ describe('docsPages locale routes', () => {
   })
 })
 
+describe('sidebar ordering', () => {
+  it('places every section a sidebar collection owns', () => {
+    for (const page of docsPages) {
+      if (page.sidebar === null) continue
+      expect(() => sectionSpec(page.locale, page.section), page.route).not.toThrow()
+    }
+  })
+
+  it('refuses a section with no declared placement', () => {
+    expect(() => sectionSpec('root', '数据结构'))
+      .toThrow('Sidebar section "数据结构" has no placement in the root locale.')
+  })
+
+  it('declares placements per locale rather than in one shared list', () => {
+    // `SDK` labels a group in both locales, so one shared list would have to
+    // rank it against `入门` and against `Guide` at the same position.
+    expect(sectionSpec('root', 'SDK').index).toBeGreaterThan(sectionSpec('root', '入门').index)
+    expect(sectionSpec('en', 'SDK').index).toBeGreaterThan(sectionSpec('en', 'Guide').index)
+    expect(() => sectionSpec('en', '入门')).toThrow()
+    expect(() => sectionSpec('root', 'Guide')).toThrow()
+  })
+
+  it('lands every navigation item on a page the manifest publishes', () => {
+    // The navigation bar named `/guide/` while the manifest published the guide's
+    // first page at `guide/quickstart.md`, so the item served a 404.
+    const collections = [
+      ['root', 'zh-guide'], ['root', 'zh-develop'], ['root', 'zh-reference'],
+      ['en', 'en-guide'], ['en', 'en-develop'], ['en', 'en-reference'],
+    ] as const
+    const published = new Set(docsPages.map(page => routeLink(page.route)))
+    for (const [locale, collection] of collections) {
+      expect(published, `${locale}/${collection}`).toContain(landingLink(locale, collection))
+    }
+  })
+
+  it('collapses the subsystem groups and leaves the smaller ones open', () => {
+    expect(sectionSpec('root', '执行与工具').collapsed).toBe(true)
+    expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true)
+    expect(sectionSpec('root', '概念').collapsed).toBeUndefined()
+  })
+
+  it('gives each page its own position within a section', () => {
+    // Sidebar entries sort by order alone, so a shared value leaves the two
+    // pages ranked by whichever manifest block happens to be concatenated
+    // first rather than by an intent the manifest states.
+    const taken = new Map<string, string>()
+    const collisions: string[] = []
+    for (const page of docsPages) {
+      const slot = `${page.locale}/${String(page.sidebar)}/${page.section}#${page.order}`
+      const holder = taken.get(slot)
+      if (holder === undefined) taken.set(slot, page.label)
+      else collisions.push(`${slot}: ${holder} / ${page.label}`)
+    }
+    expect(collisions).toEqual([])
+  })
+})
+
 describe('addProjectionFrontmatter', () => {
   it('adds frontmatter to an ordinary Markdown page', () => {
     expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
@@ -411,6 +468,25 @@ describe('projectedPageContent', () => {
     expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
   })
 
+  it('drops the language switcher the navigation bar already offers', () => {
+    expect(projectedPageContent('# Guide\n\nEnglish | [中文](./en/guide)\n\nBody.\n', page('zh-guide')))
+      .toBe('# Guide\n\nBody.\n')
+    expect(projectedPageContent('# 指南\n\n[English](./en/guide) | 中文\n\n正文。\n', page('zh-guide')))
+      .toBe('# 指南\n\n正文。\n')
+  })
+
+  it('drops the repository badge every page links from its footer', () => {
+    const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)'
+    expect(projectedPageContent(`# Guide\n\nBody.\n\n${badge}\n`, page('zh-guide')))
+      .toBe('# Guide\n\nBody.\n')
+  })
+
+  it('keeps a switcher-shaped line that is not the page header', () => {
+    // A tutorial showing the convention must still render the example.
+    const sample = '# Guide\n\nA\n\nB\n\nC\n\nD\n\nE\n\nEnglish | [中文](./x)\n'
+    expect(projectedPageContent(sample, page('zh-guide'))).toBe(sample)
+  })
+
   it('rejects a locale home source without frontmatter', () => {
     expect(() => projectedPageContent('# Harness\n', page(null)))
       .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')

+ 32 - 1
scripts/project-doc-site.ts

@@ -292,6 +292,37 @@ export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage,
   return `---\n${fields}\n---\n\n${markdown}`
 }
 
+/** The switcher line a canonical page carries so its GitHub reader can reach the other language. */
+const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\([^)]*\)|\[English\]\([^)]*\) \| 中文)$/
+
+/** The repository badge a canonical page carries for its GitHub reader. */
+const REPOSITORY_BADGE = /^\[!\[[^\]]*\]\(https:\/\/img\.shields\.io\/[^)]*\)\]\([^)]*\)$/
+
+/**
+ * Drop the lines that address a canonical page's GitHub reader.
+ *
+ * The site carries a locale switcher in its navigation bar and links the
+ * repository from every page, so projecting these lines would repeat both — the
+ * switcher as the first element under each heading.
+ *
+ * @param markdown Rewritten canonical Markdown content.
+ * @returns The content without the switcher line or the repository badge.
+ */
+function withoutRepositoryChrome(markdown: string): string {
+  const lines = markdown.split('\n')
+  const switcher = lines.findIndex(line => LANGUAGE_SWITCHER.test(line))
+  // Only the switcher introducing the page qualifies; further down the same
+  // text is prose or a sample rather than the page's own header.
+  if (switcher !== -1 && switcher < 8) {
+    lines.splice(switcher, lines[switcher + 1] === '' ? 2 : 1)
+  }
+  const badge = lines.findLastIndex(line => REPOSITORY_BADGE.test(line))
+  if (badge !== -1) {
+    lines.splice(lines[badge - 1] === '' ? badge - 1 : badge, lines[badge - 1] === '' ? 2 : 1)
+  }
+  return lines.join('\n')
+}
+
 /**
  * Select the Markdown rendered for one published page.
  *
@@ -300,7 +331,7 @@ export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage,
  * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
  */
 export function projectedPageContent(markdown: string, page: DocsPage): string {
-  if (page.sidebar !== null) return markdown
+  if (page.sidebar !== null) return withoutRepositoryChrome(markdown)
   if (!markdown.startsWith('---\n')) {
     throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
   }

+ 1 - 0
tsconfig.host.json

@@ -29,6 +29,7 @@
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/onboarding-deepseek-config.e2e.ts",
+    "apps/web/tests/onboarding-usable-provider.e2e.ts",
     "apps/web/tests/remote-welcome.e2e.ts",
     "apps/web/tests/workspace-management.e2e.ts",
     "apps/web/tests/replay-round-trip.e2e.ts",

+ 129 - 50
website/.vitepress/config.ts

@@ -1,52 +1,34 @@
 /** VitePress configuration for the locally projected documentation site. */
 
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
 import type { DefaultTheme, PageData } from 'vitepress'
 import type { ViteDevServer } from 'vite'
 import { withMermaid } from 'vitepress-plugin-mermaid'
-import { docsPages, type DocsPage } from '../docs.ts'
+import { landingLink, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage } from '../docs.ts'
 import { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts'
 
 projectDocs()
 
-const sectionOrder = [
-  '入门',
-  '基础',
-  '框架能力',
-  '实战',
-  'Cordis 教程',
-  '概念',
-  '生成参考',
-  'Cordis API',
-  '数据结构',
-  '开发手册',
-  'Guide',
-  'Basics',
-  'Framework',
-  'Practice',
-  'Cordis tutorial',
-  'Concepts',
-  'Generated reference',
-  'Cordis Core API',
-  'Data structures',
-  'Cookbook',
-]
-
-function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] {
-  const pages = docsPages.filter(page => page.sidebar === collection)
-  const sections = new Map<string, DocsPage[]>()
-  for (const page of pages) {
-    const entries = sections.get(page.section) ?? []
+function sidebar(locale: DocsLocale, collection: NonNullable<DocsPage['sidebar']>): DefaultTheme.SidebarItem[] {
+  // `orderedPages` already sorts by section placement, so insertion order
+  // carries the group order and each group keeps its pages in sequence.
+  const groups = new Map<string, DocsPage[]>()
+  for (const page of orderedPages(locale, collection)) {
+    const entries = groups.get(page.section) ?? []
     entries.push(page)
-    sections.set(page.section, entries)
+    groups.set(page.section, entries)
   }
-  return [...sections.entries()]
-    .sort(([left], [right]) => sectionOrder.indexOf(left) - sectionOrder.indexOf(right))
-    .map(([text, entries]) => ({
+  return [...groups.entries()].map(([text, entries]) => {
+    const { collapsed } = sectionSpec(locale, text)
+    return {
       text,
-      items: entries
-        .sort((left, right) => left.order - right.order)
-        .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })),
-    }))
+      // A present `collapsed` is what makes the default theme render the
+      // group as collapsible at all, so an open group must omit the key.
+      ...(collapsed === undefined ? {} : { collapsed }),
+      items: entries.map(page => ({ text: page.label, link: routeLink(page.route) })),
+    }
+  })
 }
 
 function watchCanonicalDocs(server: ViteDevServer): void {
@@ -107,10 +89,102 @@ const sharedTheme: Pick<DefaultTheme.Config, 'search' | 'socialLinks' | 'editLin
   },
 }
 
+/** Site base path, carrying the leading and trailing slashes VitePress requires. */
+const base = process.env.DOCS_BASE ?? '/'
+
+/**
+ * The DeepSeek wordmark, inlined so its `currentColor` fills follow the active
+ * theme. An `<img>` would freeze the mark at the colors the file declares.
+ */
+const wordmark = readFileSync(resolve(import.meta.dirname, '../public/wordmark.svg'), 'utf8')
+  .trim()
+  .replace('<svg ', '<svg class="dsh-wordmark" ')
+
+/**
+ * Styles the default theme does not provide, carried inline because the site
+ * runs the stock theme with no theme directory of its own.
+ *
+ * The navigation-bar lockup pairs with `siteTitle`. The scrollbar rules replace
+ * the sidebar's platform bar, which reserves 15px of a 265px column and draws a
+ * track the rest of the navigation has no border for; `scrollbarScript` supplies
+ * the marker that reveals the thumb. Chrome drops `::-webkit-scrollbar` once
+ * `scrollbar-width` is set to anything but `auto`, so the standard properties
+ * stay behind a query only Firefox answers.
+ */
+const siteStyle = `
+.dsh-lockup { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
+.dsh-wordmark { display: block; height: 22px; width: auto; color: var(--vp-c-text-1); }
+.dsh-tag {
+  display: inline-flex;
+  align-items: center;
+  border: 1px solid var(--vp-c-brand-soft);
+  border-radius: 999px;
+  padding: 1px 9px;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 18px;
+  white-space: nowrap;
+  color: var(--vp-c-brand-1);
+}
+
+.VPSidebar::-webkit-scrollbar { width: 6px; }
+.VPSidebar::-webkit-scrollbar-track { background: transparent; }
+.VPSidebar::-webkit-scrollbar-thumb {
+  background-color: transparent;
+  border-radius: 3px;
+  transition: background-color 0.3s;
+}
+.VPSidebar[data-scrolling]::-webkit-scrollbar-thumb { background-color: var(--vp-c-text-3); }
+@supports not selector(::-webkit-scrollbar) {
+  .VPSidebar { scrollbar-width: thin; scrollbar-color: transparent transparent; }
+  .VPSidebar[data-scrolling] { scrollbar-color: var(--vp-c-text-3) transparent; }
+}
+`
+
+/**
+ * Mark the sidebar while it scrolls, so its scrollbar rests invisible.
+ *
+ * A sized `::-webkit-scrollbar` opts the element out of the platform's
+ * self-hiding overlay bar, leaving one painted at all times; nothing in CSS
+ * reports that an element is scrolling. The listener captures instead of
+ * bubbling because scroll events do not bubble, and marks a `data-` attribute
+ * rather than a class because Vue rewrites `class` wholesale when it patches
+ * the element.
+ */
+const scrollbarScript = `
+(() => {
+  let idle
+  addEventListener('scroll', (event) => {
+    const target = event.target
+    if (!(target instanceof Element) || !target.classList.contains('VPSidebar')) return
+    target.dataset.scrolling = ''
+    clearTimeout(idle)
+    idle = setTimeout(() => delete target.dataset.scrolling, 800)
+  }, true)
+})()
+`
+
+/**
+ * Navigation-bar title: the DeepSeek wordmark and the release-stage tag.
+ * VitePress renders `siteTitle` as HTML.
+ *
+ * @param previewTag - Localized release-stage label.
+ * @returns Markup placed beside the navigation-bar home link.
+ */
+function siteTitle(previewTag: string): string {
+  return `<span class="dsh-lockup">${wordmark}<span class="dsh-tag">${previewTag}</span></span>`
+}
+
 export default withMermaid({
   title: 'DeepSeek Harness',
   description: '用于构建 Agent Harness 的插件化 SDK',
-  base: process.env.DOCS_BASE ?? '/',
+  base,
+  head: [
+    // VitePress leaves head hrefs untouched, so the base belongs here explicitly.
+    ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}favicon.svg` }],
+    ['style', {}, siteStyle],
+    ['script', {}, scrollbarScript],
+  ],
   cleanUrls: true,
   srcDir: '.generated',
   cacheDir: '.cache',
@@ -120,15 +194,16 @@ export default withMermaid({
       label: '简体中文',
       lang: 'zh-CN',
       themeConfig: {
+        siteTitle: siteTitle('技术预览'),
         nav: [
-          { text: '入门', link: '/guide/', activeMatch: '^/guide/' },
-          { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' },
-          { text: '参考', link: '/reference/', activeMatch: '^/reference/' },
+          { text: '入门', link: landingLink('root', 'zh-guide'), activeMatch: '^/guide/' },
+          { text: '开发', link: landingLink('root', 'zh-develop'), activeMatch: '^/develop/' },
+          { text: '参考', link: landingLink('root', 'zh-reference'), activeMatch: '^/reference/' },
         ],
         sidebar: {
-          '/guide/': sidebar('zh-guide'),
-          '/develop/': sidebar('zh-develop'),
-          '/reference/': sidebar('zh-reference'),
+          '/guide/': sidebar('root', 'zh-guide'),
+          '/develop/': sidebar('root', 'zh-develop'),
+          '/reference/': sidebar('root', 'zh-reference'),
         },
         outline: { label: '本页目录' },
         docFooter: { prev: '上一篇', next: '下一篇' },
@@ -146,15 +221,16 @@ export default withMermaid({
       lang: 'en-US',
       link: '/en/',
       themeConfig: {
+        siteTitle: siteTitle('Preview'),
         nav: [
-          { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' },
-          { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' },
-          { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' },
+          { text: 'Guide', link: landingLink('en', 'en-guide'), activeMatch: '^/en/guide/' },
+          { text: 'Develop', link: landingLink('en', 'en-develop'), activeMatch: '^/en/develop/' },
+          { text: 'Reference', link: landingLink('en', 'en-reference'), activeMatch: '^/en/reference/' },
         ],
         sidebar: {
-          '/en/guide/': sidebar('en-guide'),
-          '/en/develop/': sidebar('en-develop'),
-          '/en/reference/': sidebar('en-reference'),
+          '/en/guide/': sidebar('en', 'en-guide'),
+          '/en/develop/': sidebar('en', 'en-develop'),
+          '/en/reference/': sidebar('en', 'en-reference'),
         },
         editLink: {
           pattern: ({ frontmatter }: PageData) => {
@@ -171,6 +247,9 @@ export default withMermaid({
     },
   },
   vite: {
+    // `srcDir` puts the Vite root inside the disposable generated tree, whose
+    // own `public/` no tracked asset can live in.
+    publicDir: resolve(import.meta.dirname, '../public'),
     plugins: [
       {
         name: 'deepseek-harness-doc-projector',

+ 186 - 68
website/docs.ts

@@ -11,7 +11,7 @@
 export type DocsLocale = 'root' | 'en'
 
 /** Sidebar collection rendered for one locale and top-level module. */
-type DocsSidebar =
+export type DocsSidebar =
   | 'zh-guide'
   | 'zh-develop'
   | 'zh-reference'
@@ -133,9 +133,9 @@ const homeAndGuide = pairedPages([
   {
     source: 'docs/user/guide/python-sdk.md',
     route: 'guide/python-sdk.md',
-    label: { root: 'Python SDK', en: 'Python SDK' },
+    label: { root: 'Python', en: 'Python' },
     sidebar: { root: 'zh-guide', en: 'en-guide' },
-    section: { root: '其他接口', en: 'Other interfaces' },
+    section: { root: 'SDK', en: 'SDK' },
     order: 1,
   },
 ])
@@ -144,7 +144,7 @@ const develop = pairedPages([
   {
     source: 'docs/user/develop/basic/index.md',
     route: 'develop/basic/index.md',
-    label: { root: '第一个插件', en: 'First plugin' },
+    label: { root: '第一个 Harness 插件', en: 'Your first Harness plugin' },
     sidebar: { root: 'zh-develop', en: 'en-develop' },
     section: { root: '基础', en: 'Basics' },
     order: 1,
@@ -219,7 +219,7 @@ const develop = pairedPages([
 ])
 
 const cordisTutorial = pairedPages(([
-  ['index.md', 'Cordis 教程', 'Cordis tutorial'],
+  ['index.md', '总览', 'Overview'],
   ['01-first-plugin.md', '1. 第一个插件', '1. Your first plugin'],
   ['02-lifecycle-and-effects.md', '2. 生命周期与副作用', '2. Lifecycle and effects'],
   ['03-services.md', '3. 服务', '3. Services'],
@@ -232,7 +232,7 @@ const cordisTutorial = pairedPages(([
   route: `develop/cordis-tutorial/${file}`,
   label: { root: rootLabel, en: enLabel },
   sidebar: { root: 'zh-develop', en: 'en-develop' },
-  section: { root: 'Cordis 教程', en: 'Cordis tutorial' },
+  section: { root: 'Cordis 框架教程', en: 'Cordis framework tutorial' },
   order,
   ...(file === 'index.md' ? { sourceAliases: ['docs/cordis-tutorial'] } : {}),
 })))
@@ -248,55 +248,84 @@ const cordisPrimerReference = pairedPages([
   },
 ])
 
-const subsystemsReference = pairedPages(([
-  ['README.md', '子系统', 'Subsystems', 0],
-  ['core.md', '核心', 'Core', 1],
-  ['scope.md', '作用域', 'Scopes', 2],
-  ['typert.md', 'TypeRT', 'TypeRT', 39],
-  ['session.md', '会话', 'Sessions', 3],
-  ['session-query.md', '会话查询', 'Session query', 4],
-  ['session-reference.md', '会话引用', 'Session references', 5],
-  ['session-title.md', '会话标题', 'Session titles', 6],
-  ['settings.md', '用户设置', 'User settings', 7],
-  ['credentials.md', '用户凭据', 'User credentials', 8],
-  ['system-prompt.md', '系统提示词', 'System prompts', 9],
-  ['tools.md', '工具', 'Tools', 10],
-  ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming', 11],
-  ['token-meter.md', 'Token 计量', 'Token metering', 12],
-  ['bash.md', 'Bash 执行', 'Bash execution', 13],
-  ['subprocess.md', '子进程', 'Subprocesses', 14],
-  ['tasks.md', '后台任务', 'Background tasks', 15],
-  ['filesystem.md', '文件系统', 'Filesystem', 16],
-  ['lsp.md', 'LSP 导航', 'LSP navigation', 17],
-  ['code-runtime.md', '代码运行时', 'Code runtime', 18],
-  ['compaction.md', '上下文压缩', 'Compaction', 19],
-  ['subagent.md', '子代理', 'Subagents', 20],
-  ['workflow.md', '工作流', 'Workflows', 21],
-  ['skills.md', '技能', 'Skills', 22],
-  ['approval.md', '审批', 'Approvals', 23],
-  ['permission.md', '权限预设', 'Permission presets', 24],
-  ['plan.md', '计划模式', 'Plan mode', 25],
-  ['user-interaction.md', '用户交互', 'User interaction', 26],
-  ['sandbox.md', '沙箱', 'Sandboxing', 27],
-  ['web.md', 'Web 访问', 'Web access', 28],
-  ['spill.md', 'Spill 存储', 'Spill storage', 29],
-  ['persistence.md', '会话持久化', 'Session persistence', 30],
-  ['storage.md', '存储', 'Storage', 31],
-  ['workspace.md', '工作区', 'Workspaces', 32],
-  ['http-server.md', 'HTTP 服务器', 'HTTP server', 33],
-  ['client-modules.md', '客户端模块', 'Client modules', 34],
-  ['invariants.md', '运行时不变式', 'Runtime invariants', 36],
-  ['session-projection.md', '会话投影', 'Session projections', 37],
-  ['telemetry.md', '遥测', 'Telemetry', 38],
-] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({
-  source: `docs/subsystems/${file}`,
-  route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`,
-  label: { root: rootLabel, en: enLabel },
-  sidebar: { root: 'zh-reference', en: 'en-reference' },
-  section: { root: '子系统', en: 'Subsystems' },
-  order,
-  ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}),
-})))
+/**
+ * Subsystem pages grouped by the concern they document, as `[Chinese section,
+ * English section, pages]`. One flat list of every subsystem pushed the rest of
+ * the reference sidebar below the fold.
+ */
+const subsystemGroups = [
+  ['总览', 'Overview', [
+    ['README.md', '子系统', 'Subsystems'],
+  ]],
+  ['内核与作用域', 'Core and scopes', [
+    ['core.md', '核心', 'Core'],
+    ['scope.md', '作用域', 'Scopes'],
+    ['invariants.md', '运行时不变式', 'Runtime invariants'],
+  ]],
+  ['会话与持久化', 'Sessions and persistence', [
+    ['session.md', '会话', 'Sessions'],
+    ['session-query.md', '会话查询', 'Session query'],
+    ['session-reference.md', '会话引用', 'Session references'],
+    ['session-title.md', '会话标题', 'Session titles'],
+    ['session-projection.md', '会话投影', 'Session projections'],
+    ['persistence.md', '会话持久化', 'Session persistence'],
+    ['spill.md', 'Spill 存储', 'Spill storage'],
+    ['telemetry.md', '遥测', 'Telemetry'],
+  ]],
+  ['模型与上下文', 'Model and context', [
+    ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'],
+    ['token-meter.md', 'Token 计量', 'Token metering'],
+    ['system-prompt.md', '系统提示词', 'System prompts'],
+    ['compaction.md', '上下文压缩', 'Compaction'],
+  ]],
+  ['执行与工具', 'Execution and tools', [
+    ['tools.md', '工具', 'Tools'],
+    ['bash.md', 'Bash 执行', 'Bash execution'],
+    ['subprocess.md', '子进程', 'Subprocesses'],
+    ['pty.md', 'PTY 会话', 'PTY sessions'],
+    ['tasks.md', '后台任务', 'Background tasks'],
+    ['filesystem.md', '文件系统', 'Filesystem'],
+    ['lsp.md', 'LSP 导航', 'LSP navigation'],
+    ['code-runtime.md', '代码运行时', 'Code runtime'],
+    ['web.md', 'Web 访问', 'Web access'],
+    ['skills.md', '技能', 'Skills'],
+    ['workflow.md', '工作流', 'Workflows'],
+    ['subagent.md', '子代理', 'Subagents'],
+  ]],
+  ['策略与交互', 'Policy and interaction', [
+    ['approval.md', '审批', 'Approvals'],
+    ['permission.md', '权限预设', 'Permission presets'],
+    ['sandbox.md', '沙箱', 'Sandboxing'],
+    ['plan.md', '计划模式', 'Plan mode'],
+    ['user-interaction.md', '用户交互', 'User interaction'],
+    ['commands.md', '命令', 'Human commands'],
+    ['goal.md', '目标', 'Goals'],
+    ['schedule.md', '定时提醒', 'Scheduled reminders'],
+  ]],
+  ['平台与接入', 'Platform and access', [
+    ['http-server.md', 'HTTP 服务器', 'HTTP server'],
+    ['typert.md', 'TypeRT', 'TypeRT'],
+    ['client-modules.md', '客户端模块', 'Client modules'],
+    ['storage.md', '存储', 'Storage'],
+    ['workspace.md', '工作区', 'Workspaces'],
+    ['settings.md', '用户设置', 'User settings'],
+    ['credentials.md', '用户凭据', 'User credentials'],
+  ]],
+] as const
+
+const subsystemsReference = subsystemGroups.flatMap(([rootSection, enSection, files]) => pairedPages(
+  files.map(([file, rootLabel, enLabel], order): PairedPage => ({
+    source: `docs/subsystems/${file}`,
+    route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`,
+    label: { root: rootLabel, en: enLabel },
+    sidebar: { root: 'zh-reference', en: 'en-reference' },
+    section: { root: rootSection, en: enSection },
+    order,
+    // Subsystem pages carry long third-level sections a two-level outline reaches.
+    outline: [2, 3],
+    ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}),
+  })),
+))
 
 const reference = [
   ...pairedPages(([
@@ -359,19 +388,6 @@ const reference = [
     section: { root: 'Cordis API', en: 'Cordis Core API' },
     order: order + 5,
   }))),
-  ...pairedPages(([
-    ['goal.md', '目标', 'Goals', 14],
-    ['schedule.md', '定时提醒', 'Scheduled reminders', 15],
-    ['pty.md', 'PTY 会话', 'PTY sessions', 26],
-    ['commands.md', '命令', 'Human commands', 38],
-  ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({
-    source: `docs/subsystems/${file}`,
-    route: `reference/subsystems/${file}`,
-    label: { root: rootLabel, en: enLabel },
-    sidebar: { root: 'zh-reference', en: 'en-reference' },
-    section: { root: '子系统', en: 'Subsystems' },
-    order,
-  }))),
   ...pairedPages(([
     ['adding-a-package.md', '新增 Package', 'Adding a package'],
     ['adding-a-tool.md', '新增 Tool', 'Adding a tool'],
@@ -395,6 +411,64 @@ const reference = [
   }]),
 ]
 
+/** A sidebar group, matched to pages by `label`. */
+export interface DocsSection {
+  /** Group heading, equal to the `section` field of every page it holds. */
+  label: string
+  /** Render the group collapsed until it holds the page being read. */
+  collapsed?: boolean
+}
+
+/**
+ * Every sidebar group, in the order its locale renders it.
+ *
+ * The subsystem groups collapse because together they outnumber the rest of the
+ * reference sidebar; expanded, they push every other group below the fold.
+ */
+const sections: Record<DocsLocale, readonly DocsSection[]> = {
+  root: [
+    { label: '入门' }, { label: 'SDK' },
+    { label: '基础' }, { label: '框架能力' }, { label: '实战' }, { label: 'Cordis 框架教程' },
+    { label: '概念' }, { label: '生成参考' }, { label: 'Cordis API' }, { label: '开发手册' },
+    { label: '总览' },
+    { label: '内核与作用域', collapsed: true },
+    { label: '会话与持久化', collapsed: true },
+    { label: '模型与上下文', collapsed: true },
+    { label: '执行与工具', collapsed: true },
+    { label: '策略与交互', collapsed: true },
+    { label: '平台与接入', collapsed: true },
+  ],
+  en: [
+    { label: 'Guide' }, { label: 'SDK' },
+    { label: 'Basics' }, { label: 'Framework' }, { label: 'Practice' }, { label: 'Cordis framework tutorial' },
+    { label: 'Concepts' }, { label: 'Generated reference' }, { label: 'Cordis Core API' }, { label: 'Cookbook' },
+    { label: 'Overview' },
+    { label: 'Core and scopes', collapsed: true },
+    { label: 'Sessions and persistence', collapsed: true },
+    { label: 'Model and context', collapsed: true },
+    { label: 'Execution and tools', collapsed: true },
+    { label: 'Policy and interaction', collapsed: true },
+    { label: 'Platform and access', collapsed: true },
+  ],
+}
+
+/**
+ * Placement and collapse behavior of one sidebar group.
+ *
+ * @param locale - Route tree whose sidebar is being built.
+ * @param label - Section label carried by the pages in the group.
+ * @returns The declared group, plus its zero-based position in the locale.
+ * @throws When the locale declares no placement for the label. Ranking by list
+ *   membership alone would sort an undeclared group silently ahead of every
+ *   declared one.
+ */
+export function sectionSpec(locale: DocsLocale, label: string): DocsSection & { index: number } {
+  const declared = sections[locale]
+  const section = declared.find(candidate => candidate.label === label)
+  if (section === undefined) throw new Error(`Sidebar section "${label}" has no placement in the ${locale} locale.`)
+  return { ...section, index: declared.indexOf(section) }
+}
+
 /** Every canonical page published by the documentation website. */
 export const docsPages: DocsPage[] = [
   ...homeAndGuide,
@@ -404,3 +478,47 @@ export const docsPages: DocsPage[] = [
   ...subsystemsReference,
   ...reference,
 ]
+
+/**
+ * Pages of one sidebar collection, in the order the sidebar lists them.
+ *
+ * @param locale - Route tree whose sidebar is being built.
+ * @param collection - Sidebar collection to read.
+ * @returns The collection's pages, ordered by section placement then by `order`.
+ */
+export function orderedPages(locale: DocsLocale, collection: DocsSidebar): DocsPage[] {
+  return docsPages
+    .filter(page => page.locale === locale && page.sidebar === collection)
+    .sort((left, right) => (
+      sectionSpec(locale, left.section).index - sectionSpec(locale, right.section).index
+      || left.order - right.order
+    ))
+}
+
+/**
+ * Site-relative link for a published route.
+ *
+ * @param route - Manifest route, including its `.md` suffix.
+ * @returns The link VitePress serves the route at.
+ */
+export function routeLink(route: string): string {
+  return `/${route.replace(/(?:index)?\.md$/, '')}`
+}
+
+/**
+ * Where a top-level navigation item lands.
+ *
+ * The target is derived rather than written down: a collection whose first page
+ * is renamed or reordered would otherwise leave the navigation bar pointing at
+ * a route the manifest no longer publishes.
+ *
+ * @param locale - Route tree the navigation item belongs to.
+ * @param collection - Sidebar collection the item opens.
+ * @returns Site-relative link of the collection's first page.
+ * @throws When the collection publishes no page.
+ */
+export function landingLink(locale: DocsLocale, collection: DocsSidebar): string {
+  const first = orderedPages(locale, collection)[0]
+  if (first === undefined) throw new Error(`Sidebar collection "${collection}" publishes no page.`)
+  return routeLink(first.route)
+}

File diff suppressed because it is too large
+ 1 - 0
website/public/favicon.svg


File diff suppressed because it is too large
+ 11 - 0
website/public/wordmark.svg


Some files were not shown because too many files changed in this diff