From 2c90710383d2372eaebd9a924dc8441ab5219e33 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 11:30:02 +0800 Subject: [PATCH 01/35] fix(web): preserve question drafts across Session switches --- ...on-drafts-survive-session-switch.i18n.yaml | 6 + ...-question-drafts-survive-session-switch.md | 39 ++++++ ...estion-drafts-survive-session-switch.zh.md | 39 ++++++ apps/web/tests/question-composer.e2e.ts | 15 +++ .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 12 +- .../client/ui-user-questions/README.zh.md | 12 +- .../client/ui-user-questions/package.json | 3 +- .../src/client/QuestionComposer.tsx | 114 +++++++++++------- .../src/client/contract/slots.ts | 12 +- .../src/client/draft-store.ts | 57 +++++++++ .../ui-user-questions/src/client/index.ts | 3 + .../tests/browser-plugin.client.spec.ts | 1 + .../tests/plan-review-panel.client.spec.tsx | 5 + .../tests/question-draft-store.client.spec.ts | 36 ++++++ .../user-questions-composer.client.spec.tsx | 32 ++++- pnpm-lock.yaml | 3 + 17 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md create mode 100644 packages/client/ui-user-questions/src/client/draft-store.ts create mode 100644 packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.i18n.yaml new file mode 100644 index 0000000000..df210daf29 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.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-26-question-drafts-survive-session-switch.md +2026-08-26-question-drafts-survive-session-switch.md: 2279c4efd5e79c51a0e2e2347f21fae4b0a47b7a +2026-08-26-question-drafts-survive-session-switch.zh.md: af275a704bef723cb3ed6fde83138a2be5b5cb3f diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md new file mode 100644 index 0000000000..2279c4efd5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.md @@ -0,0 +1,39 @@ +# Agent Note: Question drafts survive Session switches + +Status: implemented + +English | [中文](2026-08-26-question-drafts-survive-session-switch.zh.md) + +## Problem + +`conversation.composer` is a strict Session-scoped slot, so selecting another Session unmounts its question entry. The generic `QuestionFlow` kept its current question index, selected labels, custom text, and skip flags in React component state. A still-pending request therefore returned with empty answers after an A → B → A Session switch even though the pending carrier remained owned by Session A. + +The draft is transient presentation state: it must follow its Session within the current page, but it must not become mutable state on the pending business carrier or a user preference synchronized through Host settings. + +## Decision + +The question entry declares a non-persisted `createQuestionDraftStore` handle when it registers into `conversation.composer`. The renderer owns one instance per Session scope and retains that instance across selection changes, so remounting the same Session reads the same progress. + +The store holds at most one request identity and one progress value: current question index plus one selected/custom/skipped draft per question. `QuestionFlow` reads the stored value only when the local pending-request key and question count match. A new request therefore renders empty immediately and its first write atomically replaces the older value instead of accumulating request records. Successful answer and cancellation settlements clear only their matching request key, so a stale completion cannot erase a later draft. + +Busy state, failure feedback, collapse state, and focus bookkeeping remain component-local because they describe the mounted interaction rather than the unfinished answer. The `plan-review` presentation has no multi-question draft and does not read the store. + +This realizes the existing [Session-scope rule](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) that remount-surviving state belongs in a Session-bound source, while retaining the [Host-backed preference decision](2026-08-06-host-backed-web-preferences.md): drafts remain page-local and never enter settings, `localStorage`, or disk. The answer semantics from [multi-select custom composition](2026-07-30-multi-select-custom-answer-composition.md) are unchanged. + +## Testing + +The store test pins keyed replacement and stale-cleanup isolation. The component test unmounts and remounts the strict entry over one store instance and requires its page, selected option, and custom text to return. The keyless assembled Web scenario types both answer forms, switches to a new Session, returns to the waiting Session, snapshots the restored composer, and submits the restored values through the real question waterfall. + +## Alternatives considered + +**Keep the state in `QuestionFlow`.** Rejected because a strict Session switch deliberately destroys that React instance; a component-local key cannot outlive the unmount it is intended to identify. + +**Put mutable drafts on `PendingQuestion`.** Rejected because the carrier represents pending request settlement, not React presentation state, and mutations there would bypass the Slot store's subscribed read/write surface and lifecycle ownership. + +**Use a module-level map keyed by Session and request.** Rejected because plugin reload and Session pruning would not own its disposal, and completed request entries could accumulate independently of the renderer's scope lifecycle. + +**Persist drafts through Host settings or browser storage.** Rejected because switching Sessions within one page needs remount continuity, not cross-page or cross-process durability. Persistence would synchronize transient answer text beyond the interaction that owns it. + +## Consequences + +Unsubmitted generic-question answers survive ordinary Session navigation in the current page, including the current question and explicit skips. They still reset after a page reload, Session-scope prune, or replacement pending-request identity. The per-Session memory cost is bounded to one request progress value and is released with the Slot store's Session scope. diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md new file mode 100644 index 0000000000..af275a704b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-question-drafts-survive-session-switch.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 提问草稿在 Session 切换后保留 + +Status: implemented + +[English](2026-08-26-question-drafts-survive-session-switch.md) | 中文 + +## Problem + +`conversation.composer` 是严格按 Session 划分 scope 的 slot,因此选择另一个 Session 会卸载其提问条目。通用 `QuestionFlow` 把当前题号、已选标签、自定义文本和跳过标记保存在 React 组件状态中。因此,即使待处理载体仍归 Session A 所有,一个仍在等待的请求经过 A → B → A 的 Session 切换后,也会以空答案重新出现。 + +草稿是临时呈现状态:它必须在当前页面内跟随所属 Session,但不能变成待处理业务载体上的可变状态,也不能成为通过 Host settings 同步的用户偏好。 + +## Decision + +提问条目注册到 `conversation.composer` 时声明一个非持久化的 `createQuestionDraftStore` handle。renderer 为每个 Session scope 拥有一个实例,并在选择切换期间保留该实例,因此重新挂载同一 Session 时会读到相同进度。 + +store 最多保存一个请求标识和一个进度值:当前题号,以及每道题各一份 selected/custom/skipped 草稿。只有本地待处理请求 key 与题目数量都相符时,`QuestionFlow` 才读取已存值。因此,新请求会立即渲染为空,并在首次写入时原子替换旧值,而不会累积请求记录。成功回答和取消落定后只清除与自身相符的请求 key,因此过期的完成动作不会删除较新的草稿。 + +忙碌状态、失败提示、折叠状态和焦点记录仍留在组件本地,因为它们描述当前已挂载交互,而不是未完成的答案。`plan-review` 呈现界面没有多题草稿,也不读取该 store。 + +这落实了既有的 [Session scope 规则](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md):需要跨重新挂载保留的状态应归 Session 绑定的数据源;同时保留[由 Host 持久化偏好的决策](2026-08-06-host-backed-web-preferences.zh.md):草稿仍只存在于当前页面,从不进入 settings、`localStorage` 或磁盘。[多选自定义答案组合](2026-07-30-multi-select-custom-answer-composition.zh.md)规定的答案语义保持不变。 + +## Testing + +store 测试固定按 key 替换和过期清理隔离。组件测试在同一个 store 实例上卸载并重新挂载严格 Session 条目,并要求题号、已选选项和自定义文本全部恢复。无密钥的组装 Web 场景会输入两种答案、切换到新 Session、返回仍在等待的 Session、对恢复后的编辑器生成快照,再经真实提问 waterfall 提交恢复的值。 + +## Alternatives considered + +**继续把状态留在 `QuestionFlow`。** 不采用,因为严格 Session 切换会刻意销毁该 React 实例;组件本地 key 无法比其试图标识的卸载过程活得更久。 + +**把可变草稿放进 `PendingQuestion`。** 不采用,因为载体表示待处理请求的落定过程,而不是 React 呈现状态;在其中做变更还会绕过 Slot store 提供的订阅读写界面和生命周期归属。 + +**使用按 Session 和请求建立索引的模块级 map。** 不采用,因为 plugin 重载与 Session 裁剪不拥有其清理过程,已完成请求的条目还可能脱离 renderer 的 scope 生命周期不断累积。 + +**通过 Host settings 或浏览器存储持久化草稿。** 不采用,因为同一页面内切换 Session 需要的是跨重新挂载连续性,而不是跨页面或跨进程耐久性。持久化会把临时答案文本同步到拥有它的交互之外。 + +## Consequences + +未提交的通用提问答案现在能在当前页面的普通 Session 导航中保留,包括当前题号和显式跳过状态。刷新页面、Session scope 被裁剪或待处理请求标识被替换后,草稿仍会重置。每个 Session 的内存成本被限制为一个请求进度值,并随 Slot store 的 Session scope 一起释放。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 8bd3ddb92b..429465cb6c 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -195,6 +195,21 @@ describe('web e2e: resident question composer round trip', () => { expect(await blue.getAttribute('aria-checked')).toBe('true') expect(await custom.inputValue()).toBe('Include accessibility notes') if (MODE !== 'record') { + // A strict Session-slot switch remounts the composer. Open a fresh blank + // Session, then return to the still-waiting request and require its + // Session-scoped store to restore both option and free-text drafts. + const originalRow = page.locator('[role="treeitem"]') + .filter({ hasText: 'Use the ask_user_question tool' }).first() + await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() + await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0) + await originalRow.click() + await composer.waitFor({ timeout: 15_000 }) + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + + // This golden now owns the composed state after a real A -> B -> A + // Session cycle, not merely the state before the remount. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) } diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 7086b6d647..cd4d3e032d 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/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-user-questions/README.md -README.md: 51095ef4b8946657d786abddb5dc01cbd462eddb -README.zh.md: ec91254b1ace817ce2e2b667870e7512e2879a2d +README.md: 2462a3d25644cf073b4652d564a9f7a213e92250 +README.zh.md: 296974703d77791393811b89fd046078969584b5 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 51095ef4b8..2462a3d256 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it. +`dsh-client-ui-user-questions` is the web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` chain, so when the agent asks the user a question the composer is taken over by the question UI. The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges, and custom answers, and submits one structured answer batch for the whole request. A request whose single question declares a presentation intent renders as that intent's own surface instead — notably the `plan-review` waiting-approval card with `Chat about it` / `Refuse` / `Approve`. Its host half is empty on purpose: mounting `dsh-tool-ask-user` there would put the tool in the registry's global layer and merge it into every agent regardless of the preset that composed it. ## Table of Contents @@ -29,15 +29,15 @@ When the agent asks a question, the composer becomes the question surface: answe ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`. ### The plan-review card -A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. +A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. ### Failure and recovery -Selection state is local to a component keyed by the request rpcId: a replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally. +The generic question flow keeps its current page, selected labels, custom text, and explicit skips in a non-persisted Slot store scoped to the owning Session and keyed by the pending request's local render identity. Switching from Session A to B remounts the strict composer entry, but returning to A reuses A's store and restores the unfinished draft. A different request identity reads an empty draft and replaces the previous value on its first edit; a successful answer or cancellation clears the matching value. The host remains authoritative for whether the request is pending. ----- @@ -76,7 +76,7 @@ These pages cover the composer host, the tool seam, and the plan-mode consumer. ## Model Experience -Indirectly, through dsh-tool-ask-user, which the package mounts and which owns the model-visible schema and answer rendering. +Indirectly, through `dsh-tool-ask-user`, whose model-visible schema and answer rendering this package presents in the Web client. #### KV Cache effect @@ -89,7 +89,7 @@ No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and These limits define draft durability and composer ownership; they are current package constraints. -- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts. +- **Unsubmitted drafts have page-and-Session lifetime** — Session navigation preserves them while that Session scope remains in the page, but a full page reload, Session pruning, or a newly delivered pending-request identity starts with an empty draft. The store never writes them to the Host, `localStorage`, or disk. - **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index ec91254b1a..296974703d 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。 +`dsh-client-ui-user-questions` 是 Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` chain 中,因此当 agent 向用户提问时,编辑器会被提问 UI 接管。组件每次渲染一个问题,提供进度导航、单选与多选选项、推荐徽标与自定义答案,并为整个请求提交一批结构化答案。若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面——最典型的是 `plan-review` 等待审批卡片,带 `Chat about it` / `Refuse` / `Approve`。其主机侧刻意为空:在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的全局层,并把它并入每一个 agent,无论它由哪个 preset 组装。 ## 目录 @@ -29,15 +29,15 @@ kind: "package-reference" ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 ### plan-review 卡片 -`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。 +`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。 ### 失败与恢复 -选择状态只存在于以请求 rpcId 为 key 的组件本地:使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 +通用提问流程把当前题号、已选标签、自定义文本和显式跳过状态保存在非持久化 Slot store 中;该 store 归属对应 Session,并以待处理请求的本地渲染标识为 key。从 Session A 切换到 B 会重新挂载严格 Session 级编辑器条目,但返回 A 时会复用 A 的 store 并恢复未完成草稿。不同的请求标识读取空草稿,并在首次编辑时替换旧值;成功回答或取消会清除相符的值。请求是否仍在等待由主机保持权威。 ----- @@ -76,7 +76,7 @@ kind: "package-reference" ## 模型体验 -间接影响模型体验:通过 `dsh-tool-ask-user` 实现,本包挂载该工具,而该工具拥有模型可见的 schema 与答案渲染。 +间接影响模型体验:本包在 Web 客户端呈现 `dsh-tool-ask-user` 所拥有的模型可见 schema 与答案渲染。 #### KV Cache 影响 @@ -89,7 +89,7 @@ kind: "package-reference" 这些限制定义草稿持久性与编辑器归属;它们是当前包约束。 -- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。 +- **未提交草稿的生命周期限于当前页面与 Session**:只要该 Session scope 仍留在页面内,Session 导航就会保留草稿;完整刷新页面、Session 被裁剪,或待处理请求以新的本地标识重新交付时,则从空草稿开始。store 从不把草稿写入主机、`localStorage` 或磁盘。 - **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。 diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 8db49c53e7..48137b5d0a 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", - "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI", + "description": "Web ask_user_question composer takeover and plan-review presentation UI", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 61891cdc48..561915952c 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -10,15 +10,10 @@ import { type QuestionAnswer, type QuestionComposerProps, } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' +import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts' import { PlanReviewPanel } from './PlanReviewPanel.tsx' import css from './QuestionComposer.module.css' -interface DraftAnswer { - selected: string[] - custom: string - skipped: boolean -} - /** * Displayed feedback: validation feedback is stored as a dictionary KEY and * translated at render, so already-shown feedback follows a locale switch; @@ -46,9 +41,9 @@ function isComposing(event: KeyboardEvent): boolean { return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 } -/** The free-text answer field shared by both question shapes. */ +/** The free-text answer field shared by both question variants. */ interface AnswerFieldProps { - /** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */ + /** Visual variant: the custom row's inline column or the optionless question's framed block. */ variant: 'inline' | 'block' /** Current draft text. */ value: string @@ -79,7 +74,7 @@ interface AnswerFieldProps { * Mirror and textarea MUST share font, line-height, padding and wrapping rules * or the two heights diverge. * - * @param props - field shape, draft text, and the field's event handlers. + * @param props - visual variant, draft text, and the field's event handlers. * @returns The mirrored auto-growing field. */ function AnswerField(props: AnswerFieldProps) { @@ -102,14 +97,15 @@ function AnswerField(props: AnswerFieldProps) { } /** - * Composer takeover boundary; the carrier key keys local drafts, so a - * same-request replay (same key, new carrier object) preserves them. + * Composer takeover router. Generic-question drafts live in this entry's + * Session-scoped Slot store, keyed by the pending carrier, so a strict Session + * entry remount restores the same request without exposing it to another one. * - * One takeover, two shapes: a request that declares a presentation intent this - * package renders takes that shape (a plan review is one decision over one + * One takeover, two presentations: a request that declares a presentation intent this + * package renders uses that presentation (a plan review is one decision over one * plan, not a question set), and every other request takes the generic flow. * The routing lives here, at the one entry that owns the composer seat, so - * neither shape can claim a request the other is already rendering. + * neither presentation can claim a request the other is already rendering. * * @param props - the selector-matched pending question carrier plus the framework standard kit. * @returns The question flow, or the intent's own surface, for this request. @@ -118,47 +114,75 @@ export function QuestionComposer(props: QuestionComposerProps) { const question = props.matched const review = useMemo(() => planReviewOf(question.questions), [question]) return review === undefined - ? + ? ( + + ) : } -function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick) { +type QuestionFlowProps = + { pending: PendingQuestion } & Pick + +function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) { const questions = pending.questions const markdownLabels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), }), [t]) - const [index, setIndex] = useState(0) - const [drafts, setDrafts] = useState(() => questions.map(() => ({ - selected: [], custom: '', skipped: false, - }))) + const initialProgress = useMemo(() => ({ + index: 0, + drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })), + }), [questions]) + const storedProgress = useStore(state => ( + state.requestKey === pending.key && state.progress.drafts.length === questions.length + ? state.progress + : undefined + )) + const { index, drafts } = storedProgress ?? initialProgress const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null) const [error, setError] = useState(null) // Collapsed to the header strip so the conversation above stays readable - // while the user decides; the drafts survive because the state lives here. + // while the user decides; answer drafts live in the Session store above. const [minimized, setMinimized] = useState(false) // The free-form textarea autofocuses on first presentation; re-expanding a // collapsed question must not steal focus from the expand toggle back into // the input, so focus is granted once per question index. const focusedQuestions = useRef(new Set()) - // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. + // Every navigation write stays in bounds and drafts mirrors questions 1:1. // oxlint-disable-next-line typescript/no-non-null-assertion const question = questions[index]! // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { + actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts }) + } + const cancelFlow = (): void => { setBusy('cancel') setError(null) - void pending.cancel().catch((cause: unknown) => { - setBusy(null) - setError({ text: cause instanceof Error ? cause.message : String(cause) }) - }) + replaceProgress(index, drafts) + void pending.cancel() + .then(() => { actions.clear(pending.key) }) + .catch((cause: unknown) => { + setBusy(null) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) + }) } - const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => { - setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item)) + const updateDraft = ( + update: (current: QuestionDraftAnswer) => QuestionDraftAnswer, + nextIndex = index, + ): void => { + const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item) + replaceProgress(nextIndex, nextDrafts) setError(null) } @@ -171,27 +195,24 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick current + 1) - } + }, question.multiSelect !== true && index < questions.length - 1 ? index + 1 : index) } - const answered = (item: DraftAnswer): boolean => + const answered = (item: QuestionDraftAnswer): boolean => item.selected.length > 0 || item.custom.trim() !== '' - const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped + const completed = (item: QuestionDraftAnswer): boolean => answered(item) || item.skipped - const submitDrafts = (values: DraftAnswer[]): void => { + const submitDrafts = (values: QuestionDraftAnswer[]): void => { const missing = values.findIndex(item => !completed(item)) if (missing >= 0) { - setIndex(missing) + replaceProgress(missing, values) setError({ key: 'error.incomplete' }) return } const answer: QuestionAnswer = { answers: questions.map((item, itemIndex) => { - const value = values[itemIndex] as DraftAnswer + const value = values[itemIndex] as QuestionDraftAnswer if (value.skipped) return { id: item.id, selected: [] } const custom = value.custom.trim() return { @@ -203,10 +224,12 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { - setBusy(null) - setError({ text: cause instanceof Error ? cause.message : String(cause) }) - }) + void pending.answer(answer) + .then(() => { actions.clear(pending.key) }) + .catch((cause: unknown) => { + setBusy(null) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) + }) } const continueFlow = (): void => { @@ -215,7 +238,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick current + 1) + replaceProgress(index + 1, drafts) setError(null) return } @@ -245,10 +268,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick itemIndex === index ? { selected: [], custom: '', skipped: true } : item) - setDrafts(nextDrafts) + replaceProgress(index < questions.length - 1 ? index + 1 : index, nextDrafts) setError(null) if (index < questions.length - 1) { - setIndex(current => current + 1) return } submitDrafts(nextDrafts) @@ -382,7 +404,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setIndex(index - 1); setError(null) }} + onClick={() => { replaceProgress(index - 1, drafts); setError(null) }} > @@ -390,7 +412,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setIndex(index + 1); setError(null) }} + onClick={() => { replaceProgress(index + 1, drafts); setError(null) }} > diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 8c2bee5c1a..2d42379205 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -1,10 +1,11 @@ /** Question composer props and one pending Remote waterfall response. */ -import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // The client module declares the conversation.composer SlotMap entry required by PropsRuntime. import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswer, AskUserQuestionItem, } from '@deepseek-ai/dsh-user-questions' +import type { createQuestionDraftStore } from '../draft-store.ts' declare module '@deepseek-ai/dsh-client-ui-session/client' { interface SessionPendingInteractionMap { @@ -38,7 +39,7 @@ function settlePendingComposer(settle: () => void, failureMessage: string): Prom /** * A request narrowed to the `plan-review` presentation intent: everything the * decision card renders and answers with, so the panel never re-reads the - * request shape. `approve` and `decline` are the asker's own options — an + * request fields. `approve` and `decline` are the asker's own options — an * answer must carry one of those labels verbatim — and `plan` is the markdown * body under review. */ @@ -108,7 +109,7 @@ function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'): export class PendingQuestion { /** Presentation discriminator used by Session pending-interaction consumers. */ readonly kind: 'question' | 'plan-review' - /** Opaque render identity and local-draft remount axis. */ + /** Opaque render identity and request key for the Session-scoped draft store. */ readonly key: string /** The request's question list. */ readonly questions: readonly AskUserQuestionItem[] @@ -217,4 +218,7 @@ export type QuestionWait = PendingQuestion * whole behavior surface. */ export type QuestionComposerProps = - PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'> + PropsRuntime<'conversation.composer'> + & PropsStore> + & { matched: QuestionWait } + & PropsLocale<'question'> diff --git a/packages/client/ui-user-questions/src/client/draft-store.ts b/packages/client/ui-user-questions/src/client/draft-store.ts new file mode 100644 index 0000000000..0d4b432542 --- /dev/null +++ b/packages/client/ui-user-questions/src/client/draft-store.ts @@ -0,0 +1,57 @@ +/** + * Session-scoped draft state for the generic question composer. The Slot + * registry owns store instances; this module exports only the factory so a + * plugin reload cannot reuse a module-global handle. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' + +/** One in-progress answer, including an explicit skip. */ +export interface QuestionDraftAnswer { + /** Offered labels currently selected. */ + selected: string[] + /** Human-authored alternative or additional answer. */ + custom: string + /** Whether the user explicitly skipped this question. */ + skipped: boolean +} + +/** Navigation and answer drafts for one pending request. */ +export interface QuestionDraftProgress { + /** Current question index. */ + index: number + /** One draft per question, in request order. */ + drafts: QuestionDraftAnswer[] +} + +interface QuestionDraftState { + requestKey?: string + progress: QuestionDraftProgress +} + +type QuestionDraftActions = { + replace: (draft: QuestionDraftState, requestKey: string, progress: QuestionDraftProgress) => void + clear: (draft: QuestionDraftState, requestKey: string) => void +} + +const emptyProgress = (): QuestionDraftProgress => ({ index: 0, drafts: [] }) + +/** + * Declare the question composer's transient Session store. + * @returns a non-persisted store handle whose instance is owned by the Slot registry. + */ +export function createQuestionDraftStore(): EngineStoreHandle { + return defineStore({ + init: (): QuestionDraftState => ({ progress: emptyProgress() }), + actions: { + replace: (draft, requestKey, progress) => { + draft.requestKey = requestKey + draft.progress = progress + }, + clear: (draft, requestKey) => { + if (draft.requestKey !== requestKey) return + delete draft.requestKey + draft.progress = emptyProgress() + }, + }, + }) +} diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 77956258fb..4596347211 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -23,6 +23,7 @@ import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { PendingQuestion } from './contract/slots.ts' +import { createQuestionDraftStore } from './draft-store.ts' import { QuestionComposer } from './QuestionComposer.tsx' import { en, zh, type QuestionKey } from './locales.ts' @@ -86,6 +87,7 @@ async function answerQuestion( */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries') + const questionDraftStore = createQuestionDraftStore() const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( pending => pending.kind === 'plan-review' ? 2 : 1, ) @@ -95,6 +97,7 @@ export function apply(ctx: ClientContext): void { select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null => pendingInteraction instanceof PendingQuestion ? pendingInteraction : null, locale: NS, + store: questionDraftStore, }, QuestionComposer, )) diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 881e6fa193..822484bf9b 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -122,6 +122,7 @@ describe('apply', () => { expect(entry.component).toBe(QuestionComposer) expect(entry.inject).toBeUndefined() expect(entry.locale).toBe('question') + expect(entry.store).toBeDefined() const pending = b.pending.getSnapshot()[0]! const select = entry.select as ( owner: { pendingInteraction: PendingQuestion | undefined }, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index faff18b4da..4b2326eb3d 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -5,6 +5,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait, } from '../src/client/contract/slots.ts' +import { createQuestionDraftStore } from '../src/client/draft-store.ts' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -93,6 +94,8 @@ const inputState: InputState = { queue: [], } +const questionDraftStore = createQuestionDraftStore().create(SID) + /** Framework standard-kit stubs: the panel consumes only the locale seat. */ const kit: Omit = { sessionId: SID, @@ -114,6 +117,8 @@ const kit: Omit = { pruneImages: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, + useStore: selector => selector(questionDraftStore.getSnapshot()), + actions: questionDraftStore.actions, t: seatOver(zh, commonZh), } diff --git a/packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts b/packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts new file mode 100644 index 0000000000..d5f7956091 --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-draft-store.client.spec.ts @@ -0,0 +1,36 @@ +/** Question-composer Session store behavior. */ +import { describe, expect, it } from 'vitest' +import { createQuestionDraftStore, type QuestionDraftProgress } from '../src/client/draft-store.ts' + +const FIRST: QuestionDraftProgress = { + index: 1, + drafts: [{ selected: ['Fast'], custom: '', skipped: false }], +} + +describe('createQuestionDraftStore', () => { + it('keeps one request progress and ignores cleanup from an obsolete request', () => { + const store = createQuestionDraftStore().create('session-one') + + store.actions.replace('question:one', FIRST) + expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST }) + + store.actions.clear('question:older') + expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST }) + + store.actions.clear('question:one') + expect(store.getSnapshot()).toEqual({ progress: { index: 0, drafts: [] } }) + }) + + it('replaces the previous request atomically instead of accumulating drafts', () => { + const store = createQuestionDraftStore().create('session-one') + const second: QuestionDraftProgress = { + index: 0, + drafts: [{ selected: [], custom: 'Careful', skipped: false }], + } + + store.actions.replace('question:one', FIRST) + store.actions.replace('question:two', second) + + expect(store.getSnapshot()).toEqual({ requestKey: 'question:two', progress: second }) + }) +}) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e6cf481fcc..c5aeed3203 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { useSyncExternalStore } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' +import { createQuestionDraftStore } from '../src/client/draft-store.ts' import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -91,10 +93,10 @@ const inputState: InputState = { queue: [], } -/** Framework standard-kit stubs: the composer consumes only the locale seat; +/** Framework standard-kit stubs: the composer consumes the locale and draft-store seats; * the composed props type mandates delivery of the rest (framework hooks are * plain stubs per the client testing discipline). */ -const kit: Omit = { +const kitBase: Omit = { session: undefined, sessionId: SID, pendingInteraction: undefined, @@ -118,6 +120,18 @@ const kit: Omit = { t: seatOver(zh, commonZh), } +let kit: Omit + +beforeEach(() => { + const instance = createQuestionDraftStore().create(SID) + const useStore: QuestionComposerProps['useStore'] = selector => useSyncExternalStore( + listener => instance.subscribe(listener), + () => selector(instance.getSnapshot()), + () => selector(instance.getSnapshot()), + ) + kit = { ...kitBase, useStore, actions: instance.actions } +}) + const QUESTIONS: PendingQuestion['questions'] = [ { id: 'profile', header: '偏好', question: '选择候选人类型', @@ -363,13 +377,21 @@ describe('QuestionComposer', () => { expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy() }) - it('keeps drafts when the same pending request rerenders', () => { + it('restores the current page and drafts after the strict Session entry remounts', () => { const pending = wait() const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) + const custom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(custom, { target: { value: '保留这段草稿' } }) expect(screen.getByText('2 / 3')).toBeTruthy() - view.rerender() + + view.unmount() + render() + expect(screen.getByText('2 / 3')).toBeTruthy() + expect(screen.getByPlaceholderText('输入你的答案').value).toBe('保留这段草稿') + fireEvent.click(screen.getByLabelText('上一题')) + expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('true') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ddd689f7..f417265e93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3512,6 +3512,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation From 327536548942da0b8520d814de22185a0db9fe6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:09:53 +0800 Subject: [PATCH 02/35] fix(docs): make site builds idempotent --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 4 +- .../2026-08-20-doc-site-raw-markdown-twins.md | 4 +- ...26-08-20-doc-site-raw-markdown-twins.zh.md | 4 +- package.json | 4 +- scripts/project-doc-site.spec.ts | 43 ++++++++++++++ website/AGENTS.md | 2 + website/build.ts | 57 +++++++++++++++++++ 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 website/build.ts diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml index b6cb38880f..2b2e377275 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.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-20-doc-site-raw-markdown-twins.md -2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2 -2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 +2026-08-20-doc-site-raw-markdown-twins.md: 85f6cc957d5f3a1675a38abc4675e76448ffb5f5 +2026-08-20-doc-site-raw-markdown-twins.zh.md: df6f0780223dc10fc9b5832f97a360e08e68b46b diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md index 5b45657d13..85f6cc957d 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure. -An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission. +An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. + +Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and removes that directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission. `llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label]():
` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point. diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md index 1730c9e23e..df6f078022 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -14,7 +14,9 @@ Status: implemented 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 -index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。 +index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。 + +每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并在打包前删除该目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 diff --git a/package.json b/package.json index 65b9786b40..bec66ae4c9 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments", + "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", + "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index df1c4dc4a4..5d58509fab 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -9,6 +9,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts' import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage, @@ -70,6 +71,48 @@ describe('website source layout', () => { }) }) +describe('documentation site build', () => { + it.each([ + { mode: 'SPA', mpa: false, expectedMpa: undefined }, + { mode: 'MPA', mpa: true, expectedMpa: 'true' }, + ])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-')) + roots.push(root) + const outDir = join(root, '.dist') + const stale = join(outDir, 'stale.md') + const fresh = join(outDir, 'index.html') + mkdirSync(outDir) + writeFileSync(stale, 'stale\n') + + const options = docSiteBuildOptions(root, mpa) + expect(options.mpa).toBe(expectedMpa) + expect(existsSync(stale)).toBe(true) + await options.onAfterConfigResolve?.({ outDir } as never) + expect(existsSync(outDir)).toBe(false) + mkdirSync(outDir) + writeFileSync(fresh, 'fresh\n') + + expect(readFileSync(fresh, 'utf8')).toBe('fresh\n') + }) + + it('refuses to remove the site root or an outside directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-')) + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-')) + roots.push(root, outside) + writeFileSync(join(root, 'keep'), 'root\n') + writeFileSync(join(outside, 'keep'), 'outside\n') + + expect(() => { + cleanDocSiteOutput(root, root) + }).toThrow('must be a child of site root') + expect(() => { + cleanDocSiteOutput(root, outside) + }).toThrow('must be a child of site root') + expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n') + expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n') + }) +}) + describe('publishableImage', () => { it('accepts a regular file inside the repository', () => { const { root } = fixture() diff --git a/website/AGENTS.md b/website/AGENTS.md index 217417660b..65ca9d28e0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -10,6 +10,8 @@ Keep canonical prose and generated catalogs in their owning `docs/` tier, then e The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`. +Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them. + The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree. Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. diff --git a/website/build.ts b/website/build.ts new file mode 100644 index 0000000000..dad6e4a5cd --- /dev/null +++ b/website/build.ts @@ -0,0 +1,57 @@ +/** Production documentation-site build with project-owned output preparation. */ + +import { rmSync } from 'node:fs' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'vitepress' + +const websiteRoot = resolve(import.meta.dirname) +type DocSiteBuildOptions = NonNullable[1]> + +/** + * Remove one documentation build output without permitting the site root or an outside path. + * @param siteRoot - VitePress site root that owns the output. + * @param outDir - Resolved VitePress output directory. + * @throws When `outDir` is not a proper child of `siteRoot`. + */ +export function cleanDocSiteOutput(siteRoot: string, outDir: string): void { + const root = resolve(siteRoot) + const output = resolve(outDir) + const child = relative(root, output) + if (child === '' || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) { + throw new Error(`build-doc-site: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`) + } + rmSync(output, { recursive: true, force: true }) +} + +/** + * Create VitePress build options that remove the resolved output directory before bundling. + * @param siteRoot - VitePress site root to build. + * @param mpa - Whether to use VitePress's multi-page application build. + * @returns VitePress options with project-owned output preparation. + */ +export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions { + const root = resolve(siteRoot) + return { + ...mpa ? { mpa: 'true' } : {}, + onAfterConfigResolve(siteConfig) { + cleanDocSiteOutput(root, siteConfig.outDir) + }, + } +} + +async function buildDocSite(siteRoot: string, mpa: boolean): Promise { + const root = resolve(siteRoot) + await build(root, docSiteBuildOptions(root, mpa)) +} + +function parseMpa(args: string[]): boolean { + if (args.length === 0) return false + if (args.length === 1 && args[0] === '--mpa') return true + throw new Error(`build-doc-site: expected no arguments or --mpa, got ${JSON.stringify(args)}.`) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) { + await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2))) +} From f7890f591a6e2ff681a34d1879968a77f963dd3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:31:08 +0800 Subject: [PATCH 03/35] fix(agent-presets): make a preset's failures legible where they happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery proved only that a composition parsed, so a preset naming a package a later rename took away kept a healthy card and its place in every picker until a person switched to it. It now resolves each row it can prove will start, reading the package off disk and falling back to the resolver only for names that look absent — the resolver costs a synchronous hooks-thread round-trip under the source launch's tsx hook, which the walk avoids for every row it clears. The mount diagnostic followed `AggregateError.errors` but never a cause, so a group that failed on two rows named neither. It now follows a cause that carries more than its own message. A refused switch left the chip's label snapping back with no account of why, which is the only account there can be for a preset that resolves and then refuses. It announces through the shared Toast, which gained a caller-set hold for a cause that names packages and rows. --- ...8-26-preset-health-resolves-rows.i18n.yaml | 6 + .../2026-08-26-preset-health-resolves-rows.md | 73 +++++++ ...26-08-26-preset-health-resolves-rows.zh.md | 73 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 49 ++++- .../agent-preset-selection/menu.expected.md | 1 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 4 +- packages/client/ui-agent-preset/README.zh.md | 4 +- .../src/client/AgentPresetSeat.tsx | 137 ++++++++----- .../src/client/AgentPresetSection.module.css | 50 ++++- .../src/client/AgentPresetSection.tsx | 23 ++- .../ui-agent-preset/src/client/locales.ts | 4 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../tests/apply.client.spec.ts | 24 +++ .../tests/components.client.spec.tsx | 60 +++++- .../tests/section.client.spec.tsx | 16 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Toast.module.css | 10 +- packages/client/ui-primitives/src/Toast.tsx | 28 ++- .../ui-primitives/tests/toast.client.spec.tsx | 18 ++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 4 +- packages/preset/agent-presets/README.zh.md | 4 +- packages/preset/agent-presets/package.json | 1 + .../preset/agent-presets/src/discovery.ts | 182 +++++++++++++++++- packages/preset/agent-presets/src/index.ts | 26 ++- packages/preset/agent-presets/src/mount.ts | 46 ++++- .../preset/agent-presets/src/specifier.ts | 45 +++++ .../agent-presets/tests/authoring.spec.ts | 7 +- .../agent-presets/tests/discovery.spec.ts | 161 ++++++++++++++-- .../tests/fixtures/plugins/throws.js | 9 + .../fixtures/user/broken/agent.cordis.yml | 6 +- .../user/nested-broken/agent.cordis.yml | 16 ++ .../fixtures/user/two-broken/agent.cordis.yml | 14 +- .../preset/agent-presets/tests/mount.spec.ts | 38 +++- .../agent-presets/tests/shipped-root.spec.ts | 8 +- pnpm-lock.yaml | 3 + 39 files changed, 1041 insertions(+), 145 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md create mode 100644 packages/preset/agent-presets/src/specifier.ts create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/throws.js create mode 100644 packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml new file mode 100644 index 0000000000..48dbb0f806 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.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/architecture/2026-08-26-preset-health-resolves-rows.md +2026-08-26-preset-health-resolves-rows.md: 1615b2b86bee27a282357e1d5f023797672cb9b5 +2026-08-26-preset-health-resolves-rows.zh.md: ea287ae8204aa1730d80199b0f4e86e3cf7b42a3 diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md new file mode 100644 index 0000000000..1615b2b86b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md @@ -0,0 +1,73 @@ +# Agent Note: Preset health resolves the rows it can prove will start + +Status: implemented + +English | [中文](2026-08-26-preset-health-resolves-rows.zh.md) + +## Problem + +A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config. + +`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose". + +The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded. + +The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row. + +## Decision + +**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `/package.json` — and only a name that finds nothing there is confirmed through `import.meta.resolve`, whose refusals are then remembered for the process. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way. + +The disk lookup is the fast path because the resolver is not one: a registered ESM loader hook turns every `import.meta.resolve` call into a synchronous round-trip to the hooks thread. Under the `tsx` hook the source launch installs, that measured 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node, which put 238ms of resolver time into each roster read. The walk answers the same 135 rows in 0.7ms. Keeping the resolver for names the walk cannot find leaves a read paying for the failures it reports rather than for every row it clears, and keeps a package only a loader can resolve — through tsconfig paths, or an import map — from being called broken. A Node builtin short-circuits ahead of both. + +The refusal memo sits behind the walk, never in front of it, so a package installed since is found on disk and a recorded refusal cannot go stale in the direction that matters. + +**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load. + +**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner. + +**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end. + +**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them. + +**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is the badge's tooltip for a pointer, and a visually hidden `role="alert"` node carries it to assistive technology — the card body is disabled when a preset is broken, so it leaves the tab order and the tooltip has no keyboard path. + +**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand. + +The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice. + +## Alternatives considered + +**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict. + +**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost. + +**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design. + +**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one. + +**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp. + +**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine. + +**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part. + +**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops. + +**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way. + +**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line. + +**Make the badge a focusable control so the tooltip has a keyboard path.** Rejected for now. The badge sits inside the card's own ` + <> + { setOpen(false) }} + items={state.options.map((option) => { + const text = presetDisplayText(option, t) + return { + id: option.id, + // Name and description together: the id alone never says what a + // preset does, which is why the roster carries display copy. + label: ( + + {text.name} + {text.description ?? t('noDescription')} + + ), + } + })} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + const picked = state.options.find(option => option.id === id) + // The fallback is for the row shape `find` cannot promise; the menu's + // items ARE `state.options`, so an emitted id is always one of them. + /* v8 ignore next */ + const name = picked === undefined ? id : presetDisplayText(picked, t).name + void select(id).then((refusal) => { + // Announced only for a pick a person just made: `apply()` also runs + // when a session becomes current, and a banner over that would + // report a refusal nobody asked for. + if (refusal === undefined) return + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) }) + }) + }} + align="start" + portal + anchor={( + + )} + /> + {toast !== null && ( + } + holdMs={REFUSAL_HOLD_MS} + // The composer card, which is the content column this chip sits + // above — not an ancestor of it, so the lookup is a page query + // rather than `closest`. Absent, the banner centers on the window, + // which is off-center whenever the sidebar is open. + anchor={ + seatRef.current?.closest('[data-composer-card]') + ?? document.querySelector('[data-composer-card]') + } + onDone={() => { setToast(null) }} + /> )} - /> + ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 0a8d2fa8a3..3b1378d3a3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -94,12 +94,49 @@ color: var(--dsw-alias-bg-layer-3); } -/* The discovery-reported reason, verbatim: it names the file and the fix. */ -.cardBrokenReason { - font-size: 12px; +/* The discovery-reported reason, verbatim: it names the rows and the fix. + A real element rather than the icon row's `data-tip` pseudo-element, for two + reasons: generated content joins the card's accessible text, where this would + repeat what the alert already carries, and that tooltip is one `nowrap` line + for an icon label while this names package specifiers one per line. */ +.brokenTip { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 1; + width: max-content; + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + font-weight: 400; line-height: 1.5; - color: var(--dsw-alias-state-error-primary); + text-align: left; + white-space: pre-line; overflow-wrap: anywhere; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.brokenBadge:hover .brokenTip { + opacity: 1; +} + +/* The same reason, for assistive technology only. The card body is disabled + when a preset is broken, so it leaves the tab order and the badge's tooltip + has no keyboard or screen-reader path; this node is that path. Sighted + pointer users read the badge instead, which keeps a picker card showing the + preset's own description rather than a package specifier. */ +.cardBrokenReason { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } /* The card body is the control that picks the preset. */ @@ -132,6 +169,11 @@ display: flex; align-items: center; gap: 8px; + /* Anchors the broken badge's tooltip: the badge itself stays unpositioned so + its `::after` resolves against the card's own width instead of against a + badge that sits partway across it. A tooltip grown from the badge would + run past the card, and past the section for a card in the last column. */ + position: relative; } .cardName { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 59ccb19226..60056e6b7a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -257,7 +257,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is - disabled and the card says why instead of offering it. */} + disabled; the reason rides the badge rather than the card + face, which stays the preset's own description. */}