From 3dff212c5ddae752b3e55dbc346ceb595a3693ad Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 00:38:46 +0800 Subject: [PATCH 01/18] feat(workflow): auto-follow run and phase disclosures --- ...10-durable-workflow-runs-in-chat.i18n.yaml | 4 +- ...026-08-10-durable-workflow-runs-in-chat.md | 4 +- ...-08-10-durable-workflow-runs-in-chat.zh.md | 4 +- ...low-run-status-driven-disclosure.i18n.yaml | 6 + ...1-workflow-run-status-driven-disclosure.md | 43 ++++ ...orkflow-run-status-driven-disclosure.zh.md | 43 ++++ apps/web/tests/workflow-run.e2e.ts | 20 +- .../client/ui-workflow-run/README.i18n.yaml | 4 +- packages/client/ui-workflow-run/README.md | 2 +- packages/client/ui-workflow-run/README.zh.md | 2 +- .../src/client/WorkflowRunPanel.tsx | 76 ++++--- .../tests/workflow-run.spec.tsx | 202 +++++++++++------- 12 files changed, 288 insertions(+), 122 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml index 4b44acecd9..2c276afafe 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md -2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab -2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd +2026-08-10-durable-workflow-runs-in-chat.md: 817fd4debd93a4768904e3934456ebdd4bdaa896 +2026-08-10-durable-workflow-runs-in-chat.zh.md: 7b09708d94783de5aff9a9fd59757120c661775a diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md index 791a81e9e3..817fd4debd 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md @@ -20,7 +20,7 @@ The workflow package exposes browser-safe run and observation vocabulary through `ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state. -The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. +The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. [Status-driven workflow disclosure](2026-08-11-workflow-run-status-driven-disclosure.md) owns which run and phase content remains visible as those facts change. Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static. @@ -42,4 +42,4 @@ Package tests cover top-level and nested eligibility, zero-member and concurrent ## Consequences -Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. +Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, the status-driven disclosure lifecycle keeps review choices local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md index e6c87f61a1..7b09708d94 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md @@ -20,7 +20,7 @@ workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运 `ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`;run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。 -renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。 +renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。[状态驱动的工作流 disclosure](2026-08-11-workflow-run-status-driven-disclosure.md)拥有这些事实变化时运行与阶段内容的可见性。 导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。 @@ -42,4 +42,4 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl ## 后果 -工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 +工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,状态驱动的 disclosure 生命周期把复盘选择留在本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml new file mode 100644 index 0000000000..47de330802 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md +2026-08-11-workflow-run-status-driven-disclosure.md: cd71115c1ff1b550d6678ea606756f44bd634246 +2026-08-11-workflow-run-status-driven-disclosure.zh.md: 06636d35e47e799b3ea680938414413d89d927c7 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md new file mode 100644 index 0000000000..cd71115c1f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md @@ -0,0 +1,43 @@ +# Agent Note: Status-driven disclosure for workflow runs + +Status: implemented + +English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md) + +## Problem + +A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review. + +The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes. + +## Decision + +Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible. + +A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action. + +A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice. + +For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes. + +The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, CSS, or the shared `DisclosureRow` API. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change. + +## Verification + +Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory. + +The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts. + +## Alternatives considered + +**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work. + +**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review. + +**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need. + +## Consequences + +Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction. + +The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly. diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md new file mode 100644 index 0000000000..06636d35e4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 工作流运行的状态驱动 disclosure + +Status: implemented + +[English](2026-08-11-workflow-run-status-driven-disclosure.md) | 中文 + +## 问题 + +持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。 + +renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。 + +## 决策 + +每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。 + +强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。 + +干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。 + +例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。 + +renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案、CSS 或共享 `DisclosureRow` API。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 + +## 验证 + +组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。 + +shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。 + +## 曾考虑的替代方案 + +**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。 + +**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。 + +**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。 + +## 后果 + +工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。 + +代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。 diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index eafb78223f..cc20bdcc6f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -74,12 +74,14 @@ 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() const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ }) await member.waitFor({ timeout: 15_000 }) await member.focus() @@ -139,15 +141,18 @@ 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') + 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') + 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 +170,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await workflow.click() const phase = page.getByRole('button', { name: /^Run/ }) await phase.waitFor() + expect(await phase.getAttribute('aria-expanded')).toBe('false') await phase.click() await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index 3d6294e997..6baade6354 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/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: a803857af24802e8a4645c4d5aca56c04424c85e +README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd +README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734 diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 66539e0c16..489715c517 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/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. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index a803857af2..326a7ae4e4 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/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)`;远程、仅地址化、父级不符或终态的行都不可交互。 diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index fcb36da7a3..c200f99bfd 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/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,34 @@ 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 + +/* 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 ( + { setOpen(value => !value) }} + /> + ) +} + +function StatusDisclosure({ requiresExpansion, ...props }: StatusDisclosureProps & { + readonly requiresExpansion: boolean +}) { + if (!requiresExpansion) return + return +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -97,21 +126,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 ( - } title={t('run.title', { name })} - open={open} - expandable - onToggle={onToggle} + requiresExpansion={requiresExpansion} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -128,7 +155,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: { )} - /> + > + {children} + ) } @@ -168,15 +197,11 @@ 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 ( - } title={readablePhase(phase.phase, t)} - open={open} - expandable - onToggle={toggle} + requiresExpansion={phaseRequiresExpansion(phase)} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -203,14 +228,15 @@ function PhaseSection({ phase, navigable, openSession, t }: { /> ))} - + ) } -/** 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 +244,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t return (
{ setOpen(value => !value) }} - /> - {open && ( + >
{node.data.phases.length === 0 ? {t('run.empty')} @@ -239,7 +263,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t /> ))}
- )} +
) } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 196bdf1144..3d8d1c798f 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -301,90 +301,148 @@ 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() + 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() - expect(screen.getByText('未分阶段')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^audit/ })) - expect(screen.queryByText('未分阶段')).toBeNull() + const phaseCompleted: WorkflowRunChatData = { + ...running, + phases: [phase({ + members: [{ + seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + 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 terminal: WorkflowRunChatData = { ...running, status: 'completed' } - view.rerender() + const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' } + view.rerender() + 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() + const cleanUpdate: WorkflowRunChatData = { + ...completed, + phases: [phase({ + members: [{ + seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + expect(screen.getByText('reviewed')).toBeTruthy() + + view.rerender() + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText('worker')).toBeTruthy() + view.rerender() + 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() - 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' }) + 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() + expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull() + expect(screen.getByText('没有启动成员')).toBeTruthy() + view.rerender() + const header = screen.getByRole('button', { name: /^empty/ }) 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') - - cleanup() - render() + 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() + expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText(status)).toBeTruthy() + memberView.unmount() + + render() + 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() - fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) - expect(screen.getByText('空成员名')).toBeTruthy() - expect(screen.queryByText('second')).toBeNull() - 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(screen.getByRole('button', { name: /空阶段名/ })) + fireEvent.click(cleanPhase) + expect(screen.getByText('空成员名')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + 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() - 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 +453,6 @@ describe('WorkflowRunPanel', () => { })], } const mixedView = render() - 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 +460,18 @@ describe('WorkflowRunPanel', () => { expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) mixedView.unmount() - const interrupted: WorkflowRunChatData = { + const interruptedView = render() - 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 +480,6 @@ describe('WorkflowRunPanel', () => { } const openSession = vi.fn() render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) fireEvent.click(screen.getByRole('button', { name: '打开 worker' })) expect(openSession).toHaveBeenCalledWith('child-1') }) @@ -464,7 +509,6 @@ describe('WorkflowRunPanel', () => { })], } render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() cleanup() }) From 1b91e5b4feb955ab0cd81325dc30e563e450b710 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 01:01:25 +0800 Subject: [PATCH 02/18] fix(workflow): refold coalesced disclosure cycles --- apps/web/tests/workflow-run.e2e.ts | 4 ++ .../src/client/WorkflowRunPanel.module.css | 2 - .../src/client/WorkflowRunPanel.tsx | 7 +++- .../tests/workflow-run.spec.tsx | 39 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index cc20bdcc6f..4cbae8e6e2 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -82,6 +82,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = 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() @@ -148,10 +150,12 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ }) await terminalWorkflow.waitFor() 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() 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( diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 77145ee06a..fdb54fdde2 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/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 { diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index c200f99bfd..59883e5ae6 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -84,10 +84,11 @@ function ManualDisclosure(props: StatusDisclosureProps) { ) } -function StatusDisclosure({ requiresExpansion, ...props }: StatusDisclosureProps & { +function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & { + readonly cleanCycleKey: number readonly requiresExpansion: boolean }) { - if (!requiresExpansion) return + if (!requiresExpansion) return return } @@ -138,6 +139,7 @@ function RunHeader({ children, count, name, requiresExpansion, status, t }: { } title={t('run.title', { name })} + cleanCycleKey={count} requiresExpansion={requiresExpansion} expandOnRowClick previewChevron={false} @@ -201,6 +203,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { } title={readablePhase(phase.phase, t)} + cleanCycleKey={phase.members.length} requiresExpansion={phaseRequiresExpansion(phase)} expandOnRowClick previewChevron={false} diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 3d8d1c798f..55ee32b8d1 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -377,6 +377,45 @@ describe('WorkflowRunPanel', () => { expect(screen.queryByText('未分阶段')).toBeNull() }) + it('refolds 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() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('first')).toBeTruthy() + phaseView.rerender() + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('first')).toBeNull() + expect(screen.queryByText('second')).toBeNull() + phaseView.unmount() + + const workflowClean: WorkflowRunChatData = { + name: 'workflow-cycle', status: 'completed', + phases: [phase({ members: [firstMember] })], + } + const workflowView = render() + fireEvent.click(screen.getByRole('button', { name: /^workflow-cycle/ })) + expect(screen.getByText('未分阶段')).toBeTruthy() + workflowView.rerender() + expect(screen.getByRole('button', { name: /^workflow-cycle/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('未分阶段')).toBeNull() + }) + 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() From 33112b495713d06c5094f58291410e039dc66078 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 01:09:03 +0800 Subject: [PATCH 03/18] fix(workflow): scope clean-cycle reset to phases --- .../src/client/WorkflowRunPanel.tsx | 3 +-- .../tests/workflow-run.spec.tsx | 19 +------------------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 59883e5ae6..f9d3adf294 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -85,7 +85,7 @@ function ManualDisclosure(props: StatusDisclosureProps) { } function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & { - readonly cleanCycleKey: number + readonly cleanCycleKey?: number | undefined readonly requiresExpansion: boolean }) { if (!requiresExpansion) return @@ -139,7 +139,6 @@ function RunHeader({ children, count, name, requiresExpansion, status, t }: { } title={t('run.title', { name })} - cleanCycleKey={count} requiresExpansion={requiresExpansion} expandOnRowClick previewChevron={false} diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 55ee32b8d1..e4fefa5ad7 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -377,7 +377,7 @@ describe('WorkflowRunPanel', () => { expect(screen.queryByText('未分阶段')).toBeNull() }) - it('refolds when a complete activity cycle arrives as one clean update', () => { + 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, } @@ -397,23 +397,6 @@ describe('WorkflowRunPanel', () => { expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('first')).toBeNull() expect(screen.queryByText('second')).toBeNull() - phaseView.unmount() - - const workflowClean: WorkflowRunChatData = { - name: 'workflow-cycle', status: 'completed', - phases: [phase({ members: [firstMember] })], - } - const workflowView = render() - fireEvent.click(screen.getByRole('button', { name: /^workflow-cycle/ })) - expect(screen.getByText('未分阶段')).toBeTruthy() - workflowView.rerender() - expect(screen.getByRole('button', { name: /^workflow-cycle/ }).getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('未分阶段')).toBeNull() }) it('derives the zero-member running and completed states from the current run status', () => { From 8f308023a2c8744dd4cb5ee73b940616121ea5f9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 01:41:43 +0800 Subject: [PATCH 04/18] docs(workflow): align disclosure lifecycle evidence --- ...2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml | 4 ++-- .../2026-08-11-workflow-run-status-driven-disclosure.md | 2 +- .../2026-08-11-workflow-run-status-driven-disclosure.zh.md | 2 +- .../client/ui-workflow-run/src/client/WorkflowRunPanel.tsx | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml index 47de330802..e24e6c6771 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml @@ -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-11-workflow-run-status-driven-disclosure.md -2026-08-11-workflow-run-status-driven-disclosure.md: cd71115c1ff1b550d6678ea606756f44bd634246 -2026-08-11-workflow-run-status-driven-disclosure.zh.md: 06636d35e47e799b3ea680938414413d89d927c7 +2026-08-11-workflow-run-status-driven-disclosure.md: e4718a62e03f957b0fcac8e6b07f8c1d8532e508 +2026-08-11-workflow-run-status-driven-disclosure.zh.md: b212be7a3fce1d429103e8efcfb88f42d12d9114 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md index cd71115c1f..e4718a62e0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md @@ -20,7 +20,7 @@ A clean level mounts an ordinary controlled disclosure in the closed state. Its 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, CSS, or the shared `DisclosureRow` API. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change. +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. The only CSS change removes unconditional 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 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md index 06636d35e4..b212be7a3f 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md @@ -20,7 +20,7 @@ renderer 已经从工作流 Conversation Node 收到全部所需生命周期事 例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。 -renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案、CSS 或共享 `DisclosureRow` API。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 +renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。唯一的 CSS 变化是移除无条件 pointer 光标,让强制展开的静态行不再提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 ## 验证 diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index f9d3adf294..c58399c583 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -85,6 +85,7 @@ function ManualDisclosure(props: StatusDisclosureProps) { } 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 }) { From e4a5c6fb9d903d8116685de349c7187f9ee264b4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 12 Aug 2026 02:11:08 +0800 Subject: [PATCH 05/18] docs(workflow): state current pointer ownership --- ...2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml | 4 ++-- .../2026-08-11-workflow-run-status-driven-disclosure.md | 2 +- .../2026-08-11-workflow-run-status-driven-disclosure.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml index e24e6c6771..1f7ecb98d3 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml @@ -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-11-workflow-run-status-driven-disclosure.md -2026-08-11-workflow-run-status-driven-disclosure.md: e4718a62e03f957b0fcac8e6b07f8c1d8532e508 -2026-08-11-workflow-run-status-driven-disclosure.zh.md: b212be7a3fce1d429103e8efcfb88f42d12d9114 +2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781 +2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md index e4718a62e0..2f452d25a8 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md @@ -20,7 +20,7 @@ A clean level mounts an ordinary controlled disclosure in the closed state. Its 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. The only CSS change removes unconditional 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. +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 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md index b212be7a3f..12cc106fea 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md @@ -20,7 +20,7 @@ renderer 已经从工作流 Conversation Node 收到全部所需生命周期事 例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。 -renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。唯一的 CSS 变化是移除无条件 pointer 光标,让强制展开的静态行不再提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 +renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 ## 验证 From f1deffeccb9850c06148002eeb46a08471c5ba49 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 09:56:17 +0800 Subject: [PATCH 06/18] test(sandbox-local): pin the windows-acl runner-entry arms independent of build state The source-through-tsx fallback in windowsAclRunnerInvocation was only reachable while sandbox-windows-acl/lib/runner.js was absent, so the per-file 100% gate failed wherever a gate had already built it. In ci-primary and ci-windows-complete the typert-contracts gate runs build:lib:host ahead of test:coverage in one workspace, leaving both fallback statements uncovered; the sharded coverage job never builds first and stayed green. Cover the fallback through an injected entry path that is guaranteed not to exist, and keep the uninjected case asserting only the verdict that holds under either arm. Verified at 100% statements/branches/functions/ lines with lib/runner.js both present and absent. --- .../sandbox/sandbox-local/tests/local.spec.ts | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 1586df3947..389f23a011 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -32,6 +32,16 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter return { ctx, sandbox } } +/** + * A path inside a fresh temp dir where no file is written, pinning the + * built-entry `existsSync` check to false. Without it the resolution depends on + * whether the checkout has run `build:lib:host`, which emits + * `sandbox-windows-acl/lib/runner.js`. + */ +function absentRunnerEntry(): string { + return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') +} + /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) @@ -395,15 +405,31 @@ describe('the windows-acl probe (runner invocation contract)', () => { }) it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { - // The default probe spawns the exact runner argv confine would use — the - // runner source through tsx on a lib-less checkout. The windows-acl - // runner cannot init off win32, so the probe reads unusable and the walk - // falls through to the injected bwrap verdict on every host. + // No entry injected: this covers the production resolution through + // import.meta.resolve. Which arm of the existsSync check it takes depends + // on whether the checkout has run build:lib:host (which emits + // sandbox-windows-acl/lib/runner.js), so this asserts only what holds + // either way — the runner cannot init off win32, so the probe reads + // unusable and the walk falls through to the injected bwrap verdict. const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) const confined = sandbox.confine(['true'], RO) expect(confined.argv[0]).toBe('bwrap') }, 30_000) + it('falls back to the runner source through tsx when the built entry is absent', async () => { + // The absent entry pins the source-through-tsx arm regardless of build + // state: on a checkout where build:lib:host has run, the real resolution + // above takes the built-entry arm instead and would leave this uncovered. + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: absentRunnerEntry(), + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 3)).toEqual([process.execPath, '--import', 'tsx/esm']) + expect(confined.argv[3]).toMatch(/runner\.ts$/) + }) + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { // windowsAclRunnerInvocation always yields [node, ...] in product; an // override returning [] exercises the default probe's empty-argv guard. From 6ed5df964e951b73ef5d8864a1e418cd2b16af9c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:46:41 -0700 Subject: [PATCH 07/18] feat(web): add read-only Loader plugin inventory --- apps/web/tests/plugin-config.e2e.ts | 4 +- apps/web/tests/settings-chrome.e2e.ts | 26 ++- .../created.expected.md | 3 + .../damaged.expected.md | 3 + .../section.expected.md | 3 + .../models-settings/configured.expected.md | 3 + .../models-settings/declared-edit.expected.md | 3 + .../models-settings/declared.expected.md | 3 + .../models-settings/empty.expected.md | 3 + .../models.expected.md | 3 + .../plugin-config/section.expected.md | 3 + .../settings-chrome/dialog.expected.md | 3 + .../settings-chrome/plugins.expected.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 17 +- docs/module-graph.zh.md | 17 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 3 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 7 + packages/bundle/web-app/package.json | 2 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/ui-plugins/README.i18n.yaml | 6 + packages/client/ui-plugins/README.md | 20 ++ packages/client/ui-plugins/README.zh.md | 20 ++ packages/client/ui-plugins/package.json | 80 +++++++ .../client/PluginSettingsSection.module.css | 213 ++++++++++++++++++ .../src/client/PluginSettingsSection.tsx | 149 ++++++++++++ .../client/ui-plugins/src/client/index.ts | 42 ++++ .../client/ui-plugins/src/client/locales.ts | 46 ++++ .../client/ui-plugins/src/css-modules.d.ts | 6 + packages/client/ui-plugins/src/index.ts | 4 + packages/client/ui-plugins/src/invariant.ts | 20 ++ .../ui-plugins/tests/browser-plugin.spec.tsx | 87 +++++++ .../ui-plugins/tests/components.spec.tsx | 100 ++++++++ .../client/ui-plugins/tests/invariant.spec.ts | 15 ++ packages/client/ui-plugins/tsconfig.json | 36 +++ packages/client/ui-plugins/tsdown.config.ts | 3 + packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 1 + packages/host/README.zh.md | 1 + .../host/plugin-inventory/README.i18n.yaml | 6 + packages/host/plugin-inventory/README.md | 22 ++ packages/host/plugin-inventory/README.zh.md | 22 ++ packages/host/plugin-inventory/package.json | 70 ++++++ packages/host/plugin-inventory/src/index.ts | 72 ++++++ .../host/plugin-inventory/src/invariant.ts | 20 ++ packages/host/plugin-inventory/src/types.ts | 28 +++ .../plugin-inventory/tests/invariant.spec.ts | 16 ++ .../plugin-inventory/tests/inventory.spec.ts | 89 ++++++++ packages/host/plugin-inventory/tsconfig.json | 27 +++ pnpm-lock.yaml | 73 ++++++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 64 files changed, 1429 insertions(+), 19 deletions(-) create mode 100644 apps/web/tests/snapshots/settings-chrome/plugins.expected.md create mode 100644 packages/client/ui-plugins/README.i18n.yaml create mode 100644 packages/client/ui-plugins/README.md create mode 100644 packages/client/ui-plugins/README.zh.md create mode 100644 packages/client/ui-plugins/package.json create mode 100644 packages/client/ui-plugins/src/client/PluginSettingsSection.module.css create mode 100644 packages/client/ui-plugins/src/client/PluginSettingsSection.tsx create mode 100644 packages/client/ui-plugins/src/client/index.ts create mode 100644 packages/client/ui-plugins/src/client/locales.ts create mode 100644 packages/client/ui-plugins/src/css-modules.d.ts create mode 100644 packages/client/ui-plugins/src/index.ts create mode 100644 packages/client/ui-plugins/src/invariant.ts create mode 100644 packages/client/ui-plugins/tests/browser-plugin.spec.tsx create mode 100644 packages/client/ui-plugins/tests/components.spec.tsx create mode 100644 packages/client/ui-plugins/tests/invariant.spec.ts create mode 100644 packages/client/ui-plugins/tsconfig.json create mode 100644 packages/client/ui-plugins/tsdown.config.ts create mode 100644 packages/host/plugin-inventory/README.i18n.yaml create mode 100644 packages/host/plugin-inventory/README.md create mode 100644 packages/host/plugin-inventory/README.zh.md create mode 100644 packages/host/plugin-inventory/package.json create mode 100644 packages/host/plugin-inventory/src/index.ts create mode 100644 packages/host/plugin-inventory/src/invariant.ts create mode 100644 packages/host/plugin-inventory/src/types.ts create mode 100644 packages/host/plugin-inventory/tests/invariant.spec.ts create mode 100644 packages/host/plugin-inventory/tests/inventory.spec.ts create mode 100644 packages/host/plugin-inventory/tsconfig.json diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index bdad2083aa..15956877f4 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -56,9 +56,9 @@ describe('web e2e: plugin configuration section', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件' }).click() + await dialog.getByRole('button', { name: '插件配置', exact: true }).click() await expect - .poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 }) + .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) .toBe('true') return dialog } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index a3b387b660..5d057a7bed 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') +const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() describe('web e2e: settings modal and General preferences', () => { @@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '模型' }).click() await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() + // Plugins is a read-only projection of the same assembled Loader tree. + // Capture one stable shipped row rather than the whole inventory so adding + // an unrelated plugin does not rewrite this surface's golden. + await dialog.getByRole('button', { name: '插件', exact: true }).click() + await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) + await pluginRow.waitFor({ timeout: 10_000 }) + const expectedPluginCount = [...scaffold.ctx.loader.entries()] + .filter(entry => !entry.options.group) + .length + expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) + expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount) + expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) + .toBe(String(expectedPluginCount)) + expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + const pluginsSnapshot = await captureStableAria( + page, + PLUGIN_ROW_SELECTOR, + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE) // Close path 1: Escape. await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) @@ -454,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index fc96442a85..40b9497831 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b8136ec702..c3e9035098 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index 9bc3d8db4d..e411e8ea28 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index e4fb6e13e8..66cc7c88b6 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 1acd03b4aa..86f8b77fe8 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index b126a5025b..b9e5dca61f 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 5a1dba54ee..03e87a7a51 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 2624f4db70..562b54d837 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 7d10d05cd1..18cdef13d5 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 914293aee3..2a7c767bf8 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md new file mode 100644 index 0000000000..e3b2e9ee54 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md @@ -0,0 +1,4 @@ +- listitem "ui-settings, 存活, 已启用": + - strong: ui-settings + - img "存活" + - text: 已启用 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7b380a0698..833002c14a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa -config-catalog.zh.md: 4be51942de65a7e8afdaf71a44573ba1cdd6230d +config-catalog.md: 83e0d3e3a8bd1b5f0b5d70e1552e0fd9b70eccf1 +config-catalog.zh.md: 21cf904708c5eae1c71b2c85843eb80926a4912e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bb1f315e0..83e0d3e3a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2770,6 +2770,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins` ([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -2792,6 +2793,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be51942de..21cf904708 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2771,6 +2771,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins`([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -2793,6 +2794,7 @@ export interface Config { - `@deepseek-ai/dsh-goal-session` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `httpServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty`([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5b26c5608a..cbd76b01e5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: e24d2601fd74f58eec8c5e5b1a35701e1800baaa -module-graph.zh.md: b71da431b05634c4c599096c8cd33fed23a6be45 +module-graph.md: 9ca575137f3ebdddbc77e89c3b3b97da90c94843 +module-graph.zh.md: 6978ff30b7e8e927a0d0a10dd7b06cfbbeb1f7e9 diff --git a/docs/module-graph.md b/docs/module-graph.md index e24d2601fd..9ca575137f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -162,6 +162,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -219,6 +220,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -358,6 +360,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -642,6 +647,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1136,6 +1142,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1374,6 +1387,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1443,7 +1457,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1522,6 +1536,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b71da431b0..6978ff30b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -164,6 +164,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -221,6 +222,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -360,6 +362,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -644,6 +649,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1138,6 +1144,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1376,6 +1389,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1445,7 +1459,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1524,6 +1538,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 421a56d951..35117e9dda 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e -README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a +README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6 +README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cc903af720..288c63c9f4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index fe34b8774c..1fd599b08e 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -6,8 +6,7 @@ `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 - +当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 30ebcb3195..5c643a2aea 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", @@ -78,6 +79,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index c08190ddc1..2f077dfd75 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -3,11 +3,13 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -54,7 +56,7 @@ export const inject = ['remote'] export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { - for (const contribution of [commandsRemote, goalsRemote]) { + for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) { disposers.push(await ctx.remote.$mount(contribution)) } } catch (error) { diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 8f28b83a6e..274570dc52 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -26,6 +26,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../host/plugin-inventory" + }, { "path": "../../interaction/commands" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c696b41503..a5d039e3dd 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -80,6 +80,10 @@ - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-auto' + # Read-only projection of current Loader entries for trusted client RPCs. + - id: plugin-inventory + name: '@deepseek-ai/dsh-host-plugin-inventory' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -172,6 +176,9 @@ - id: ui-models name: '@deepseek-ai/dsh-client-ui-models' + - id: ui-plugins + name: '@deepseek-ai/dsh-client-ui-plugins' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3375fed71a..efd11b896d 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c5da6b743a..cd9f668fad 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 75abe408952ed66dcc237ce489e417f61159bcc3 -README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca +README.md: 236531281c17ef982982e97caad99491584bd0b5 +README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64 diff --git a/packages/client/README.md b/packages/client/README.md index 75abe40895..236531281c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | +| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. | Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5432efcb0a..e619ffaa63 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | +| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 | 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml new file mode 100644 index 0000000000..ce5113cde1 --- /dev/null +++ b/packages/client/ui-plugins/README.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 packages/client/ui-plugins/README.md +README.md: c3236935b2a568c6996fcac8469f061a813cd95c +README.zh.md: 77810bccf95a55761518f89909fddd99818f686a diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md new file mode 100644 index 0000000000..c3236935b2 --- /dev/null +++ b/packages/client/ui-plugins/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +English | [中文](README.zh.md) + +Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). + +The page renders a searchable two-column catalog of compact cards. Each card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. + +## Model Experience + +None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot. +- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md new file mode 100644 index 0000000000..77810bccf9 --- /dev/null +++ b/packages/client/ui-plugins/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +[English](README.md) | 中文 + +Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 + +页面以可搜索的双列紧凑卡片展示清单。每张卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 + +## 模型体验 + +无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。 +- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json new file mode 100644 index 0000000000..cca1843929 --- /dev/null +++ b/packages/client/ui-plugins/package.json @@ -0,0 +1,80 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugins", + "description": "Read-only Cordis Loader plugin inventory in Web settings", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-plugins" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css new file mode 100644 index 0000000000..eb26d02298 --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -0,0 +1,213 @@ +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.heading h2, +.catalogHeading h3, +.status, +.failure p { + margin: 0; +} + +.heading h2 { + font-size: 16px; + line-height: 24px; + font-weight: 600; +} + +.status, +.failure { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.failure button { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +.catalog { + display: flex; + flex-direction: column; + gap: 12px; +} + +.search { + position: relative; + display: flex; + align-items: center; + width: 100%; + color: var(--dsw-alias-label-tertiary); +} + +.search > svg { + position: absolute; + left: 12px; + pointer-events: none; +} + +.search input { + width: 100%; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 0 34px 0 36px; + outline: none; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; +} + +.search input::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.search input:focus-visible { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent); +} + +.catalogHeading { + display: flex; + align-items: baseline; + gap: 7px; + padding: 0 2px; +} + +.catalogHeading h3 { + font-size: 13px; + line-height: 20px; + font-weight: 600; +} + +.catalogHeading span { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; +} + +.cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-3); +} + +.cardContent { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 52px; + border: 0; + padding: 12px 14px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.cardTitle { + min-width: 0; + overflow: hidden; + font-size: 14px; + line-height: 20px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardTrailing { + display: inline-flex; + flex: none; + align-items: center; + gap: 7px; + color: var(--dsw-alias-label-tertiary); +} + +.statusDot { + display: inline-block; + width: 7px; + height: 7px; + flex: none; + border-radius: 999px; + background: var(--dsw-alias-label-tertiary); +} + +.statusDot[data-phase='active'] { + background: var(--dsw-alias-state-success-primary); +} + +.statusDot[data-phase='failed'] { + background: var(--dsw-alias-state-error-primary); +} + +.statusDot[data-phase='loading'] { + background: var(--dsw-alias-state-business-primary); +} + +.configTag { + display: inline-flex; + align-items: center; + min-height: 20px; + border-radius: 5px; + padding: 1px 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +.configTag[data-enabled='true'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 680px) { + .cards { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx new file mode 100644 index 0000000000..2d33ac2e8b --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -0,0 +1,149 @@ +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginsKey } from './locales.ts' +import css from './PluginSettingsSection.module.css' + +/** Registration-side Remote face used by the section. */ +export interface PluginSettingsSectionInjected { + /** Read a current Host inventory snapshot. */ + list: ClientRemote['pluginInventory']['list'] +} + +type PluginInventorySnapshot = Awaited> +type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] +type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] + +/** Full component props assembled by the Settings slot renderer. */ +export type PluginSettingsSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.plugins'> + & InjectFace + +type ViewState = + | { readonly status: 'loading' } + | { readonly status: 'error' } + | { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot } + +const PHASE_KEYS = { + pending: 'pending', + loading: 'loadingPhase', + active: 'active', + failed: 'failed', + unloading: 'unloading', +} satisfies Record, PluginsKey> + +/** Localized accessible label for one root Fiber phase. */ +function phaseLabel( + phase: PluginFiberPhase, + t: PluginSettingsSectionProps['t'], +): string { + return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) +} + +/** Whether an inventory row matches the local catalog query. */ +function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { + if (normalizedQuery.length === 0) return true + return [entry.displayId, entry.entryId] + .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) +} + +/** Render the read-only current Loader inventory. */ +export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode { + const titleId = useId() + const [request, setRequest] = useState(0) + const [query, setQuery] = useState('') + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let current = true + void Promise.resolve().then(() => list()).then( + (snapshot) => { if (current) setState({ status: 'ready', snapshot }) }, + () => { if (current) setState({ status: 'error' }) }, + ) + return () => { current = false } + }, [list, request]) + + const normalizedQuery = query.trim().toLocaleLowerCase() + const filteredEntries = useMemo( + () => state.status === 'ready' + ? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery)) + : [], + [normalizedQuery, state], + ) + + const retry = (): void => { + setState({ status: 'loading' }) + setRequest(value => value + 1) + } + + return ( +
+
+

{t('title')}

+
+ {state.status === 'loading' ?

{t('loading')}

: null} + {state.status === 'error' ? ( +
+

{t('error')}

+ +
+ ) : null} + {state.status === 'ready' ? ( +
+ +
+

{t('catalog')}

+ {filteredEntries.length} +
+ {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

{t('emptySearch')}

+ : null} + {filteredEntries.length > 0 ? ( +
    + {filteredEntries.map((entry) => { + const status = phaseLabel(entry.fiberPhase, t) + return ( +
  • +
    + {entry.displayId} + + + + {t(entry.enabled ? 'enabledTag' : 'disabledTag')} + + +
    +
  • + ) + })} +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts new file mode 100644 index 0000000000..b3034a86f9 --- /dev/null +++ b/packages/client/ui-plugins/src/client/index.ts @@ -0,0 +1,42 @@ +/** Read-only Host plugin inventory registered into Web Settings. */ + +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx' +import { en, zh, type PluginsKey } from './locales.ts' + +export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx' +export type { PluginsKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only Host plugin inventory copy. */ + 'settings.plugins': PluginsKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.plugins' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] + +/** Register the lazy plugin inventory page below Models in Settings. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') + + const t = ctx.locale.bind(NS) + const list: ClientRemote['pluginInventory']['list'] = () => ctx.remote.pluginInventory.list() + const injected = (): PluginSettingsSectionInjected => ({ list }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugin-inventory', + order: 15, + label: () => t('nav'), + locale: NS, + inject: injected, + }, PluginSettingsSection)) +} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts new file mode 100644 index 0000000000..64745ce14f --- /dev/null +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -0,0 +1,46 @@ +/** Copy dictionaries for the plugin inventory Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + nav: '插件', + title: '插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + retry: '重试', + search: '搜索插件', + catalog: '插件列表', + empty: '暂无插件。', + emptySearch: '没有匹配的插件。', + enabledTag: '已启用', + disabledTag: '已停用', + unobserved: '无根 Fiber', + pending: '等待依赖', + loadingPhase: '加载中', + active: '存活', + failed: '失败', + unloading: '卸载中', +} satisfies Record + +/** Plugin inventory locale key union. */ +export type PluginsKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + nav: 'Plugins', + title: 'Plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + retry: 'Retry', + search: 'Search plugins', + catalog: 'Plugin list', + empty: 'No plugins are available.', + emptySearch: 'No matching plugins.', + enabledTag: 'Enabled', + disabledTag: 'Disabled', + unobserved: 'No root Fiber', + pending: 'Pending', + loadingPhase: 'Loading', + active: 'Active', + failed: 'Failed', + unloading: 'Unloading', +} satisfies Record diff --git a/packages/client/ui-plugins/src/css-modules.d.ts b/packages/client/ui-plugins/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugins/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts new file mode 100644 index 0000000000..489544a421 --- /dev/null +++ b/packages/client/ui-plugins/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin settings section. */ +export function apply(): void {} diff --git a/packages/client/ui-plugins/src/invariant.ts b/packages/client/ui-plugins/src/invariant.ts new file mode 100644 index 0000000000..2d001d4312 --- /dev/null +++ b/packages/client/ui-plugins/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugins-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a read-only Settings contribution. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx new file mode 100644 index 0000000000..c2e3c741fd --- /dev/null +++ b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +const EMPTY = { entries: [] } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const list = vi.fn(() => Promise.resolve(EMPTY)) + ctx.provide('remote.pluginInventory', { list }) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } +} + +function declare(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugins browser plugin', () => { + it('declares only the services used by the Settings Remote contribution', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) + }) + + it('registers a localized section without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(PluginSettingsSection) + expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(b.list).not.toHaveBeenCalled() + + const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() + await expect(injected.list()).resolves.toEqual(EMPTY) + expect(b.list).toHaveBeenCalledOnce() + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + b.locale.setLocale('en') + expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + + stop() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + }) + + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() + await b.ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.spec.tsx new file mode 100644 index 0000000000..af49f96ad5 --- /dev/null +++ b/packages/client/ui-plugins/tests/components.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { + PluginSettingsSectionInjected, + PluginSettingsSectionProps, +} from '../src/client/PluginSettingsSection.tsx' +import { en, type PluginsKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Snapshot = Awaited> +const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] +const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never + +function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { + return { + close: vi.fn(), + useSessions: unusedHook, + useWorkspaces: unusedHook, + t, + list, + } +} + +const SNAPSHOT = { + entries: [ + { entryId: 'active', displayId: 'active-name', enabled: true, fiberPhase: 'active' }, + { entryId: 'pending', displayId: 'pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'loading', displayId: 'loading-name', enabled: true, fiberPhase: 'loading' }, + { entryId: 'failed', displayId: 'failed-name', enabled: true, fiberPhase: 'failed' }, + { entryId: 'unloading', displayId: 'unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'disabled-entry', displayId: 'disabled-name', enabled: false, fiberPhase: null }, + ], +} as unknown as Snapshot + +describe('PluginSettingsSection', () => { + it('renders searchable two-column-card semantics with dots and tags', async () => { + const deferred = Promise.withResolvers() + const list = vi.fn(() => deferred.promise) + const view = render() + expect(screen.getByText(en.loading)).toBeTruthy() + + await act(async () => { deferred.resolve(SNAPSHOT) }) + expect(list).toHaveBeenCalledOnce() + expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(screen.getByText(en.disabledTag)).toBeTruthy() + for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) { + expect(screen.getByRole('img', { name: value })).toBeTruthy() + } + expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy() + }) + + it('filters by local id or Loader entry id', async () => { + render( SNAPSHOT)} />) + const search = await screen.findByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'disabled-entry' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('disabled-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'pending' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('pending-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'not-a-plugin' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.getByText(en.emptySearch)).toBeTruthy() + }) + + it('shows a generic failure and retries into the empty state', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('private transport detail')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + expect(screen.queryByText('private transport detail')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.empty)).toBeTruthy() + }) + + it('contains a synchronous Remote failure and ignores a result after unmount', async () => { + const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list'] + const failed = render() + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + failed.unmount() + + const deferred = Promise.withResolvers() + const pending = render( deferred.promise)} />) + pending.unmount() + await act(async () => { deferred.resolve(SNAPSHOT) }) + }) +}) diff --git a/packages/client/ui-plugins/tests/invariant.spec.ts b/packages/client/ui-plugins/tests/invariant.spec.ts new file mode 100644 index 0000000000..df4161cb13 --- /dev/null +++ b/packages/client/ui-plugins/tests/invariant.spec.ts @@ -0,0 +1,15 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginsInvariant from '../src/invariant.ts' + +describe('ui-plugins invariant companion', () => { + it('registers the empty installer and keeps the node half inert', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() + const { apply } = await import('../src/index.ts') + apply() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tsconfig.json b/packages/client/ui-plugins/tsconfig.json new file mode 100644 index 0000000000..2019585ff7 --- /dev/null +++ b/packages/client/ui-plugins/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugins/tsdown.config.ts b/packages/client/ui-plugins/tsdown.config.ts new file mode 100644 index 0000000000..a85ab4569f --- /dev/null +++ b/packages/client/ui-plugins/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 3eb8fe7eb8..84e471c7fb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/host/README.md -README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c -README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448 +README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57 +README.zh.md: a062d5fce055e3266953993d532a86bec1375377 diff --git a/packages/host/README.md b/packages/host/README.md index 926cb0b6b8..1c3b6ab319 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | +| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | `apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7ef057ee56..a062d5fce0 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承 | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | +| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | `apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml new file mode 100644 index 0000000000..761b9c6850 --- /dev/null +++ b/packages/host/plugin-inventory/README.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 packages/host/plugin-inventory/README.md +README.md: d7a50824d337c66a30ba4332ffb1e36f9ad46547 +README.zh.md: e424b968f1f692fb30b3b0e49efe6c3cd5fadf70 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md new file mode 100644 index 0000000000..d7a50824d3 --- /dev/null +++ b/packages/host/plugin-inventory/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +English | [中文](README.zh.md) + +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, local display id, effective enablement, and current root Fiber phase. + +The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. + +## Model Experience + +None, as this Host-only inventory projection registers no prompt, tool, message, or provider request. + +#### KV Cache effect + +None; this package never assembles model input. + +## Known Limitations and Deferred Work + +- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md new file mode 100644 index 0000000000..e424b968f1 --- /dev/null +++ b/packages/host/plugin-inventory/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +[English](README.md) | 中文 + +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、本地展示 id、有效启用状态与当前根 Fiber 阶段。 + +阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 + +## 模型体验 + +无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。 + +#### KV Cache 影响 + +无;本包从不组装模型输入。 + +## 已知限制与暂缓事项 + +- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 +- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json new file mode 100644 index 0000000000..4da1a61f06 --- /dev/null +++ b/packages/host/plugin-inventory/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-inventory", + "description": "Read-only Remote projection of current Cordis Loader plugin state", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-inventory" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts new file mode 100644 index 0000000000..8aeafcebed --- /dev/null +++ b/packages/host/plugin-inventory/src/index.ts @@ -0,0 +1,72 @@ +/** Read-only projection of the current Cordis Loader plugin entries. */ + +import type { Context, FiberState } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginEntryId, + PluginFiberPhase, + PluginInventoryEntry, + PluginInventorySnapshot, +} from './types.ts' + +export type * from './types.ts' + +/** Brand an existing Loader-tree entry id at the owning boundary. */ +function pluginEntryId(value: string): PluginEntryId { + return value as PluginEntryId +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete public projection of Cordis Fiber states. */ +const FIBER_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** Remote-only service exposing the Loader's current non-group entry state. */ +export class PluginInventoryService extends GatewayService { + static inject = ['loader'] + + constructor(ctx: Context) { + super(ctx, 'pluginInventory') + } + + /** + * Read the Loader directly on every call. Cordis's internal plugin/status + * events already maintain Entry.fiber and Fiber.state, so a second cache + * would only add another lifecycle truth to keep synchronized. + * @returns Current non-group Loader entries in Loader order. + */ + @Remote('list') + list(): PluginInventorySnapshot { + const entries: PluginInventoryEntry[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + entries.push({ + entryId: pluginEntryId(entry.id), + displayId: entry.options.id, + enabled: !entry.disabled, + fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + }) + } + return { entries } + } +} + +export default PluginInventoryService diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts new file mode 100644 index 0000000000..34acc058aa --- /dev/null +++ b/packages/host/plugin-inventory/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory' + +/** Cordis companion plugin name. */ +export const name = 'host-plugin-inventory-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts new file mode 100644 index 0000000000..d1c81f5310 --- /dev/null +++ b/packages/host/plugin-inventory/src/types.ts @@ -0,0 +1,28 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable Loader-tree identity of one configured plugin entry. */ +export type PluginEntryId = Branded<'PluginEntryId'> + +/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */ +export type PluginFiberPhase = + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | null + +/** One non-group Loader entry exposed to trusted clients. */ +export interface PluginInventoryEntry { + readonly entryId: PluginEntryId + /** Local Loader id used as the compact card title. */ + readonly displayId: string + /** Effective Loader enablement, including disabled ancestor groups. */ + readonly enabled: boolean + readonly fiberPhase: PluginFiberPhase +} + +/** Point-in-time inventory returned by the plugin inventory Remote. */ +export interface PluginInventorySnapshot { + readonly entries: readonly PluginInventoryEntry[] +} diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts new file mode 100644 index 0000000000..d7e3b99fd8 --- /dev/null +++ b/packages/host/plugin-inventory/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginInventoryInvariant from '../src/invariant.ts' + +describe('plugin-inventory invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(PluginInventoryInvariant) + await expect(fiber.await()).resolves.toBeDefined() + await fiber.dispose() + await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts new file mode 100644 index 0000000000..a822fab2c2 --- /dev/null +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import PluginInventoryService from '../src/index.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const activePlugin: Plugin.Function = () => {} +const pendingPlugin: Plugin.Object = { + inject: ['neverReady'], + apply() {}, +} + +async function harness(): Promise<{ + ctx: Context + inventory: PluginInventoryService +}> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = activePlugin + ctx.loader.builtins.pending = pendingPlugin + await ctx.plugin(PluginInventoryService) + const inventory = ctx.get('pluginInventory') as PluginInventoryService + return { ctx, inventory } +} + +describe('PluginInventoryService', () => { + it('publishes one direct list method under the pluginInventory namespace', async () => { + const { inventory } = await harness() + expect(inventory.typertGateway).toMatchObject({ + serviceKey: 'pluginInventory', + namespace: 'pluginInventory', + }) + expect(remoteMethods(inventory)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + ]) + }) + + it('projects current non-group Loader entries without a second cache', async () => { + const { ctx, inventory } = await harness() + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const pendingId = await ctx.loader.create({ name: 'cordis:pending' }) + const disabledId = await ctx.loader.create({ + name: 'cordis:not-installed', + disabled: true, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(inventory.list()).toEqual({ + entries: [ + { + entryId: activeId, + displayId: activeId, + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + displayId: pendingId, + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + displayId: disabledId, + enabled: false, + fiberPhase: null, + }, + ], + }) + + await ctx.loader.update(activeId, { disabled: true }) + expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + entryId: activeId, + displayId: activeId, + enabled: false, + fiberPhase: null, + }) + + await ctx.loader.remove(pendingId) + expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) +}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json new file mode 100644 index 0000000000..524783f8b8 --- /dev/null +++ b/packages/host/plugin-inventory/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e71c12536..9d6c610aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1593,6 +1596,9 @@ importers: '@deepseek-ai/dsh-client-ui-plugin-config': specifier: workspace:^ version: link:../../client/ui-plugin-config + '@deepseek-ai/dsh-client-ui-plugins': + specifier: workspace:^ + version: link:../../client/ui-plugins '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -1656,6 +1662,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2582,6 +2591,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -4942,6 +4993,28 @@ importers: specifier: workspace:^ version: link:../../support/invariants + packages/host/plugin-inventory: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b11a8845e8..f2270444ac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -91,6 +91,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugins': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, @@ -105,6 +106,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, + 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..b373642dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,8 @@ "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], + "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], + "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -199,6 +201,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-ui-plugins": ["./packages/client/ui-plugins/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, diff --git a/tsconfig.client.json b/tsconfig.client.json index ce48a77ea9..6def6dddd9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,6 +81,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/ui-plugins" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..2f055167a0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -281,6 +281,7 @@ { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, + { "path": "./packages/host/plugin-inventory" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" }, From 4cf76f23622937d2672e616eeb699628ce90ad79 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:12:37 -0700 Subject: [PATCH 08/18] Fix plugin status tag clipping --- .../ui-plugins/src/client/PluginSettingsSection.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index eb26d02298..c2cf16cf16 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -129,7 +129,6 @@ align-items: center; justify-content: space-between; gap: 12px; - width: 100%; min-height: 52px; border: 0; padding: 12px 14px; From 2614a2df93b038505fda3d505ff2b920f7cb1db2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:03:39 -0700 Subject: [PATCH 09/18] Refine plugin inventory card details --- .../settings-chrome/plugins.expected.md | 10 ++- packages/client/ui-plugins/README.i18n.yaml | 4 +- packages/client/ui-plugins/README.md | 2 +- packages/client/ui-plugins/README.zh.md | 2 +- .../client/PluginSettingsSection.module.css | 74 +++++++++++++++++++ .../src/client/PluginSettingsSection.tsx | 45 ++++++++++- .../client/ui-plugins/src/client/locales.ts | 18 +++-- .../ui-plugins/tests/components.spec.tsx | 19 ++++- 8 files changed, 153 insertions(+), 21 deletions(-) diff --git a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md index e3b2e9ee54..9e8362a942 100644 --- a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md @@ -1,4 +1,6 @@ -- listitem "ui-settings, 存活, 已启用": - - strong: ui-settings - - img "存活" - - text: 已启用 +- listitem: + - button "ui-settings, 已挂载, 已启用": + - strong: ui-settings + - img "已挂载" + - text: 已启用 + - img diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml index ce5113cde1..62085c3d6b 100644 --- a/packages/client/ui-plugins/README.i18n.yaml +++ b/packages/client/ui-plugins/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-plugins/README.md -README.md: c3236935b2a568c6996fcac8469f061a813cd95c -README.zh.md: 77810bccf95a55761518f89909fddd99818f686a +README.md: bb487d5e2cbd34406d83867997ede4d70b190d70 +README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9 diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md index c3236935b2..bb487d5e2c 100644 --- a/packages/client/ui-plugins/README.md +++ b/packages/client/ui-plugins/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). -The page renders a searchable two-column catalog of compact cards. Each card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. +The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. ## Model Experience diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md index 77810bccf9..48a1191150 100644 --- a/packages/client/ui-plugins/README.zh.md +++ b/packages/client/ui-plugins/README.zh.md @@ -4,7 +4,7 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 -页面以可搜索的双列紧凑卡片展示清单。每张卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 +页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 ## 模型体验 diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index c2cf16cf16..9429b60bb5 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -124,11 +124,18 @@ background: var(--dsw-alias-bg-layer-3); } +.card[data-open='true'] { + border-color: var(--dsw-alias-border-l1); + box-shadow: var(--dsw-shadow-lv1); +} + .cardContent { + box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 12px; + width: 100%; min-height: 52px; border: 0; padding: 12px 14px; @@ -136,6 +143,17 @@ color: inherit; font: inherit; text-align: left; + cursor: pointer; +} + +.cardContent:hover, +.card[data-open='true'] > .cardContent { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cardContent:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; } .cardTitle { @@ -195,6 +213,56 @@ color: var(--dsw-alias-state-success-primary); } +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.card[data-open='true'] .chevron { + transform: rotate(180deg); +} + +.cardDetails { + border-top: 1px solid var(--dsw-alias-border-l2); + padding: 10px 14px 12px; + background: var(--dsw-alias-bg-module-platform); +} + +.entryValue { + display: block; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.details { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 6px 10px; + margin: 8px 0 0; +} + +.details div { + display: contents; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 17px; +} + +.details dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 17px; +} + .visuallyHidden { position: absolute; width: 1px; @@ -205,6 +273,12 @@ white-space: nowrap; } +@media (prefers-reduced-motion: no-preference) { + .chevron { + transition: transform 140ms var(--ds-ease-in-out); + } +} + @media (max-width: 680px) { .cards { grid-template-columns: minmax(0, 1fr); diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 2d33ac2e8b..fc04e9b12a 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -1,6 +1,9 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' -import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconChevronDownOutline14, + IconSearchOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginsKey } from './locales.ts' import css from './PluginSettingsSection.module.css' @@ -54,6 +57,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): const titleId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState(null) const [state, setState] = useState({ status: 'loading' }) useEffect(() => { @@ -73,6 +77,12 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): [normalizedQuery, state], ) + useEffect(() => { + if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { + setExpanded(null) + } + }, [expanded, filteredEntries]) + const retry = (): void => { setState({ status: 'loading' }) setRequest(value => value + 1) @@ -115,14 +125,25 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
    {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) + const open = expanded === entry.entryId + const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` return (
  • -
    +
    + + {open ? ( +
    + {entry.entryId} +
    +
    +
    {t('configuration')}
    +
    {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
    +
    +
    +
    {t('cordis')}
    +
    {status}
    +
    +
    +
    + ) : null}
  • ) })} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts index 64745ce14f..c505296f38 100644 --- a/packages/client/ui-plugins/src/client/locales.ts +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -13,11 +13,13 @@ export const zh = { emptySearch: '没有匹配的插件。', enabledTag: '已启用', disabledTag: '已停用', - unobserved: '无根 Fiber', + configuration: '配置状态', + cordis: 'Cordis 状态', + unobserved: '未挂载', pending: '等待依赖', loadingPhase: '加载中', - active: '存活', - failed: '失败', + active: '已挂载', + failed: '挂载失败', unloading: '卸载中', } satisfies Record @@ -37,10 +39,12 @@ export const en = { emptySearch: 'No matching plugins.', enabledTag: 'Enabled', disabledTag: 'Disabled', - unobserved: 'No root Fiber', - pending: 'Pending', + configuration: 'Configuration', + cordis: 'Cordis status', + unobserved: 'Not mounted', + pending: 'Waiting for dependencies', loadingPhase: 'Loading', - active: 'Active', - failed: 'Failed', + active: 'Mounted', + failed: 'Mount failed', unloading: 'Unloading', } satisfies Record diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.spec.tsx index af49f96ad5..059d09b738 100644 --- a/packages/client/ui-plugins/tests/components.spec.tsx +++ b/packages/client/ui-plugins/tests/components.spec.tsx @@ -50,10 +50,25 @@ describe('PluginSettingsSection', () => { expect(screen.getAllByRole('listitem')).toHaveLength(6) expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) expect(screen.getByText(en.disabledTag)).toBeTruthy() - for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) { + for (const value of [ + 'Mounted', + 'Waiting for dependencies', + 'Loading', + 'Mount failed', + 'Unloading', + 'Not mounted', + ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } - expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy() + const active = screen.getByRole('button', { name: 'active-name, Mounted, Enabled' }) + expect(active.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(active) + expect(active.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('active') + expect(screen.getByText(en.configuration)).toBeTruthy() + expect(screen.getByText(en.cordis)).toBeTruthy() + fireEvent.click(active) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() }) it('filters by local id or Loader entry id', async () => { From eea356e7852713a95e8a109506191e998f993272 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:54:29 -0700 Subject: [PATCH 10/18] Show module names for dynamic plugins --- .../src/client/PluginSettingsSection.tsx | 16 ++++++++++--- .../ui-plugins/tests/components.spec.tsx | 24 +++++++++---------- .../host/plugin-inventory/README.i18n.yaml | 4 ++-- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/src/index.ts | 2 +- packages/host/plugin-inventory/src/types.ts | 4 ++-- .../plugin-inventory/tests/inventory.spec.ts | 8 +++---- 8 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index fc04e9b12a..6fdf058c02 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -45,10 +45,19 @@ function phaseLabel( return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } +/** Compact a module specifier without guessing whether its Loader id was generated. */ +function moduleShortName(moduleName: string): string { + const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName + return unscoped + .replace(/^cordis:/, '') + .replace(/^cordis-plugin-/, '') + .replace(/^dsh-(?:host-|client-)?/, '') +} + /** Whether an inventory row matches the local catalog query. */ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { if (normalizedQuery.length === 0) return true - return [entry.displayId, entry.entryId] + return [entry.moduleName, entry.entryId] .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) } @@ -125,6 +134,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
      {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) + const title = moduleShortName(entry.moduleName) const open = expanded === entry.entryId const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` return ( @@ -139,12 +149,12 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): type="button" aria-expanded={open} aria-controls={detailId} - aria-label={`${entry.displayId}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`} + aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`} onClick={() => { setExpanded(current => current === entry.entryId ? null : entry.entryId) }} > - {entry.displayId} + {title} { ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } - const active = screen.getByRole('button', { name: 'active-name, Mounted, Enabled' }) + const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) expect(active.getAttribute('aria-expanded')).toBe('false') fireEvent.click(active) expect(active.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('active') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') expect(screen.getByText(en.configuration)).toBeTruthy() expect(screen.getByText(en.cordis)).toBeTruthy() fireEvent.click(active) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() }) - it('filters by local id or Loader entry id', async () => { + it('filters by module name or Loader entry id', async () => { render( SNAPSHOT)} />) const search = await screen.findByRole('searchbox', { name: en.search }) fireEvent.change(search, { target: { value: 'disabled-entry' } }) expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('disabled-name')).toBeTruthy() + expect(screen.getByText('directory-picker-native')).toBeTruthy() - fireEvent.change(search, { target: { value: 'pending' } }) + fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('pending-name')).toBeTruthy() + expect(screen.getByText('hmr')).toBeTruthy() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) expect(screen.queryAllByRole('listitem')).toHaveLength(0) diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 761b9c6850..e9fc3f9a09 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/host/plugin-inventory/README.md -README.md: d7a50824d337c66a30ba4332ffb1e36f9ad46547 -README.zh.md: e424b968f1f692fb30b3b0e49efe6c3cd5fadf70 +README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de +README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index d7a50824d3..23fbf07d79 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, local display id, effective enablement, and current root Fiber phase. +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index e424b968f1..87058cde59 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、本地展示 id、有效启用状态与当前根 Fiber 阶段。 +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 8aeafcebed..5bc4db936a 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -60,7 +60,7 @@ export class PluginInventoryService extends GatewayService { if (entry.options.group) continue entries.push({ entryId: pluginEntryId(entry.id), - displayId: entry.options.id, + moduleName: entry.options.name, enabled: !entry.disabled, fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], }) diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index d1c81f5310..f5678fc3c2 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -15,8 +15,8 @@ export type PluginFiberPhase = /** One non-group Loader entry exposed to trusted clients. */ export interface PluginInventoryEntry { readonly entryId: PluginEntryId - /** Local Loader id used as the compact card title. */ - readonly displayId: string + /** Exact module specifier imported by the Loader entry. */ + readonly moduleName: string /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index a822fab2c2..e979d34306 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -56,19 +56,19 @@ describe('PluginInventoryService', () => { entries: [ { entryId: activeId, - displayId: activeId, + moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', }, { entryId: pendingId, - displayId: pendingId, + moduleName: 'cordis:pending', enabled: true, fiberPhase: 'pending', }, { entryId: disabledId, - displayId: disabledId, + moduleName: 'cordis:not-installed', enabled: false, fiberPhase: null, }, @@ -78,7 +78,7 @@ describe('PluginInventoryService', () => { await ctx.loader.update(activeId, { disabled: true }) expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ entryId: activeId, - displayId: activeId, + moduleName: 'cordis:active', enabled: false, fiberPhase: null, }) From 46c0e3dba7b1c108a8f5d8a981c4ba0c2fdcfece Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:38:11 -0700 Subject: [PATCH 11/18] fix(ci): align plugin inventory with current contracts --- packages/api/remotes/src/client/index.ts | 1 + packages/client/ui-plugins/package.json | 2 +- .../src/client/PluginSettingsSection.tsx | 7 +++---- packages/client/ui-plugins/src/client/index.ts | 9 +++++++-- ...in.spec.tsx => browser-plugin.client.spec.tsx} | 8 +++++++- ...onents.spec.tsx => components.client.spec.tsx} | 15 ++++++++++++++- ...invariant.spec.ts => invariant.client.spec.ts} | 0 packages/host/plugin-inventory/package.json | 6 ++---- 8 files changed, 35 insertions(+), 13 deletions(-) rename packages/client/ui-plugins/tests/{browser-plugin.spec.tsx => browser-plugin.client.spec.tsx} (87%) rename packages/client/ui-plugins/tests/{components.spec.tsx => components.client.spec.tsx} (87%) rename packages/client/ui-plugins/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 2f077dfd75..026fd864f2 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -7,6 +7,7 @@ import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' +export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json index cca1843929..07fb9d162c 100644 --- a/packages/client/ui-plugins/package.json +++ b/packages/client/ui-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugins", "description": "Read-only Cordis Loader plugin inventory in Web settings", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 6fdf058c02..87d6486000 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' import { IconChevronDownOutline14, IconSearchOutline16, @@ -11,10 +11,9 @@ import css from './PluginSettingsSection.module.css' /** Registration-side Remote face used by the section. */ export interface PluginSettingsSectionInjected { /** Read a current Host inventory snapshot. */ - list: ClientRemote['pluginInventory']['list'] + list: () => Promise } -type PluginInventorySnapshot = Awaited> type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] @@ -119,7 +118,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): value={query} placeholder={t('search')} aria-label={t('search')} - onChange={event => setQuery(event.currentTarget.value)} + onChange={(event) => { setQuery(event.currentTarget.value) }} />
      diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts index b3034a86f9..ccf12ab989 100644 --- a/packages/client/ui-plugins/src/client/index.ts +++ b/packages/client/ui-plugins/src/client/index.ts @@ -1,6 +1,5 @@ /** Read-only Host plugin inventory registered into Web Settings. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -28,7 +27,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') const t = ctx.locale.bind(NS) - const list: ClientRemote['pluginInventory']['list'] = () => ctx.remote.pluginInventory.list() + const list: PluginSettingsSectionInjected['list'] = async () => { + const result = await ctx.remote.pluginInventory.list() + if (!result.ok) { + throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } const injected = (): PluginSettingsSectionInjected => ({ list }) ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx similarity index 87% rename from packages/client/ui-plugins/tests/browser-plugin.spec.tsx rename to packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx index c2e3c741fd..d9d8a43cd8 100644 --- a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -14,6 +14,9 @@ usePinnedBrowserLanguages('zh-CN') afterEach(cleanup) const EMPTY = { entries: [] } +type ListResult = + | { readonly ok: true; readonly value: typeof EMPTY } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } } async function bench() { const ctx = new Context() @@ -26,7 +29,8 @@ async function bench() { } } new RemoteService(ctx) - const list = vi.fn(() => Promise.resolve(EMPTY)) + const list = vi.fn<() => Promise>() + .mockResolvedValue({ ok: true, value: EMPTY }) ctx.provide('remote.pluginInventory', { list }) return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } } @@ -58,6 +62,8 @@ describe('ui-plugins browser plugin', () => { const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() await expect(injected.list()).resolves.toEqual(EMPTY) expect(b.list).toHaveBeenCalledOnce() + b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } }) + await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable') await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx similarity index 87% rename from packages/client/ui-plugins/tests/components.spec.tsx rename to packages/client/ui-plugins/tests/components.client.spec.tsx index 9e4fcbc5f3..9da8a79b0d 100644 --- a/packages/client/ui-plugins/tests/components.spec.tsx +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -27,7 +27,7 @@ function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSecti const SNAPSHOT = { entries: [ { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, - { entryId: 'pending', moduleName: '@fixture/pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, @@ -69,6 +69,14 @@ describe('PluginSettingsSection', () => { expect(screen.getByText(en.cordis)).toBeTruthy() fireEvent.click(active) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + + fireEvent.click(active) + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { + target: { value: 'disabled-entry' }, + }) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) }) it('filters by module name or Loader entry id', async () => { @@ -111,5 +119,10 @@ describe('PluginSettingsSection', () => { const pending = render( deferred.promise)} />) pending.unmount() await act(async () => { deferred.resolve(SNAPSHOT) }) + + const deferredFailure = Promise.withResolvers() + const pendingFailure = render( deferredFailure.promise)} />) + pendingFailure.unmount() + await act(async () => { deferredFailure.reject(new Error('late failure')) }) }) }) diff --git a/packages/client/ui-plugins/tests/invariant.spec.ts b/packages/client/ui-plugins/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-plugins/tests/invariant.spec.ts rename to packages/client/ui-plugins/tests/invariant.client.spec.ts diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 4da1a61f06..ac51ce4aa8 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -45,9 +45,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "dependencies": { From d57463c2fbab8c17cc1107fb12171b0ee80d100d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 12:50:40 +0800 Subject: [PATCH 12/18] fix(web): end first-run onboarding on any usable provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step and the Models page both asked one question of a join that describes every provider: is deepseek-official's credential stored? A user who configured some other route was taken over on every blank session, and the DeepSeek setup card opened over them on every visit to Models with a Cancel that could not close it — while clearing the add card's draft, because it shared the row-editor close handler. providerUsable(row) now answers what both surfaces need: the route is registered and whatever credential its profile names is stored. Readiness (renamed onboardingReadiness) ends on any usable row, needsSetup takes the same fact, and each card kind owns its own close handler. Fixes #2325 --- ...-onboarding-reads-every-provider.i18n.yaml | 6 + ...6-08-12-onboarding-reads-every-provider.md | 38 ++++ ...8-12-onboarding-reads-every-provider.zh.md | 38 ++++ .../tests/onboarding-usable-provider.e2e.ts | 128 +++++++++++ .../dismissed.expected.md | 71 +++++++ apps/web/tsconfig.json | 1 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/DeepSeekOnboardingDialog.tsx | 18 +- .../ui-models/src/client/ModelsSection.tsx | 58 +++-- packages/client/ui-models/src/client/store.ts | 59 +++-- .../tests/components.client.spec.tsx | 201 +++++++++++------- .../ui-models/tests/readiness.client.spec.ts | 83 +++++--- tsconfig.host.json | 1 + 15 files changed, 557 insertions(+), 157 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md create mode 100644 apps/web/tests/onboarding-usable-provider.e2e.ts create mode 100644 apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml new file mode 100644 index 0000000000..cc3873f137 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md +2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d +2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md new file mode 100644 index 0000000000..1f247a6c93 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md) + +## Problem + +The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading. + +A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured. + +On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself. + +## Decision + +One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key. + +`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering). + +`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card. + +Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it. + +## Alternatives considered + +- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. +- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. +- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. + +## Consequences + +Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface. + +## Testing + +Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md new file mode 100644 index 0000000000..fc6e43195a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +[English](2026-08-12-onboarding-reads-every-provider.md) | 中文 + +## Problem + +首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。 + +配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。 + +在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。 + +## Decision + +一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。 + +`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。 + +`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。 + +现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。 + +## Alternatives considered + +- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 +- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 +- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 + +## Consequences + +引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。 + +## Testing + +包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。 diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts new file mode 100644 index 0000000000..09e3923064 --- /dev/null +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -0,0 +1,128 @@ +// Keyless browser e2e: a user who configures some OTHER provider is not asked +// for the official DeepSeek key again, and the first-run setup card is a card +// they can close. The shipped DeepSeek adapter stays mounted without a +// credential throughout, so the only thing that ends onboarding here is the +// pi-ai route the user configures through the real wire. Zero model calls: +// configuration is pure settings/credentials/llm-domain traffic. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url)) +const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md') +const MODE = webSnapshotMode() +const CREDENTIAL_STEP = '添加一个 API Key 开始使用' + +describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('closes the setup card without discarding the add card beside it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP }) + await credentialStep.waitFor({ timeout: 15_000 }) + await credentialStep.getByRole('button', { name: '前往配置' }).click() + await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) + + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + // Nothing is reachable yet, so DeepSeek presents itself as its open card. + const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) + await setupKey.waitFor({ timeout: 10_000 }) + + const add = settings.getByRole('button', { name: '添加提供方' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + const pick = settings.getByLabel('提供方') + await pick.waitFor({ timeout: 10_000 }) + await pick.selectOption('minimax-cn') + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(2) + + // Cancelling the setup card is the regression: it used to leave itself open + // and close the add card, discarding that draft. + await settings.getByRole('button', { name: '取消', exact: true }).first().click() + expect(await settings.getByLabel('提供方').count()).toBe(1) + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) + // DeepSeek is now an ordinary row: a missing-key dot and an Edit button. + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE) + + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('stops prompting for DeepSeek once the other provider can serve requests', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') + await settings.getByRole('button', { name: '保存', exact: true }).click() + await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + + // Only minimax-cn is reachable; DeepSeek still holds no credential. + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') + expect(credentials).not.toContain('DEEPSEEK_API_KEY') + + const warningsBefore = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) + // The regression: the step read only the official route's credential, so a + // fully configured user was taken over on every blank session. + await expect.poll( + async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(), + { timeout: 10_000 }, + ).toBe(0) + expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false) + + // The Models page agrees: DeepSeek stays a row rather than reopening its + // setup card over a user who already has somewhere to send a request. + await page.getByRole('button', { name: '设置', exact: true }).click() + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) + + expect((await page.content()).includes('sk-e2e-minimax')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md new file mode 100644 index 0000000000..182fadf973 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -0,0 +1,71 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "插件配置": + - img + - text: 插件配置 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥缺失" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: 提供方 + - combobox "提供方": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b1153f4b8e..e656b099ea 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/default-model.e2e.ts", "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 1a4c53cc8d..671d4a2bfd 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff -README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de +README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b +README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index f6604f8224..a8d030b767 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2500bbae09..63fb1b486a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,9 +4,9 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index c8668c3700..302d4592f8 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,7 +1,9 @@ /** * Official-DeepSeek first-run step. Readiness comes from the same - * provider/settings/credential join as the Models page; the prompt only - * routes the user to that page's single credential editor. + * provider/settings/credential join as the Models page: any provider the user + * can already talk to ends the step, and only a user with none is offered the + * official DeepSeek route. The prompt itself only routes to that page's single + * credential editor. */ import { useEffect, useRef } from 'react' @@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' -import { deepSeekReadiness } from './store.ts' +import { onboardingReadiness } from './store.ts' import type { en } from './locales.ts' import styles from './DeepSeekOnboardingDialog.module.css' @@ -34,15 +36,15 @@ function assertNever(_value: never): never { } /** - * Prompt a first-run user to open Models while the official adapter exists - * and its effective credential is not configured. + * Prompt a first-run user to open Models while no provider can serve requests + * and the official adapter exists with an unconfigured effective credential. * @param props - settings-shell owner state and Models feature dependencies. * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) - const readiness = deepSeekReadiness(state) + const readiness = onboardingReadiness(state) const titleRef = useRef(null) useEffect(() => { @@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): useEffect(() => { if ( readiness.kind === 'adapter-absent' - || readiness.kind === 'configured' + || readiness.kind === 'provider-ready' || readiness.kind === 'unavailable' ) complete() }, [complete, readiness.kind]) @@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): switch (readiness.kind) { case 'loading': case 'adapter-absent': - case 'configured': + case 'provider-ready': case 'unavailable': return null case 'credential-missing': diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 1eba48903e..5fe5647b88 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -3,11 +3,13 @@ * directory, settings namespaces, and credential states, with one editor * card at a time. Rows expose only confirmed API-key state through accessible * solid configured or missing dots. A whole-section provider without a - * configured key (the unconfigured DeepSeek posture) renders as its open setup - * card instead of a row; the add flow is a card carrying the dormant-provider - * select. Every mutation writes through the wire, while a provider removal first requires - * confirmation; the page re-renders from pushed invalidations or the - * post-apply reload. + * configured key renders as its open setup card instead of a row, but only in + * the first-run posture — no provider on the page can serve requests yet — and + * only until the user closes that card; the add flow is a card carrying the + * dormant-provider select. Each card kind owns its own open state, so closing + * one never discards a draft in another. Every mutation writes through the + * wire, while a provider removal first requires confirmation; the page + * re-renders from pushed invalidations or the post-apply reload. */ import { useState } from 'react' @@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -116,11 +118,15 @@ export async function removeProviderProfile( /** * Whether a whole-section provider still needs its first key: an unconfigured - * credential opens the setup card instead of showing a row. + * credential opens the setup card instead of showing a row. This is the + * first-run posture alone — a user who can already reach some provider gets an + * ordinary row with the missing-key dot, since nothing here is blocking them. * @param row - the joined provider row. + * @param anyUsable - whether any joined row can already serve requests. * @returns whether to render the setup card. */ -export function needsSetup(row: ProviderRow): boolean { +export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { + if (anyUsable) return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) const [declaring, setDeclaring] = useState(false) + const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) + + const announceSaved = (target: ProviderIdentity): void => { + // Announced only once the refreshed directory is in the snapshot the + // notice reads its name from: an apply can rename the route, and the + // target captured when the card opened still carries the old name. + void controller.load().then(() => { setSavedTarget(target) }) + } const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) setDeclaring(false) - if (changed) { - // Announced only once the refreshed directory is in the snapshot the - // notice reads its name from: an apply can rename the route, and the - // target captured when the card opened still carries the old name. - void controller.load().then(() => { setSavedTarget(target) }) - } + if (changed) announceSaved(target) + } + + /** + * Close a setup card, which owns none of the state above: the row-editor, + * add, and declare cards each own one of those, so clearing them here would + * discard a draft the user opened beside this card. Dismissal is this card's + * own — the provider falls back to an ordinary row for the rest of the + * session, and reopens through Edit. + */ + const closeSetup = (changed: boolean, target: ProviderIdentity): void => { + setDismissedSetup(previous => new Set([...previous, target.provider])) + if (changed) announceSaved(target) } const closeDelete = (): void => { @@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? savedTarget : { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName } + // One fact decides both first-run postures on this page and the onboarding + // step: whether the user already has a provider to talk to. + const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') const addTarget = adding ? editing : undefined @@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null - if (needsSetup(row)) { + if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the - // setup card IS its presence on the page. + // setup card IS its presence on the page, until the user closes it. return (
    • {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) }, })}
    • ) diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 9cc2cb7c77..4389b9a6cb 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/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', diff --git a/packages/client/ui-models/tests/components.client.spec.tsx b/packages/client/ui-models/tests/components.client.spec.tsx index b1582a5fb8..01f0a32349 100644 --- a/packages/client/ui-models/tests/components.client.spec.tsx +++ b/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[0] -async function mountSection(overrides: Parameters[0] = {}) { - const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) +async function mountFace(scripted: ReturnType) { + 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[0] = {}) return { view, face, update, replace, mutate, set, unset, controller } } +async function mountSection(overrides: Parameters[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[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[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(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(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(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(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + const editorKey = await screen.findByLabelText(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(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(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(en.baseUrl) - expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) - const keys = screen.getAllByLabelText(en.keyInput) - const addKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + const addKey = screen.getByLabelText(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(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(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(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(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(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(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(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(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(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(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(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(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) diff --git a/packages/client/ui-models/tests/readiness.client.spec.ts b/packages/client/ui-models/tests/readiness.client.spec.ts index 8647a2da83..f01e821767 100644 --- a/packages/client/ui-models/tests/readiness.client.spec.ts +++ b/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 { } } +/** A second provider the user configured themselves. */ +function otherRow(overrides: Partial = {}): 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 { return { status: 'ready', @@ -35,12 +53,25 @@ function state(overrides: Partial = {}): 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', }) diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..5002ec7e45 100644 --- a/tsconfig.host.json +++ b/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", From 625711e71824cb3c5436b0a4834a5c4f58ef676b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 8 Aug 2026 11:04:11 +0800 Subject: [PATCH 13/18] fix(client): distinguish grep/glob rows from the web search row The grep, glob, and web_search tool rows all rendered as "Search" with the same magnifier icon, so a transcript full of local searches was indistinguishable from web searches. Grep and glob now carry their own command-named titles through TOOL_TITLES, and the web_search row wears a new globe glyph (IconGlobeOutline14) while keeping its "Search" title. --- packages/client/ui-primitives/src/icons/index.tsx | 12 ++++++++++++ .../client/ui-primitives/tests/icons.client.spec.tsx | 4 ++-- .../ui-tool/src/client/tool/toolviews/search-row.tsx | 9 +++++++-- .../ui-tool/src/client/tool/toolviews/web-row.tsx | 5 +++-- .../client/ui-tool/tests/search-card.client.spec.tsx | 5 ++++- .../client/ui-tool/tests/web-card.client.spec.tsx | 3 +++ 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 972e0ec14d..2606f54e62 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -31,6 +31,18 @@ export const IconSearchOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_globe_outline_14 — meridian globe (harness-only figma extract). */ +export const IconGlobeOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_ds_settings_outline_14 */ export const IconSettingsOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index f6560a4cc1..41f8caad47 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(68) + it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(69) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 47b17b6fa8..e49189d6d4 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -23,8 +23,13 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' /** Full row props: the toolview runtime share plus the standard locale seat. */ type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const SEARCH_TITLES: Record = { + grep: 'Grep', + glob: 'Glob', +} + /** - * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the + * Search row: icon + Grep/Glob · {summary} in the shared ToolRow chrome, with the * completed search's card as the row's collapsed-by-default card body (a capped * search's recovery footer rides below it, inside ToolRow). Registered under * both `grep` and `glob`; the derived model's `kind` decides the card shape. A @@ -40,7 +45,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={SEARCH_TITLES[toolName] ?? model.title} // The result view's replacement title outranks the args-derived summary, // matching the terminal card's description precedence. summary={search?.title ?? model.summary} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index c0e546f071..3dab222e95 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -10,7 +10,7 @@ // summary line alone. import type { Context } from '@deepseek-ai/cordis' -import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { webCardModel } from '../models/web-card-model.ts' @@ -35,7 +35,8 @@ const WEB_TITLES: Record = { export function WebRow({ toolName, block, inspect, t }: WebRowProps) { const model = toolRowModel(toolName, block) const web = webCardModel(block) - const icon = toolName === 'web_fetch' ? : + // Web search uses a globe; local grep/glob keep the magnifier family. + const icon = toolName === 'web_fetch' ? : return ( { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Grep')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() // Collapsed: the card is not in the DOM until the row is expanded. expect(searchKindOf(view.container)).toBeNull() expect(view.queryByText(/const foo = 1/)).toBeNull() @@ -259,6 +260,8 @@ describe('SearchRow keyed card', () => { it('expands to the glob path card', () => { const view = render() + expect(view.getByText('Glob')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) expect(view.getByText('src/a.ts')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 8a3efd2627..3b5a7ebf32 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -20,6 +20,7 @@ import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -140,9 +141,11 @@ describe('chat row web body', () => { } it('the WebRow collapses to the summary row, expanding to the full search card', () => { + const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('svg')?.outerHTML).toBe(globe) expect(view.queryByText('Titled')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) From 54dd75a96973b27212754b7000c1f93d02293570 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:41:21 +0800 Subject: [PATCH 14/18] refactor(cmdline): run the program's own commander action instead of a plan callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCmdline(ctx, program): void only adapts commander control flow to the launcher: it parses the immutable cmdlineArgs snapshot and turns help, version, parse errors, and action rejections into a ctx.appExit request. App validation and the ctx.provide of the app-owned service live in the program's own synchronous .action(), which commander runs inside parse — program.error(...) there shares the exit path with a grammar rejection. Deletes the CmdlinePlan export, its unread ctx parameter, the type-unsound (() => ({}) as T) default, and the T | undefined return with its per-caller publish guard. --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- ...026-08-11-cmdline-program-action.i18n.yaml | 6 ++ .../2026-08-11-cmdline-program-action.md | 29 ++++++ .../2026-08-11-cmdline-program-action.zh.md | 29 ++++++ apps/cli/tests/built-bin.e2e.ts | 4 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 2 +- docs/user/develop/basic/publish.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 9 +- packages/boot/cmdline/README.zh.md | 9 +- packages/boot/cmdline/src/index.ts | 96 ++++++++++++------- packages/boot/cmdline/tests/cmdline.spec.ts | 80 +++++++++++----- packages/bundle/headless/src/startup.ts | 25 ++--- packages/bundle/web-app/src/startup.ts | 38 ++++---- 17 files changed, 229 insertions(+), 116 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..97163e3a85 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.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/simplification/2026-08-11-cmdline-program-action.md +2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d +2026-08-11-cmdline-program-action.zh.md: 91036f1c52b60d28055935813d6698205f045422 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md new file mode 100644 index 0000000000..40c4dae1d3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline runs the program's own commander action + +Status: implemented + +English | [中文](2026-08-11-cmdline-program-action.zh.md) + +## Problem + +`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan = (program, ctx) => T`, invoked after a successful parse inside the helper's catch so a plan's `program.error(...)` shared the help/parse-error exit path, with a type-unsound `(() => ({}) as T)` default only tests used and a `ctx` argument no plan read. The whole seam duplicated a slot commander already defines: a command's action handler runs inside `parse`, and `program.error(...)` thrown from it obeys `exitOverride` exactly like a grammar rejection. + +## Decision + +`parseCmdline(ctx, program): void` only adapts commander control flow to the launcher: it parses the immutable `cmdlineArgs` snapshot and turns help, version, parse errors, and action rejections into a `ctx.appExit` request. App code — validation commander's grammar cannot express and the `ctx.provide` of the app-owned service — lives in the program's own synchronous `.action()`, which commander runs on a successful parse and never runs on help or rejection. The `CmdlinePlan` export, its `ctx` parameter, the default plan, and the `T | undefined` return are deleted; both bundle providers publish from their action. Because the `Command` type cannot express the action precondition, `parseCmdline` reads the handler structurally (as `isCommanderError` reads commander's control-flow errors) and refuses at load a program in which no command declares an action — without the guard, a provider that forgot its action (or a stale caller still passing the deleted third argument) parses successfully, publishes nothing, and surfaces only as dependent rows pending on the absent service at settlement. The helper configures `exitOverride` and output on the whole command tree, not the root alone: commander copies those settings into a subcommand only at registration, so a root-only override would let a pre-registered subcommand's rejection call `process.exit` past `ctx.appExit`. An action must reject before it publishes; statements before its `program.error(...)` have already run. + +Verified on commander 15 before shipping: an action runs inside `parse` and its `program.error(...)` throws a `CommanderError` through `exitOverride`; help and version short-circuit before the action; excess-argument handling is identical with and without an action. + +## Alternatives considered + +- **Keeping a bespoke `resolve`/plan callback**: it existed only so app rejection could share the helper's catch, which commander's action slot already provides; a second callback seam for the same moment in the parse lifecycle is duplication. +- **Returning the parsed `Command` for the caller to read**: a post-parse `program.error(...)` in the caller escapes the helper's catch as an uncaught `CommanderError`, turning a usage rejection into a plugin load failure; every app with validation would rebuild the try/catch the helper owns. +- **Moving all validation into commander option/argument parsers**: `InvalidArgumentError` covers per-value checks, but the headless bundle rejects a joined variadic ("task must be non-blank") with its own usage message, which per-argument parsers cannot express. +- **Accepting an action-less program and relying on the settlement diagnostic**: the assembled launcher does fail loud (`pending (waiting for service: …)`), but that error names the consumers, not the misconfigured provider, and an embedding host without the settlement assertion would hang silently; the load-time guard reports the culprit program directly. +- **Replacing the `CmdlineArgs` accessor with a bare frozen `readonly string[]` service**: the maintainer keeps the accessor object as the service's named interface. + +## Consequences + +- `parseCmdline` loses its generic, callback parameter, and `undefined` sentinel; callers lose the `if (values !== undefined)` publish guard. +- An app's command is self-contained — flags, help text, validation, and the publishing effect travel together on the `Command`. +- Actions must be synchronous: the helper calls `parse`, not `parseAsync`, so a returned promise would escape the catch unobserved. diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md new file mode 100644 index 0000000000..91036f1c52 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline 运行 program 自己的 commander action + +Status: implemented + +[English](2026-08-11-cmdline-program-action.md) | 中文 + +## Problem + +`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。 + +## Decision + +`parseCmdline(ctx, program): void` 只把 commander 的控制流适配到启动器:它解析不可变的 `cmdlineArgs` 快照,并把 help、version、解析错误与 action 的拒绝转换为一次 `ctx.appExit` 请求。应用代码——commander 语法表达不了的校验,以及应用自有服务的 `ctx.provide`——放在 program 自己的同步 `.action()` 里,commander 在解析成功时运行它,在 help 或拒绝时绝不运行。`CmdlinePlan` 导出、其 `ctx` 参数、默认 plan 与 `T | undefined` 返回值全部删除;两个组合包提供方都在各自的 action 中发布。由于 `Command` 类型无法表达 action 前置条件,`parseCmdline` 按结构读取处理器(如同 `isCommanderError` 按结构识别 commander 的控制流错误),在加载时拒绝整棵命令树中没有任何命令声明 action 的 program 并点名它——若无此守卫,漏写 action 的提供方(或仍在传已删除第三参数的陈旧调用方)会解析成功、什么也不发布,只在 settlement 时以依赖行 pending 等待缺席服务的形式浮现。该适配器在整棵命令树而非仅根命令上配置 `exitOverride` 与输出:commander 只在注册时把这些设置复制进子命令,只配置根命令会让已注册子命令的拒绝绕过 `ctx.appExit` 直接调用 `process.exit`。action 必须先拒绝后发布;写在 `program.error(...)` 之前的语句已经执行。 + +交付前已在 commander 15 上验证:action 在 `parse` 内部运行,其 `program.error(...)` 经 `exitOverride` 抛出 `CommanderError`;help 与 version 在 action 之前短路;有无 action 时的多余参数处理完全一致。 + +## Alternatives considered + +- **保留自造的 `resolve`/plan 回调**:它存在的唯一理由是让应用侧的拒绝共用适配器的 catch,而 commander 的 action 席位本就提供这一点;为解析生命周期的同一时刻再造第二条回调接缝属于重复。 +- **返回解析后的 `Command` 交调用方读取**:调用方在解析之后调用 `program.error(...)` 会以未捕获的 `CommanderError` 逃出适配器的 catch,把一次用法拒绝变成插件加载失败;每个带校验的应用都得重建适配器持有的那套 try/catch。 +- **把全部校验移进 commander 的 option/argument 解析器**:`InvalidArgumentError` 覆盖逐值检查,但 headless 组合包用自己的用法信息拒绝拼接后的可变参数("任务不得为空白"),逐参数解析器表达不了。 +- **接受没有 action 的 program,依赖 settlement 诊断**:组装好的启动器确实会大声失败(`pending (waiting for service: …)`),但那个错误点名的是消费者而非配置错误的提供方,且没有 settlement 断言的嵌入宿主会静默挂起;加载时守卫直接报出肇事的 program。 +- **用裸的冻结 `readonly string[]` 服务替换 `CmdlineArgs` 访问器**:维护者保留该访问器对象作为服务的具名接口。 + +## Consequences + +- `parseCmdline` 失去泛型、回调参数与 `undefined` 哨兵值;调用方不再需要 `if (values !== undefined)` 的发布守卫。 +- 应用的命令是自包含的——flag、help 文本、校验与发布效果一起挂在 `Command` 上。 +- action 必须是同步的:适配器调用的是 `parse` 而非 `parseAsync`,返回的 promise 会在无人观察的情况下逃出 catch。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 81acefadc5..760f7c58ad 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -227,8 +227,8 @@ function createStartupFixture(): StartupFixture { "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", - ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', - ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))", + ' parseCmdline(ctx, program)', '}', '', ].join('\n')) diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 91dba947bb..a7b9b1d39a 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.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/publish.md -publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 -publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a +publish.md: 588531a28020ebe620643cd1aaaa43de000e658a +publish.zh.md: 938e4b0aa2ea09f80fce897413e9c57f90d3209a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 8437c7ea5c..588531a280 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -117,7 +117,7 @@ A bundle that defines a runnable app mounts an ordinary provider plugin: name: 'dsh-hello-plugin/startup' ``` -The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides its app-owned service from the program's action. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 4409dbfda0..938e4b0aa2 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -117,7 +117,7 @@ dsh --profile demo name: 'dsh-hello-plugin/startup' ``` -该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再在 program 自己的 action 中把应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9d30c65bb8..22a80a7e13 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/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/boot/cmdline/README.md -README.md: 2e8e58b23785fa78bd2663a459817669309a81be -README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd +README.md: 33125014539e801dbd2952a3b4513cafc80bdcee +README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 2e8e58b237..3312501453 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones ## Ordinary providers and injected config -Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. +`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate. ### How injection orders config diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index c04d76905e..7ef49a1027 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 ## 普通提供方与注入配置 -任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ export function apply(ctx: Context): void { port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 +`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index ebe8d95aee..c053dcb95f 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,7 +8,8 @@ * text, and its parse errors instead of the launcher knowing them. * * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A - * provider may publish the parsed values as its own service, and ordinary rows + * provider may publish the parsed values as its own service from its program's + * commander action, and ordinary rows * can inject that service and read it from lazily resolved config — * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written * beside it. No row has launcher-level command-line status. @@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w stderr: process.stderr, } -/** - * Resolve parsed arguments into an app-owned value. Call - * `program.error(...)` to reject the invocation with a usage message instead - * of throwing. - * @param program - the parsed commander program. - * @param ctx - the plugin context that received the command line. - * @returns the value an ordinary provider plugin may publish. - */ -export type CmdlinePlan = (program: Command, ctx: Context) => T - /** * Parse the launcher's immutable argument snapshot with an app's commander - * program. The caller decides whether and how to publish the returned value; - * this helper has no Loader-row or service ownership semantics. + * program. Commander runs the program's own synchronous action handler on a + * successful parse; app code there publishes its service and rejects an + * invalid invocation with `program.error(...)`. This helper has no Loader-row + * or service ownership semantics. * - * Help, version, and rejected arguments are terminal for the process: commander - * writes the text, the helper requests `ctx.appExit`, and it returns - * `undefined` so the caller publishes nothing. + * Help, version, and rejected arguments — from the grammar or from an action + * — are terminal for the process: commander writes the text and the helper + * requests `ctx.appExit`. The action never runs on help, version, or a + * grammar rejection; an action must reject before it publishes, because + * statements before its `program.error(...)` have already run. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. - * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved value; omitted returns an empty object. - * @returns the resolved value, or `undefined` when the app asked to exit. - * @throws when the launcher did not provide the command line and exit request. + * @param program - the app's commander program, with its flags, description, + * actions, and any subcommands already declared. + * @throws when the launcher did not provide the command line and exit request, + * or when no command in the program declares an action. */ -export function parseCmdline( - ctx: Context, - program: Command, - plan: CmdlinePlan = (() => ({}) as T), -): T | undefined { +export function parseCmdline(ctx: Context, program: Command): void { // Read through the global service store, not the property proxy: appExit is // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') @@ -112,23 +103,54 @@ export function parseCmdline( if (args === undefined || exit === undefined) { throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) } - program + if (!hasAction(program)) { + throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`) + } + configureExitAndOutput(program) + try { + program.parse(args.get(), { from: 'user' }) + } catch (error) { + // exitOverride turns help, version, a parse error, and the action's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + } +} + +/** + * Whether any command in the tree declares an action handler. + * + * The `Command` type cannot express the action precondition, so the handler is + * read structurally (as {@link isCommanderError} reads commander's control-flow + * errors): without this guard, a program that forgot its action would parse + * successfully, publish nothing, and surface only as dependent rows pending on + * the absent service. + * @param command - the command whose tree is inspected. + * @returns true when the command or any registered subcommand has an action. + */ +function hasAction(command: Command): boolean { + if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true + return command.commands.some(hasAction) +} + +/** + * Route every command's exit and output through the launcher adapter. + * + * Commander copies `exitOverride` and output configuration into a subcommand + * only at registration, so a root-only override would let an + * already-registered subcommand's rejection write to the process streams and + * call `process.exit` directly, bypassing `ctx.appExit`. + * @param command - the root of the command tree to configure. + */ +function configureExitAndOutput(command: Command): void { + command .exitOverride() .configureOutput({ writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - try { - program.parse(args.get(), { from: 'user' }) - return plan(program, ctx) - } catch (error) { - // exitOverride turns help, version, a parse error, and a plan's own - // program.error() into a CommanderError; commander has already written the - // text through the output configured above. - if (!isCommanderError(error)) throw error - exit(error.exitCode) - return undefined - } + for (const child of command.commands) configureExitAndOutput(child) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 941bfe727e..d05126a29f 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts' +import { internals, parseCmdline, provideCmdline } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -43,8 +43,8 @@ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { +/** The fixture app's action body: the resolved values its rows read. */ +const resolveDemo = (program: Command): { port?: number } => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) * Mount a two-row composition the way a profile boot does: both rows at once, * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. - * @param plan - the app's plan; defaults to the fixture's own. + * @param resolve - the app's action body; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - plan: CmdlinePlan = demoPlan, + resolve: (program: Command) => unknown = resolveDemo, options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) @@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed globals.__provideDemoArgs = (ctx: Context) => { - const values = parseCmdline(ctx, demoCommand(), plan) - if (values !== undefined) ctx.provide('demoStartup', values) + const program = demoCommand() + program.action(() => { ctx.provide('demoStartup', resolve(program)) }) + parseCmdline(ctx, program) } // The composition, exactly as a profile delivers one: include patches whose @@ -133,7 +134,7 @@ describe('parseCmdline', () => { }) it('recognizes the Loader object form of a provider-service injection', async () => { - const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -144,31 +145,35 @@ describe('parseCmdline', () => { expect(observed.exits).toEqual([0]) }) - it('rejects the invocation from the plan without starting the app', async () => { + it('rejects the invocation from the action without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { throw new Error('plan exploded') } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + it('rethrows an action failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { throw new Error('action exploded') }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { - const thrown: unknown = 'plan threw a string' + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { + const thrown: unknown = 'action threw a string' throw thrown - } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string') }) - it('returns values without inspecting Loader rows or owning a service', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - expect(parseCmdline(ctx, demoCommand())).toEqual({}) + it('runs the action without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + expect(values).toEqual({}) expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -182,6 +187,28 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) + it('refuses at load a program in which no command declares an action', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('no command in the program declares an action') + }) + + it('routes a pre-registered subcommand rejection through the launcher exit request', () => { + const ctx = new Context() + const exits: number[] = [] + let err = '' + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } + provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) }) + // The root declares no action of its own: the tree-wide guard accepts the + // subcommand's, and the subcommand inherits the exit and output routing. + const program = new Command().name('demo') + const child = program.command('serve') + child.action(() => { child.error('error: serve rejected') }) + parseCmdline(ctx, program) + expect(err).toContain('serve rejected') + expect(exits).toEqual([1]) + }) + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() expect(() => { parseCmdline(ctx, demoCommand()) }) @@ -191,8 +218,15 @@ describe('provideCmdline', () => { it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + const parseOnce = (): unknown => { + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + return values + } + expect(parseOnce()).toEqual({ port: 8080 }) + expect(parseOnce()).toEqual({ port: 8080 }) expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index bfb4d44e51..cb56b5ae9a 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -41,22 +41,17 @@ Examples: } /** - * Turn the parsed command line into the runner's task. - * @param program - the parsed headless command. - * @returns the runner's service value. - */ -function planHeadlessStartup(program: Command): HeadlessStartupValues { - const task = program.args.join(' ') - if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - return { task } -} - -/** - * Parse and provide the one-shot task as an ordinary Cordis service. + * Parse and provide the one-shot task as an ordinary Cordis service. The + * command's action publishes the task; a missing or whitespace-only task is a + * usage error, so on rejection (and on `--help`) nothing is provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) - if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) + const program = headlessCommand() + program.action(() => { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues) + }) + parseCmdline(ctx, program) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 90de34b01d..2aaf89a742 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -57,28 +57,24 @@ Examples: } /** - * Turn the parsed flags into the value injected rows read. - * @param program - the parsed web command. - * @returns this invocation's immutable Web options. - */ -function planWebStartup(program: Command): WebStartupValues { - const options = program.opts() - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - return { - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - trustedHosts: options.trustedHost ?? [], - } -} - -/** - * Parse and provide the Web invocation as an ordinary Cordis service. + * Parse and provide the Web invocation as an ordinary Cordis service. The + * command's action publishes the flags this invocation named; a non-numeric + * `--port` is a usage error, so on rejection (and on `--help`) nothing is + * provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) + const program = webCommand() + program.action(() => { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + ctx.provide(WEB_STARTUP_SERVICE, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + trustedHosts: options.trustedHost ?? [], + } satisfies WebStartupValues) + }) + parseCmdline(ctx, program) } From 0a2ac90617a7ba5c7a813c0f2abffb83daad1cc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 13:52:27 +0800 Subject: [PATCH 15/18] docs: fix reference sidebar ordering and group the subsystem pages The VitePress config declared no position for the subsystem or other-interface sections, so `indexOf` returned -1 and sorted them ahead of every declared group: the reference landing page's own sidebar entry sat 1549px below the fold. Four subsystem pages also shared `order` values with pages in the same section, resolved only by sort stability and array concatenation order. Section placement and collapse move into the manifest as a per-locale declaration, and `sectionSpec` throws for an undeclared section instead of sorting it silently to the top. Subsystem pages are grouped by concern, the six topical groups collapse until one holds the page being read, and page order derives from array position. The projector drops the language-switcher line and repository badge the canonical pages carry for their GitHub readers. The navigation bar gains the DeepSeek wordmark, a release-stage tag, and a favicon; the sidebar scrollbar rests invisible and appears while scrolling. Subsystem pages carry a two-level outline, and the two plugin-development tracks now cross-link. --- ...ation-site-navigation-and-chrome.i18n.yaml | 6 + ...ocumentation-site-navigation-and-chrome.md | 37 ++++ ...mentation-site-navigation-and-chrome.zh.md | 37 ++++ docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 2 + docs/cordis-tutorial/index.zh.md | 2 + docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 1 + docs/user/develop/basic/index.zh.md | 1 + docs/user/develop/framework/index.i18n.yaml | 4 +- docs/user/develop/framework/index.md | 1 + docs/user/develop/framework/index.zh.md | 1 + scripts/project-doc-site.spec.ts | 65 +++++- scripts/project-doc-site.ts | 33 ++- website/.vitepress/config.ts | 170 +++++++++++---- website/docs.ts | 204 ++++++++++++------ website/public/favicon.svg | 3 + website/public/wordmark.svg | 19 ++ 18 files changed, 477 insertions(+), 117 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md create mode 100644 website/public/favicon.svg create mode 100644 website/public/wordmark.svg diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml new file mode 100644 index 0000000000..d0e78c1c71 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +2026-08-12-documentation-site-navigation-and-chrome.md: 1b1868a011744decf1c0a25a825fd022ea3609ab +2026-08-12-documentation-site-navigation-and-chrome.zh.md: 0fd685ec8c6b7f3f2fd93013ebaf9266a70b2c81 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md new file mode 100644 index 0000000000..1b1868a011 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -0,0 +1,37 @@ +# 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 `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — 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. + +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: one shared list ordered both label sets by convention and accepted a label missing from either without complaint. + +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. + +`projectedPageContent` in [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) drops the language-switcher line and the repository badge. The switcher match is confined to the first eight lines so a tutorial that shows the convention still renders its example. + +The navigation-bar title is the DeepSeek wordmark inlined into `siteTitle`, which VitePress renders as HTML. Inlining is what lets the mark's `currentColor` fills follow the active theme; `themeConfig.logo` renders an ``, which freezes the mark at the colors its file declares and would need one asset per theme. The sidebar scrollbar rests invisible and appears while scrolling, marked by a `data-` attribute rather than a class because Vue rewrites `class` wholesale when it patches the element. + +## Alternatives considered + +**A search tokenizer for Chinese queries.** Built and reverted. The premise — that MiniSearch leaves Chinese prose as untokenizable whole sentences — was tested against a term (`子代理`) that appears nowhere in the corpus; the Chinese pages write `Subagent` and `子 agent`. Measured against the unmodified index, `插件配置` returns 120 hits, `会话持久化` 85, `工作流` 28, `沙箱` 12, each ranking its own page first: `prefix: true` already reaches Chinese terms through the short tokens punctuation produces. Adjacent-character pairs grew the Chinese index from 1.23MB to 2.12MB for no gain. The attempt also surfaced a trap worth keeping: VitePress ships search-option functions to the browser through `Function.prototype.toString` and rebuilds them with `new Function`, so any such function that closes over a module-level constant throws in an empty scope and silently returns no results. + +**Placing the subsystem groups directly after `概念`.** Rejected: it restores the architecture page to the top but leaves generated reference, the Cordis API, and the cookbook below 43 rows. + +**Rewriting filename link text during projection.** The subsystem index table writes `[core.md](core.md)`, which reads as a repository file index on the site. `scripts/project-doc-site.spec.ts` asserts that exact row format, so the filenames are a deliberate convention rather than an oversight; changing what the site displays means changing the convention and its gate together, not working around them in the projector. + +## Consequences + +The reference sidebar measures 1452px with every subsystem group collapsed, against 2478px before, and the architecture page is its first entry. Section placement and collapse are declared in one manifest instead of split between the manifest and the config, and `scripts/project-doc-site.spec.ts` pins three invariants: every sidebar-owning page resolves a placement, an undeclared section is refused, and no two pages share an `order` within a section. + +Canonical Markdown is unchanged by the chrome stripping — the switcher and badge still serve GitHub readers. The cost is that the projector now knows two presentation conventions of the source corpus, which a page written with a different switcher wording would not match. + +The wordmark is a second copy of a mark that also lives in `apps/web/public/favicon.svg` and `packages/client/ui-primitives/src/FishLogo.tsx`, each carrying its own presentation. A change to the DeepSeek wordmark reaches the documentation site only by updating this copy. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md new file mode 100644 index 0000000000..0fd685ec8c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 文档站导航与仓库 chrome + +Status: implemented + +[English](2026-08-12-documentation-site-navigation-and-chrome.md) | 中文 + +## 问题 + +参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 + +另外,每个规范页面都带有写给 GitHub 读者的行——标题下的语言切换行,部分页面还有仓库徽章——站点原样投影了它们,尽管其导航栏已经提供了这两者。 + +## 决定 + +[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组:单一共享列表既要按约定排列两套标签,又会对任一侧缺失的标签毫无反应。 + +子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 + +[scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) 中的 `projectedPageContent` 会丢弃语言切换行和仓库徽章。切换行的匹配被限制在前八行内,因此展示该约定的教程仍能渲染出它的示例。 + +导航栏标题是内联进 `siteTitle` 的 DeepSeek 字标,VitePress 会将其按 HTML 渲染。内联正是让字标的 `currentColor` 填充跟随当前主题的原因;`themeConfig.logo` 渲染为 ``,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `data-` 属性而非 class 标记,因为 Vue 在 patch 该元素时会整体重写 `class`。 + +## 考虑过的替代方案 + +**为中文查询定制搜索分词器。** 已实现并撤回。其前提——MiniSearch 会把中文散文留作无法切分的整句——是用一个语料中根本不存在的词(`子代理`)验证的;中文页面写的是 `Subagent` 和 `子 agent`。在未改动的索引上实测,`插件配置` 返回 120 条命中、`会话持久化` 85 条、`工作流` 28 条、`沙箱` 12 条,且各自的页面均排在首位:`prefix: true` 已经能通过标点切出的短 token 命中中文词。相邻字符二元组把中文索引从 1.23MB 增至 2.12MB,却没有带来收益。该尝试还暴露出一个值得保留的陷阱:VitePress 通过 `Function.prototype.toString` 把搜索选项中的函数送到浏览器,再用 `new Function` 重建,因此任何闭包引用了模块级常量的此类函数都会在空作用域中抛错,并静默地返回零结果。 + +**把子系统分组直接放在 `概念` 之后。** 已否决:这样能让架构页回到顶部,但生成参考、Cordis API 和开发手册仍处在 43 行之下。 + +**在投影时重写文件名链接文字。** 子系统索引表写的是 `[core.md](core.md)`,在站点上读起来像仓库文件索引。`scripts/project-doc-site.spec.ts` 断言了该行的确切格式,因此这些文件名是刻意的约定而非疏漏;要改变站点显示的内容,就要连同该约定及其门禁一起改,而不是在投影器里绕开它们。 + +## 影响 + +在所有子系统分组折叠时,参考侧边栏高度为 1452px,此前为 2478px,且架构页是它的第一个条目。分区位置与折叠行为声明在同一份 manifest 中,不再分散于 manifest 与配置之间;`scripts/project-doc-site.spec.ts` 固定了三条不变式:每个拥有侧边栏的页面都能解析到位置、未声明的分区会被拒绝、同一分区内没有两个页面共用 `order`。 + +剥离 chrome 不改动规范 Markdown——切换行与徽章仍服务于 GitHub 读者。代价是投影器现在知晓源语料的两项呈现约定,而采用不同切换行措辞的页面将不会被匹配到。 + +字标是同一图形的第二份副本,另两份位于 `apps/web/public/favicon.svg` 和 `packages/client/ui-primitives/src/FishLogo.tsx`,各自承载自己的呈现方式。DeepSeek 字标的变更只有通过更新这份副本才能到达文档站。 diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 1ce61a593a..af65a4b898 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: a10a0f93fde4f710af2ab14f74b854ee07d7c03f -index.zh.md: fb2c4f0959eab8c7a072c44207943c31b0bed8ea +index.md: dc9bc13c80885857d42bbc32532f678d8942a40d +index.zh.md: 4bd3837d7df0c9bcc1d512e1505c0934b56cf0a7 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index a10a0f93fd..dc9bc13c80 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -8,6 +8,8 @@ The audience is agent developers. You do not need deep TypeScript experience; th If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) and the [Cordis core API](../cordis-api/context.md) pages. +To write plugins for the harness itself — loaded from a `cordis.yml` and driven from the Web UI rather than the launcher below — start from [your first Harness plugin](../user/develop/basic/index.md). + ## Setup You need a clone of this repository with dependencies installed; the [development guide](../development.md#setup-tutorial) lists the prerequisites. No API key is needed for this tutorial; every example runs keylessly. diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index fb2c4f0959..4bd3837d7d 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -8,6 +8,8 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见[子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块,以及 [Cordis 核心 API](../cordis-api/context.md)页面。 +如果你要为 harness 本身编写插件——由 `cordis.yml` 加载、在 Web UI 中驱动,而不是下面这个启动器——请从[第一个 Harness 插件](../user/develop/basic/index.md)开始。 + ## 准备工作 你需要克隆本仓库并安装依赖;[开发指南](../development.md#setup-tutorial)列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 1bfaea7b92..e4075e6325 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: 71b5bd5ef5d296999420c40d3b8c9cf46c918841 -index.zh.md: 5dafe8bf0938337fa1f38634088acf00a2fcab46 +index.md: 494b7869be6ffdf5767fac260b36b2585305b516 +index.zh.md: 92b5ad4e876b31bc10d57a19d45b1bfdf2fdb9ba diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 71b5bd5ef5..494b7869be 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -139,3 +139,4 @@ Function form is sufficient in most cases. Use class form when the plugin provid - [Build a tool](./tool.md) — learn the tool definition DSL - [Plugin configuration](./config.md) — accept user configuration +- [Cordis tutorial](../../../cordis-tutorial/index.md) — the plugin framework underneath, built from a scratch directory with no API key diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 5dafe8bf09..92b5ad4e87 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -139,3 +139,4 @@ export default class MyService extends Service { - [开发一个工具](./tool.md) — 详细了解工具定义 DSL - [插件配置](./config.md) — 让插件接受用户配置 +- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 底层的插件框架,在临时目录中动手构建,无需 API 密钥 diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml index 1c8dc3dae4..3e27e77e5a 100644 --- a/docs/user/develop/framework/index.i18n.yaml +++ b/docs/user/develop/framework/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md -index.md: 85701ce281d92da0c805b39291179df73eb65f51 -index.zh.md: 871aa55ef81a7dcbfe3cbde5986244220ee32f98 +index.md: 8cc673148d7fec4f7d9b994907e17293bc3a6a97 +index.zh.md: 1a1f7feb8685e124babb182544bde332b52da42c diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md index 85701ce281..8cc673148d 100644 --- a/docs/user/develop/framework/index.md +++ b/docs/user/develop/framework/index.md @@ -134,3 +134,4 @@ effect cleaned up - [Services and dependencies](./service.md) — expose a capability to other plugins - [Event system](./events.md) — communicate between plugins +- [Cordis tutorial](../../../cordis-tutorial/index.md) — the same lifecycle, services, and events built step by step against the Cordis runtime diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md index 871aa55ef8..1a1f7feb86 100644 --- a/docs/user/develop/framework/index.zh.md +++ b/docs/user/develop/framework/index.zh.md @@ -134,3 +134,4 @@ effect cleaned up - [服务与依赖](./service.md) — 让插件向其他插件提供能力 - [事件系统](./events.md) — 在插件之间通信 +- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 在 Cordis 运行时上逐步搭出同一套生命周期、服务与事件 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 107f2c1034..439664ddd0 100644 --- a/scripts/project-doc-site.spec.ts +++ b/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, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, } from './project-doc-site.ts' @@ -364,6 +364,50 @@ 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', () => { + // Each locale ranks only its own labels, so a label one locale never uses + // cannot borrow a rank from the other. + expect(sectionSpec('root', '入门').index).toBe(0) + expect(sectionSpec('en', 'Guide').index).toBe(0) + expect(() => sectionSpec('en', '入门')).toThrow() + expect(() => sectionSpec('root', 'Guide')).toThrow() + }) + + 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() + 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 +455,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') diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index e7acc73998..1d0ea9072a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -292,6 +292,37 @@ export function addProjectionFrontmatter(markdown: string, page: Pick 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 page.sidebar === collection) - const sections = new Map() +function sidebar(locale: DocsLocale, collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { + const pages = docsPages.filter(page => page.locale === locale && page.sidebar === collection) + const groups = new Map() for (const page of pages) { - const entries = sections.get(page.section) ?? [] + 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]) => ({ - text, - items: entries - .sort((left, right) => left.order - right.order) - .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), - })) + return [...groups.entries()] + .sort(([left], [right]) => sectionSpec(locale, left).index - sectionSpec(locale, right).index) + .map(([text, entries]) => { + const { collapsed } = sectionSpec(locale, text) + return { + text, + // 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 + .sort((left, right) => left.order - right.order) + .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), + } + }) } function watchCanonicalDocs(server: ViteDevServer): void { @@ -107,10 +92,102 @@ const sharedTheme: Pick` would freeze the mark at the colors the file declares. + */ +const wordmark = readFileSync(resolve(import.meta.dirname, '../public/wordmark.svg'), 'utf8') + .trim() + .replace(' { + 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 `${wordmark}${previewTag}` +} + 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 +197,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/' }, ], 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 +224,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/' }, ], 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 +250,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', diff --git a/website/docs.ts b/website/docs.ts index 3cf1dab74c..7615952d0e 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -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 = { + root: [ + { label: '入门' }, { label: '其他接口' }, + { 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: 'Other interfaces' }, + { 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, diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000000..653b77e157 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/public/wordmark.svg b/website/public/wordmark.svg new file mode 100644 index 0000000000..36e055ff2f --- /dev/null +++ b/website/public/wordmark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + From d6af042cf7ceeadcb9c9b1d860ddb1687717da03 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 14:02:29 +0800 Subject: [PATCH 16/18] docs(website): derive navigation targets from the publication manifest The navigation bar named `/guide/` while the manifest published the guide's first page at `guide/quickstart.md`, so the item served a 404 in both locales. `landingLink` resolves each item against `orderedPages`, the ordering the sidebar already renders, and a test asserts every navigation target is a route the manifest publishes. --- ...ation-site-navigation-and-chrome.i18n.yaml | 4 +- ...ocumentation-site-navigation-and-chrome.md | 4 ++ ...mentation-site-navigation-and-chrome.zh.md | 4 ++ scripts/project-doc-site.spec.ts | 15 +++++- website/.vitepress/config.ts | 45 +++++++++--------- website/docs.ts | 46 ++++++++++++++++++- 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml index d0e78c1c71..c5b67aa400 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml @@ -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/process/2026-08-12-documentation-site-navigation-and-chrome.md -2026-08-12-documentation-site-navigation-and-chrome.md: 1b1868a011744decf1c0a25a825fd022ea3609ab -2026-08-12-documentation-site-navigation-and-chrome.zh.md: 0fd685ec8c6b7f3f2fd93013ebaf9266a70b2c81 +2026-08-12-documentation-site-navigation-and-chrome.md: 07f88d303a96676806cce0801bce5d478fb5406e +2026-08-12-documentation-site-navigation-and-chrome.zh.md: ca605e2a890bac4bcff9f22a6c6ab8928d1a8aa8 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md index 1b1868a011..07f88d303a 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -8,6 +8,8 @@ English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — 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 @@ -16,6 +18,8 @@ Separately, every canonical page carries lines written for its GitHub reader — Subsystem pages are grouped by concern — overview, core and scopes, sessions and persistence, model and context, execution and tools, policy and interaction, platform and access — and the six topical groups render collapsed until one holds the page being read. The groups sort last within the reference sidebar: expanded, they outnumber every other group combined, so anything placed after them is reachable only by scrolling past the whole list. Page `order` derives from array position rather than a hand-written number. +`landingLink(locale, collection)` derives each navigation item's target from `orderedPages`, the same ordering the sidebar renders, so an item always opens its collection's first published page. + `projectedPageContent` in [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) drops the language-switcher line and the repository badge. The switcher match is confined to the first eight lines so a tutorial that shows the convention still renders its example. The navigation-bar title is the DeepSeek wordmark inlined into `siteTitle`, which VitePress renders as HTML. Inlining is what lets the mark's `currentColor` fills follow the active theme; `themeConfig.logo` renders an ``, which freezes the mark at the colors its file declares and would need one asset per theme. The sidebar scrollbar rests invisible and appears while scrolling, marked by a `data-` attribute rather than a class because Vue rewrites `class` wholesale when it patches the element. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index 0fd685ec8c..ca605e2a89 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -8,6 +8,8 @@ Status: implemented 参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 +顶栏把 `入门` 指向 `/guide/`,而 manifest 已把入门首页发布在 `guide/quickstart.md`,该导航项因此返回 404:写死的导航目标会与 manifest 实际发布的路由脱节。 + 另外,每个规范页面都带有写给 GitHub 读者的行——标题下的语言切换行,部分页面还有仓库徽章——站点原样投影了它们,尽管其导航栏已经提供了这两者。 ## 决定 @@ -16,6 +18,8 @@ Status: implemented 子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 +`landingLink(locale, collection)` 依据 `orderedPages`——即侧边栏所用的同一套排序——推导每个导航项的目标,因此导航项始终打开该分区已发布的首个页面。 + [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) 中的 `projectedPageContent` 会丢弃语言切换行和仓库徽章。切换行的匹配被限制在前八行内,因此展示该约定的教程仍能渲染出它的示例。 导航栏标题是内联进 `siteTitle` 的 DeepSeek 字标,VitePress 会将其按 HTML 渲染。内联正是让字标的 `currentColor` 填充跟随当前主题的原因;`themeConfig.logo` 渲染为 ``,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `data-` 属性而非 class 标记,因为 Vue 在 patch 该元素时会整体重写 `class`。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 439664ddd0..e52017cbd8 100644 --- a/scripts/project-doc-site.spec.ts +++ b/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, sectionSpec, 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' @@ -386,6 +386,19 @@ describe('sidebar ordering', () => { 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) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index f611804927..e451ecb68c 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -5,33 +5,30 @@ import { resolve } from 'node:path' import type { DefaultTheme, PageData } from 'vitepress' import type { ViteDevServer } from 'vite' import { withMermaid } from 'vitepress-plugin-mermaid' -import { docsPages, sectionSpec, type DocsLocale, 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() -function sidebar(locale: DocsLocale, collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { - const pages = docsPages.filter(page => page.locale === locale && page.sidebar === collection) +function sidebar(locale: DocsLocale, collection: NonNullable): 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() - for (const page of pages) { + for (const page of orderedPages(locale, collection)) { const entries = groups.get(page.section) ?? [] entries.push(page) groups.set(page.section, entries) } - return [...groups.entries()] - .sort(([left], [right]) => sectionSpec(locale, left).index - sectionSpec(locale, right).index) - .map(([text, entries]) => { - const { collapsed } = sectionSpec(locale, text) - return { - text, - // 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 - .sort((left, right) => left.order - right.order) - .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), - } - }) + return [...groups.entries()].map(([text, entries]) => { + const { collapsed } = sectionSpec(locale, text) + return { + text, + // 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 { @@ -199,9 +196,9 @@ export default withMermaid({ 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('root', 'zh-guide'), @@ -226,9 +223,9 @@ export default withMermaid({ 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', 'en-guide'), diff --git a/website/docs.ts b/website/docs.ts index 7615952d0e..12280585cc 100644 --- a/website/docs.ts +++ b/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' @@ -478,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) +} From 555771496bf8da2c6a07f0d2aa0db48fc85d5fc0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 14:08:18 +0800 Subject: [PATCH 17/18] docs(website): name the Python SDK group SDK The group holds language SDKs, and the sidebar reads `SDK > Python` rather than repeating the word in the page label. --- ...-12-documentation-site-navigation-and-chrome.i18n.yaml | 4 ++-- ...2026-08-12-documentation-site-navigation-and-chrome.md | 4 ++-- ...6-08-12-documentation-site-navigation-and-chrome.zh.md | 4 ++-- scripts/project-doc-site.spec.ts | 8 ++++---- website/docs.ts | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml index c5b67aa400..ef5f11a4f8 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml @@ -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/process/2026-08-12-documentation-site-navigation-and-chrome.md -2026-08-12-documentation-site-navigation-and-chrome.md: 07f88d303a96676806cce0801bce5d478fb5406e -2026-08-12-documentation-site-navigation-and-chrome.zh.md: ca605e2a890bac4bcff9f22a6c6ab8928d1a8aa8 +2026-08-12-documentation-site-navigation-and-chrome.md: 03cd44b94f853725da33800e8c89886b1a657a0b +2026-08-12-documentation-site-navigation-and-chrome.zh.md: d0972f909e648278cb3cecb7788705b228f4b675 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md index 07f88d303a..03cd44b94f 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -6,7 +6,7 @@ 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 `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — 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 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. @@ -14,7 +14,7 @@ Separately, every canonical page carries lines written for its GitHub reader — ## 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: one shared list ordered both label sets by convention and accepted a label missing from either without complaint. +[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. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index ca605e2a89..d0972f909e 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 +参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为子系统分组、也没有为承载 Python SDK 页的分组声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 顶栏把 `入门` 指向 `/guide/`,而 manifest 已把入门首页发布在 `guide/quickstart.md`,该导航项因此返回 404:写死的导航目标会与 manifest 实际发布的路由脱节。 @@ -14,7 +14,7 @@ Status: implemented ## 决定 -[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组:单一共享列表既要按约定排列两套标签,又会对任一侧缺失的标签毫无反应。 +[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组,而两侧共用的标签 `SDK` 无法同时相对 `入门` 和相对 `Guide` 取同一位次。 子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index e52017cbd8..b5f5218de7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -378,10 +378,10 @@ describe('sidebar ordering', () => { }) it('declares placements per locale rather than in one shared list', () => { - // Each locale ranks only its own labels, so a label one locale never uses - // cannot borrow a rank from the other. - expect(sectionSpec('root', '入门').index).toBe(0) - expect(sectionSpec('en', 'Guide').index).toBe(0) + // `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() }) diff --git a/website/docs.ts b/website/docs.ts index 12280585cc..6c75635a80 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -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, }, ]) @@ -427,7 +427,7 @@ export interface DocsSection { */ const sections: Record = { root: [ - { label: '入门' }, { label: '其他接口' }, + { label: '入门' }, { label: 'SDK' }, { label: '基础' }, { label: '框架能力' }, { label: '实战' }, { label: 'Cordis 框架教程' }, { label: '概念' }, { label: '生成参考' }, { label: 'Cordis API' }, { label: '开发手册' }, { label: '总览' }, @@ -439,7 +439,7 @@ const sections: Record = { { label: '平台与接入', collapsed: true }, ], en: [ - { label: 'Guide' }, { label: 'Other interfaces' }, + { 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' }, From 0fc77a7ba1a3b15a74cd1431978f5ccbac65bd09 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:13:28 -0700 Subject: [PATCH 18/18] test(web): update onboarding settings snapshot --- .../snapshots/onboarding-usable-provider/dismissed.expected.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 182fadf973..b3e1141abc 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设