From fac2a46c025ad163eafc1b87700979dadf5cdc57 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:55:06 +0800 Subject: [PATCH] fix(ui-chat): reset nested disclosures when collapsing a turn --- ...6-09-14-step-process-disclosures.i18n.yaml | 4 +-- .../2026-09-14-step-process-disclosures.md | 2 +- .../2026-09-14-step-process-disclosures.zh.md | 2 +- apps/web/tests/step-process.e2e.ts | 6 ++++ packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 32 ++++++++++++------- .../src/client/chat/StepProcessList.tsx | 3 ++ .../ui-chat/tests/chat-view.client.spec.tsx | 14 ++++++++ 10 files changed, 51 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml index 76a90494cb..1089cf1a05 100644 --- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md -2026-09-14-step-process-disclosures.md: 1b74fb42aef846e5f793b1bb5db6757e2fe37a50 -2026-09-14-step-process-disclosures.zh.md: 19a88fabb84552dfe474c18ec4cd350c0e81af0c +2026-09-14-step-process-disclosures.md: 1bde40fa197694853f5f5c8b5b13a205c112a443 +2026-09-14-step-process-disclosures.zh.md: f95085580ccc684dbcddec022546536d0484d4e4 diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md index 1b74fb42ae..1bde40fa19 100644 --- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md @@ -30,4 +30,4 @@ Work details provides Compact, Detailed, and Expanded defaults for the two discl ## Consequences -The grouping changes presentation only and adds no Session events or model input. Tool names determine categories; unmatched custom tools use the generic category. Disclosure state is local to the mounted chat view. Pagination that changes a group's first member may reset its expansion. Existing per-tool disclosures remain available inside the capped process area. +The grouping changes presentation only and adds no Session events or model input. Tool names determine categories; unmatched custom tools use the generic category. Disclosure state is local to the mounted chat view. Closing the whole Turn resets nested process groups and remounts hidden process renderers so tool and detail disclosures start collapsed when reopened. Pagination that changes a group's first member may reset its expansion. Existing per-tool disclosures remain available inside the capped process area. diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md index 19a88fabb8..f95085580c 100644 --- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md @@ -30,4 +30,4 @@ Status: implemented ## Consequences -分组仅改变展示,不增加 Session 事件或模型输入。分类由工具名决定,未匹配的自定义工具计入通用类别。展开状态保存在当前挂载的聊天视图中。分页改变分组首个成员时可能重置展开状态。各工具原有的独立折叠仍可在限高过程区内使用。 +分组仅改变展示,不增加 Session 事件或模型输入。分类由工具名决定,未匹配的自定义工具计入通用类别。展开状态保存在当前挂载的聊天视图中。收起整轮会重置内部过程组,并重新挂载已隐藏的过程 renderer,让工具和详情在重新打开时保持折叠。分页改变分组首个成员时可能重置展开状态。各工具原有的独立折叠仍可在限高过程区内使用。 diff --git a/apps/web/tests/step-process.e2e.ts b/apps/web/tests/step-process.e2e.ts index b9bc655714..2a7363494c 100644 --- a/apps/web/tests/step-process.e2e.ts +++ b/apps/web/tests/step-process.e2e.ts @@ -65,6 +65,12 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s expect(geometry.scrollable).toBe(true) expect(geometry.top).toBeGreaterThan(0) await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' }) + await outer.click() + await outer.click() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + await toggle.click() + const resetGeometry = await body.evaluate(element => ({ height: element.clientHeight, scroll: element.scrollHeight })) + expect(resetGeometry.scroll).toBeLessThanOrEqual(resetGeometry.height) await toggle.click() expect(await toggle.getAttribute('aria-expanded')).toBe('false') } finally { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3f6ec6d8a8..a0ce1207f1 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 52b6086f6a73d5d7f9ffea62876ed63089251daf -README.zh.md: c5e6f145eda0c8fb006c32542682a56c827399d3 +README.md: e5c4314268d2c9b05d3e8f02b1e568da8e667443 +README.zh.md: bdb8ad1230a2330964595960b28dc24b0f051354 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 52b6086f6a..e5c4314268 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -67,7 +67,7 @@ The completed-turn action footer starts 20px below the preceding prose or extens Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement. -Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. Compact folds completed Turns; Detailed and Expanded omit the whole-Turn control. Detailed defaults step groups to collapsed; Expanded defaults them to expanded. Both allow manual step toggles. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. The opening System prompt remains independently visible before the opening User. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Expanded ranges scroll within the smaller of 400px and half the viewport height. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Worked for …` (Chinese `已处理…`), ticking each second with a minimum of 1s. Normal completion fixes the duration, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Cancelled and failed Turns stay expanded with a disabled control. Missing start timestamps omit the duration. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer folds all process rows while keeping independent rows visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, closed Turns whose `turn/start` is loaded can fold independently, including Turns awakened by context injection. A Turn missing its start waits until pagination supplies it or exhausts history; unrelated unloaded Turns do not block folding. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records expanded completed answer generations; different answer generations start collapsed. +Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. Compact folds completed Turns; Detailed and Expanded omit the whole-Turn control. Detailed defaults step groups to collapsed; Expanded defaults them to expanded. Both allow manual step toggles. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. The opening System prompt remains independently visible before the opening User. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Expanded ranges scroll within the smaller of 400px and half the viewport height. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Worked for …` (Chinese `已处理…`), ticking each second with a minimum of 1s. Normal completion fixes the duration, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Cancelled and failed Turns stay expanded with a disabled control. Missing start timestamps omit the duration. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer folds all process rows while keeping independent rows visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, closed Turns whose `turn/start` is loaded can fold independently, including Turns awakened by context injection. A Turn missing its start waits until pagination supplies it or exhausts history; unrelated unloaded Turns do not block folding. Stable Chat Node Seats preserve row placement, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. Closing a whole Turn resets its nested groups and tool details to collapsed; hidden process renderers remount while their seats stay stable. The session-scoped store records expanded completed answer generations; different answer generations start collapsed. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c5e6f145ed..bdb8ad1230 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -67,7 +67,7 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。 -「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。简洁折叠已完成轮次;详细和完全展开不显示整轮控件。详细默认收起步骤组,完全展开默认展开步骤组,两者均允许手动切换。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。开场系统提示词独立显示于开场 User 上方。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。展开区在 400px 与视口高度的一半之中取较小值作为最大高度,超出后内部滚动。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题显示「已处理…」(英文为 `Worked for …`),每秒更新用时,最少显示 1 秒。正常完成后固定时长,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。取消或失败的轮次始终展开,控件不可点击。缺少起点时间戳时省略时长。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储记录完成后的手动展开正文 generation;不同正文 generation 均默认收起。 +「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。简洁折叠已完成轮次;详细和完全展开不显示整轮控件。详细默认收起步骤组,完全展开默认展开步骤组,两者均允许手动切换。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。开场系统提示词独立显示于开场 User 上方。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。展开区在 400px 与视口高度的一半之中取较小值作为最大高度,超出后内部滚动。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题显示「已处理…」(英文为 `Worked for …`),每秒更新用时,最少显示 1 秒。正常完成后固定时长,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。取消或失败的轮次始终展开,控件不可点击。缺少起点时间戳时省略时长。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 保持行的位置,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。收起整轮会把内部过程组和工具详情重置为折叠;隐藏的过程 renderer 重新挂载,seat 保持稳定。会话作用域存储记录完成后的手动展开正文 generation;不同正文 generation 均默认收起。 ----- diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 4d2b68dd47..444cc366c2 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react' +import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' @@ -107,6 +107,12 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ if (processMember) setOpen(true) }, [processMember, setOpen]) const wrapperRef = useSearchableHidden(processHidden, revealProcess) + const [disclosureReset, setDisclosureReset] = useState(0) + useEffect(() => { + if (processMember && processHidden && wrapperRef.current?.hasAttribute('hidden')) { + setDisclosureReset(value => value + 1) + } + }, [processMember, processHidden, wrapperRef]) const owner = useMemo(() => node === undefined ? null : { @@ -141,17 +147,19 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ data-turn-process-hidden={processHidden || undefined} data-turn-process-answer={compactAnswer || undefined} > - {renderSlot('conversation.chat.node', routedOwner, { - entryKey: routedNode.kind, - hookContext: turnData, - fallback: ( - t('json.truncated', { total })} - /> - ), - })} + + {renderSlot('conversation.chat.node', routedOwner, { + entryKey: routedNode.kind, + hookContext: turnData, + fallback: ( + t('json.truncated', { total })} + /> + ), + })} + ) }) diff --git a/packages/client/ui-chat/src/client/chat/StepProcessList.tsx b/packages/client/ui-chat/src/client/chat/StepProcessList.tsx index 9d12d93cef..9325afb4ed 100644 --- a/packages/client/ui-chat/src/client/chat/StepProcessList.tsx +++ b/packages/client/ui-chat/src/client/chat/StepProcessList.tsx @@ -45,6 +45,9 @@ function StepProcess({ range, nodes, expandedSteps, ...seatProps }: SeatProps & if (spec !== undefined && !alwaysOpen) actions.setTurnProcessOpen(spec.turn, (spec.answerStep ?? 0), true) }, [actions, spec, alwaysOpen]) const rootRef = useSearchableHidden(outerHidden, revealOuter) + useEffect(() => { + if (outerHidden && rootRef.current?.hasAttribute('hidden')) setOpen(false) + }, [outerHidden, rootRef]) const reveal = useCallback(() => { setOpen(true) }, []) const bodyRef = useSearchableHidden(!open, reveal) const summary = processActivity(nodes) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 3acbf6b18e..3ed8d81a3e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1764,6 +1764,20 @@ describe('ChatView', () => { expect(processRow.getAttribute('hidden')).toBe('until-found') }) + it('resets nested process disclosures when the whole Turn closes', () => { + const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')], + turnEnds: new Map([[1, 4]]) }) + const view = render() + const outer = turnProcessControl(view.container)! + fireEvent.click(outer) + const inner = view.container.querySelector('[data-step-process] > button')! + fireEvent.click(inner) + expect(inner.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(outer) + fireEvent.click(outer) + expect(inner.getAttribute('aria-expanded')).toBe('false') + }) + it('switches secondary defaults between Detailed and Expanded and permits manual toggles', () => { const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')], turnEnds: new Map([[1, 4]]) })