fix(web): preserve question drafts across Session switches

This commit is contained in:
Yichen Jiang
2026-08-26 11:42:52 +08:00
parent a3c852b497
commit 2c90710383
17 changed files with 323 additions and 70 deletions
@@ -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
@@ -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.
@@ -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 一起释放。
+15
View File
@@ -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)
}
@@ -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
+6 -6
View File
@@ -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.
<a id="model-experience"></a>
## 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.
<a id="dev-note"></a>
@@ -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"
<a id="model-experience"></a>
## 模型体验
间接影响模型体验:通过 `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` 或磁盘
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
<a id="dev-note"></a>
@@ -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:^",
@@ -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<HTMLTextAreaElement>): 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
? <QuestionFlow key={question.key} pending={question} t={props.t} />
? (
<QuestionFlow
key={question.key}
pending={question}
t={props.t}
useStore={props.useStore}
actions={props.actions}
/>
)
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
type QuestionFlowProps =
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
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<DraftAnswer[]>(() => questions.map(() => ({
selected: [], custom: '', skipped: false,
})))
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
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<Feedback | null>(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<number>())
// 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<Questi
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => 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<Questi
}
setBusy('answer')
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
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<Questi
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
replaceProgress(index + 1, drafts)
setError(null)
return
}
@@ -245,10 +268,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const nextDrafts = drafts.map((item, itemIndex) => 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<Questi
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
onClick={() => { replaceProgress(index - 1, drafts); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
@@ -390,7 +412,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
onClick={() => { replaceProgress(index + 1, drafts); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
@@ -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<ReturnType<typeof createQuestionDraftStore>>
& { matched: QuestionWait }
& PropsLocale<'question'>
@@ -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<QuestionDraftState, QuestionDraftActions> {
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()
},
},
})
}
@@ -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<PendingQuestion>(
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,
))
@@ -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 },
@@ -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<QuestionComposerProps, 'matched'> = {
sessionId: SID,
@@ -114,6 +117,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
pruneImages: () => { throw new Error('unused') },
submit: () => { throw new Error('unused') },
},
useStore: selector => selector(questionDraftStore.getSnapshot()),
actions: questionDraftStore.actions,
t: seatOver(zh, commonZh),
}
@@ -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 })
})
})
@@ -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<QuestionComposerProps, 'matched'> = {
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
session: undefined,
sessionId: SID,
pendingInteraction: undefined,
@@ -118,6 +120,18 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
t: seatOver(zh, commonZh),
}
let kit: Omit<QuestionComposerProps, 'matched'>
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(<QuestionComposer matched={pending.carrier} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '保留这段草稿' } })
expect(screen.getByText('2 / 3')).toBeTruthy()
view.rerender(<QuestionComposer matched={pending.carrier} {...kit} />)
view.unmount()
render(<QuestionComposer matched={pending.carrier} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案').value).toBe('保留这段草稿')
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('true')
})
})
+3
View File
@@ -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