diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md new file mode 100644 index 0000000000..88ee6a4986 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -0,0 +1,276 @@ +# Agent Note: Chat 展示策略通道与过程分组的基础架构 + +Status: proposed + +## 问题 + +Chat 的「对话显示」设置今天只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。 + +产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。 + +审阅那次实现,问题不在业务需求,在选型: + +- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。 +- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。 +- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。 +- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。 + +再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。 + +本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。 + +## 目标与约束 + +下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 + +| 编号 | 裁决 | 含义 | +|---|---|---| +| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 | +| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 | +| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 | +| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 | +| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 | +| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 | +| 7 | 本轮范围 | 只写代码与本记录;不写测试,不改其他文档;测试与文档在下一轮补 | + +有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。 + +## 可行的路线 + +围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。 + +**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。 + +**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。 + +**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。 + +**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。 + +**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。 + +## 砍掉的路线与理由 + +**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。 + +**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。 + +**路线 β 被裁决 3 否决。** 见上表。 + +**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。 + +**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。 + +选择路线 δ。 + +## 方案 + +### 数据流 + +``` +事件流 + ──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件) + ──▶ Turn 数据 step-groups:组列表,每组一个对象 + ──▶ Chat builder + ├─ 物化组头节点:一组一个 key,data 即组对象 + └─ 成员归属:按 step 映射到 key,写进 per-key presentation + ──▶ seat:hidden = f(策略字段, 角色, 展开状态) + ──▶ 组头渲染器:读自己节点的 data +展示策略 + ──▶ 渲染器:按 kind 注入的 hooks,选单个字段 + ──▶ seat:组合 selector,选出的是自己的结论 +``` + +引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。 + +### 各层职责 + +| 层 | 拥有 | 明确不拥有 | +|---|---|---| +| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 | +| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 | +| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 | +| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 | +| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 | +| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 | + +### 组头节点的身份与生命周期 + +组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。 + +组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。 + +组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。 + +组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。 + +### fold 状态机 + +`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。 + +| 事件 | 转移 | +|---|---| +| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 | +| `tool/result` 追加 | 从运行集合移除 callId | +| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 | +| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 | +| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 | +| `turn/end` | 有打开的组则标记为被轮次结束关闭 | +| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 | + +三条不变量: + +- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。 +- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。 +- 只向前折叠。不读邻居 Context,不读节点,不向后看。 + +Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。 + +发布节奏:live chunk 走动画帧合并,其余立即。 + +### 成员归属规则 + +归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。 + +| 节点 | 规则 | +|---|---| +| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 | +| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 | +| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 | +| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 | + +归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。 + +组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。 + +### 分组变化怎样到达 builder + +fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。 + +所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。 + +### seat 的 hidden 合成 + +seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论: + +```ts +const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) +const foldCompleted = usePresentation(p => p.foldCompletedTurns) +``` + +不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。 + +三个隐藏来源合成一个 `hidden`: + +| 来源 | 条件 | 揭示动作 | +|---|---|---| +| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 | +| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 | +| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 | + +关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。 + +组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。 + +### 刷新范围 + +下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。 + +| 外部变化 | 允许重渲染的 | +|---|---| +| 运行中的组来一个推理 chunk | 该 assistant seat | +| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 | +| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 | +| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 | +| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 | +| 模式切到完全展开或切回 | 仅成员与组头 seat | +| `performanceUsage` 切换 | 仅 turn-tail | +| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat | +| 语言切换 | 全部,作为对照 | + +### 展示策略对象 + +持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`: + +| 字段 | compact | detailed | expanded | +|---|---|---|---| +| `foldCompletedTurns` | true | true | true | +| `stepGrouping` | collapsed | collapsed | none | +| `settledReasoningPreview` | false | true | true | + +映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。 + +渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。 + +### 按轮折叠 + +per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。 + +### 复杂度契约 + +| 场景 | fold | builder | 发布 | +|---|---|---|---| +| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 | +| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key | +| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key | +| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 | + +## 改动清单 + +| 包 | 文件 | 改动 | +|---|---|---| +| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` | +| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 | +| ui-conversation | `index.ts` | 导出新类型 | +| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` | +| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` | +| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 | +| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 | +| ui-chat | `locale.ts` | 三档文案;组头文案 | +| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` | +| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 | +| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` | +| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 | +| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 | +| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 | +| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 | +| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation | +| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key | +| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition | +| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` | +| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props | +| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 | +| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 | +| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 | +| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 | + +本轮不改:README、i18n 配对文件、slot catalog、测试、快照。slot catalog 中 `ChatNodeOwnerProps` 的源码摘录与 key 列表需要在提 PR 前重新生成。 + +## 验收标准 + +下一轮补齐,本轮不实现。 + +**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。 + +**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。 + +**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。 + +**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。 + +**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。 + +## 风险与待决 + +- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。 +- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。 +- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。 +- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。 +- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。 +- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。 + +## 备选保留:父 Context 派生子 Context + +如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。 + +它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md new file mode 100644 index 0000000000..62381f453e --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -0,0 +1,120 @@ +# Agent Note: Chat 工作过程展示迁移进度 + +Status: proposed + +## 问题 + +PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先完成不依赖 step-group 的部分,再处理分组,避免遗漏原有产品需求。 + +## 方案 + +当前迁移目标是 `feat/chat-presentation-channel`。`pr-4565` worktree 保存原 PR 的重放内容,作为产品行为和实现的参考;`feat/chat-step-groups` 保存尚未接入当前通道的分组实验。不能仅凭分支名或提交记录判断完成情况,还要核对各 worktree 中未提交的代码。 + +本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。 + +本记录处于开发阶段,只维护中文版。新增迁移代码不自动暂存,不覆盖已有暂存内容。当前这一轮仅新增本记录,没有继续迁移功能代码。 + +### 状态定义 + +| 状态 | 含义 | +|---|---| +| 当前代码已有 | 已能在当前 worktree 源码中找到实现;不代表已提交、已发布或通过完整验收 | +| 待迁移 | 已纳入非分组迁移范围,当前尚未实现 | +| 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 | +| 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用编译结果代替 | + +### 当前通道已有的能力 + +| 能力 | compact | detailed | expanded | 实现状态 | 验收状态 | +|---|---|---|---|---|---| +| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 当前代码已有 | 待验收 | +| 按轮判断整轮折叠资格 | 本轮已结束、起点已加载且存在最终答案时可折叠 | 相同 | 相同 | 当前代码已有 | 待验收 | +| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 当前代码已有 | 待验收 | +| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 当前代码已有 | 待验收 | +| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 当前代码已有 | 待验收 | +| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 当前代码已有 | 待验收 | + +三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 + +### 不依赖分组的迁移清单 + +下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。 + +| 功能 | compact | detailed | expanded | 当前状态 | 迁移落点与注意事项 | +|---|---|---|---|---|---| +| 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 待迁移 | 保留现有摘要 DOM 和局部订阅;配套右侧渐隐。当前仍跟随最新一行,不能标为完成。原版每次扫描全文的计算方式需要优化 | +| 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 | +| 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 悬停时标题和摘要加深,展开箭头朝上;共享 `DisclosureRow` 的调用方一并核对 | +| Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 补齐停止状态的可访问名称,不改变执行或取消语义 | +| 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 保留固定文本节点,以运行状态控制动画;不把新建 ReactNode 和临时回调重新引入 memo 调用链;保留减少动态效果设置 | +| 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 已停止和失败的轮次不自动收起,也不允许手动收起;可独立于计时控件迁移 | +| 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 从轮次开始显示,运行时显示用时,完成后固定时长,停止或失败显示对应状态;移除底部重复运行条。控件提前出现需要处理锚点和排序,但不需要 step-group | +| 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 用持久化 Inbox 领取记录识别唤醒输入,显示独立通知;展开查看原始内容。来源标题仅使用已记录的来源字段,不解析正文猜测身份 | +| 隐藏基础设施行 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | Chat 隐藏系统提示词、普通上下文注入和 permission 命令;Session 数据及 Trajectory 保留。先保住唤醒通知,再隐藏普通上下文;可见性在节点定义处集中决定 | +| 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 操作栏放到轮次结束之后,反馈仍对应最后一条持久化文字回复;检查中断、重试和分支操作,不直接删除相关判定 | +| 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 待迁移 | 最新轮仅在最后可见内容为回复时常显,其余通过悬停或键盘聚焦显示;把判定放回 Turn 自己的投影,不引入原版分组索引 | + +开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。 + +### 分组阶段保留的需求 + +这些项目当前均未接入 `feat/chat-presentation-channel`。隔壁 worktree 有实验代码,不代表这些产品行为或性能要求已经完成。 + +| 功能 | compact | detailed | expanded | 当前状态 | +|---|---|---|---|---| +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 分组阶段 | +| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 分组阶段;不直接复制原版拆成两个渲染节点的实现 | +| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 分组阶段 | +| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 分组阶段 | +| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 分组阶段 | +| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 分组阶段 | +| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 分组阶段;保留节点身份,内部开合状态是否重置单独确定 | +| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 分组阶段 | + +分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 + +## 性能约束 + +| 场景 | 要保留的性质 | 所需证据 | +|---|---|---| +| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 | +| 推理或工具内容增量更新 | 只更新所属节点;避免每个分片重新扫描完整推理或完整历史 | 增量工作量与长内容场景 | +| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 | +| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 | +| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 | +| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 | + +保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。 + +## 备选方案与取舍 + +**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。 + +**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。 + +**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。 + +**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。 + +## 验收标准 + +完成一项时更新它的实现状态、实际落点和验证记录;未完成项保留,不因拆分分支而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。 + +| 验收项 | 当前状态 | +|---|---| +| 当前通道已有能力的完整行为验证 | 待验收;既有测试包含只做类型接线、尚未调整的旧断言 | +| 非分组功能迁移 | 待迁移;本次仅创建进度记录 | +| 模式切换时的 DOM 身份和刷新范围 | 待验收;不能以 memo 或 selector 的存在代替实际证据 | +| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 | +| 新行为的单元、浏览器与录制快照覆盖 | 待补;开发阶段不在此轮运行测试或录制 | +| 分组功能与两档差异 | 分组阶段 | + +此前通道调整有编译检查记录,但不是本次全部迁移的通过证明,也不代表当前所有测试通过。本轮未执行检查。 + +## 风险 + +- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。 +- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。 +- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。 +- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。 +- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 7d9e60da65..489c1e8723 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: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe -README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3 +README.md: 3a0e74332a0ee2c5453a024f1ffe9dfbb9ae0163 +README.zh.md: 89a385a61f08efa86c98d59fd4271f99c3b25210 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index eea8eeda01..3a0e74332a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -67,6 +67,8 @@ 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. +Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded. + Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. 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 reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. 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 keeps all process evidence 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, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. 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 only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 58fef8e8c4..89a385a61f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -67,6 +67,8 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H 每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。 +思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。 + 「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 ----- diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index 021a2b0616..ccc9bc55fd 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -5,15 +5,24 @@ import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the Chat target. */ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' -/** Field carrying the completed-Turn transcript presentation mode. */ +/** Field carrying the work-details presentation mode. */ export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' -/** Transcript presentation modes accepted at settings boundaries. */ -export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const +/** Work-details presentation modes a user can choose. */ +export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const -/** Completed-Turn transcript presentation. */ +/** Work-details presentation mode. */ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] +/** + * Saved value from the two-mode generation of this setting. Read as `detailed`; + * never offered as a choice and never written back. + */ +export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal' + +/** Every value the durable field accepts: current modes plus the legacy saved value. */ +const TRANSCRIPT_VIEW_SETTING_VALUES = [...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE] as const + /** Default preserves the compact process disclosure introduced by Chat. */ export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact' @@ -34,8 +43,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { - /** Presentation mode for completed Turn process content. */ - transcriptView: TranscriptViewMode + /** Work-details preference; the legacy value is accepted only from existing saved settings. */ + transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode /** Default destination for Chat HTTP(S) links. */ @@ -46,5 +55,5 @@ export interface ChatSettings { export const ChatSettingsSchema: z = z.object({ linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), - [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 079a5e2512..70390ec889 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -34,6 +34,7 @@ import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' +import { derivePresentationPolicy } from './presentation-policy.ts' import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts' import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx' import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' @@ -111,9 +112,10 @@ export function apply(ctx: Context): void { }, LinkOpeningRow)) }) const transcriptView = new TranscriptViewPolicy(chatSettings) + const presentation = derivePresentationPolicy(transcriptView.mode) const performancePolicy = new PerformanceUsagePolicy(chatSettings) const performanceUsage = performancePolicy.mode - registerChatNodeRenderers(ctx, performanceUsage) + registerChatNodeRenderers(ctx, performanceUsage, presentation) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', @@ -155,7 +157,7 @@ export function apply(ctx: Context): void { const session = binding.session const chat = chatSource(binding) return { - hooks: { transcriptView: transcriptView.mode }, + hooks: { presentation }, keyedHooks: { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 13ff7fae4b..967a7a09b1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' @@ -29,9 +29,11 @@ export interface AssistantMarkdownProps { interrupted?: boolean | undefined /** Render consecutive image blocks through the attachment slot. */ renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] - /** Hide reasoning that belongs to the Turn-level process disclosure. */ + /** Hide reasoning that belongs to the Turn-level process disclosure or a collapsed step group. */ reasoningHidden?: boolean | undefined - /** Reveal the owning Turn-level process disclosure. */ + /** Live display policy for reasoning summaries. */ + usePresentation: UsePresentation + /** Reveal the disclosure that hides this reasoning. */ revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined @@ -42,7 +44,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, renderMessageImages, - reasoningHidden = false, revealProcess, mentions, t, + reasoningHidden = false, usePresentation, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -83,7 +85,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 399897a9b5..9ef72941f5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,11 +1,14 @@ import { memo, useCallback, useMemo } from 'react' -import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace + /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, -}: ChatNodeViewProps<'assistant-step'>) { + node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t, +}: AssistantNodeViewProps) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn @@ -33,6 +36,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ interrupted={data.status === 'interrupted'} renderMessageImages={renderMessageImages} reasoningHidden={reasoningHidden} + usePresentation={usePresentation} revealProcess={revealProcess} mentions={mentions} t={t} diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 40e08dbaf7..3ed6f7b8d8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useMemo } 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' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { storedTurnProcessEntry } from '../stores.ts' @@ -12,8 +12,7 @@ interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string readonly useChatNode: ChatViewSlotProps['useChatNode'] readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess'] - readonly historyIncomplete: boolean - readonly compactTranscript: boolean + readonly usePresentation: UsePresentation readonly useStore: ChatViewSlotProps['useStore'] readonly actions: ChatViewSlotProps['actions'] readonly renderSlot: ChatViewSlotProps['renderSlot'] @@ -34,9 +33,13 @@ function turnOf(node: ChatNode | undefined): number | undefined { return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined } -/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ +/** + * Subscribe, apply Turn-process visibility, and dispatch one stable Context key. + * Policy reads select this seat's own conclusion, so a mode change re-renders + * only seats whose visibility actually changes. + */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript, + nodeKey, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { @@ -59,13 +62,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open) } }, [actions, processSpec]) + const foldCompleted = usePresentation(policy => policy.foldCompletedTurns) + // Folding is decided per Turn: the Turn is closed and its start is loaded. const processWindowReady = processSpec !== undefined && processPresentation !== undefined - && compactTranscript + && foldCompleted && processSpec.answerAnchorSeq !== null && processPresentation.turn === processSpec.turn && processPresentation.turnClosed - && !historyIncomplete + && processPresentation.turnStarted const processMember = routedNode !== undefined && processWindowReady && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 257dad64ab..bad68eb828 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -113,7 +113,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod export function ChatView({ useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, - useTranscriptView, useProjection, t, + usePresentation, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) @@ -137,7 +137,6 @@ export function ChatView({ const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) - const compactTranscript = useTranscriptView(mode => mode === 'compact') const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) // Close/retry must ignore a settlement that started before the latest @@ -237,8 +236,7 @@ export function ChatView({ order={order} useChatNode={useChatNode} useChatNodeProcess={useChatNodeProcess} - historyIncomplete={hasMore} - compactTranscript={compactTranscript} + usePresentation={usePresentation} useStore={useStore} actions={actions} cwd={cwd} diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 3ca51f8d85..5e98217171 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -97,6 +97,11 @@ text-overflow: clip; } +.root:not([data-preview]) .separator, +.root:not([data-preview]) .summary { + display: none; +} + .thinkBody { /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 4d484af95b..2d01eeb7fd 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,11 +1,13 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ -import { useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' import css from './ReasoningRow.module.css' +const THINK_ICON = + function firstLine(text: string): string { const newline = text.indexOf('\n') return newline === -1 ? text : text.slice(0, newline) @@ -20,16 +22,40 @@ function latestLine(text: string): string { /** * Render one assistant reasoning block collapsed until the reader opens it. The * collapsed summary omits double-asterisk markers; expanded content renders - * the complete Markdown with secondary typography. + * the complete Markdown with secondary typography. A streaming row always + * previews its latest line. Mode changes toggle CSS display without unmounting + * collapsed summaries. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. + * @param props.usePresentation - live display-policy selector for this reasoning row. * @param props.t - conversation locale seat for status and Markdown actions. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { +export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, t }: { + text: string + running: boolean + usePresentation: UsePresentation + t: ChatViewSlotProps['t'] +}) { const [expanded, setExpanded] = useState(false) const labels = useMemo(() => markdownLabels(t), [t]) + const toggle = useCallback(() => { setExpanded(value => !value) }, []) const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') + const preview = usePresentation(policy => !expanded && summary !== '' + && (running || policy.settledReasoningPreview)) + const collapsedContent = useMemo(() => ( + <> + + + {summary} + + + ), [running, summary]) + const content = useMemo(() => expanded ? ( +
+ +
+ ) : undefined, [expanded, labels, running, text]) return (
{running && {t('row.running')}} } + icon={THINK_ICON} title={t('message.think')} open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - - {summary} - - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
- -
+ {content}
) -} +}) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index f8f78000f1..9fa302992d 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -1,6 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PerformanceUsageMode } from '../../chat-settings.ts' +import type { ChatPresentationPolicy } from '../presentation-policy.ts' import { NS } from '../locale.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' @@ -14,10 +15,17 @@ import { TurnTailNodeView } from './TurnTailNodeView.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. + * Renderers whose output depends on the work-details mode receive the policy + * through their own registration; the seat and the other renderers do not. * @param ctx - owning UI Conversation context. * @param performanceUsage - live statistics detail preference. + * @param presentation - live presentation policy. */ -export function registerChatNodeRenderers(ctx: Context, performanceUsage: ObservableSnapshot): void { +export function registerChatNodeRenderers( + ctx: Context, + performanceUsage: ObservableSnapshot, + presentation: ObservableSnapshot, +): void { ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( @@ -26,8 +34,12 @@ export function registerChatNodeRenderers(ctx: Context, performanceUsage: Observ { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView)) - ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( - { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'assistant-step', + locale: NS, + inject: () => ({ hooks: { presentation } }), + }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'command', diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5e811b7963..4086eb4d5b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -9,17 +9,18 @@ import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' +import type { ChatPresentationPolicy } from '../presentation-policy.ts' import type { ToolCallId } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, } from './snapshot.ts' import type { TurnProcessSpec } from './turn-process.ts' -import type { PerformanceUsageMode, TranscriptViewMode } from '../../chat-settings.ts' +import type { PerformanceUsageMode } from '../../chat-settings.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook @@ -30,6 +31,13 @@ export type UseChatNode = KeyedSnapshotSelectorHook +/** + * Selector hook over the live presentation policy. Callers select one field or + * a derived conclusion, never the whole policy, so a mode change re-renders + * only components whose selected value changed. + */ +export type UsePresentation = SnapshotSelectorHook + /** Where in a file an open should land. */ export interface OpenFileOptions { /** 1-based line to reveal; absent = the file's beginning. */ @@ -105,6 +113,14 @@ export interface TurnProcessOwnerProps { setOpen(open: boolean): void } +/** Shared presentation policy source for renderers that depend on the work-details mode. */ +export interface PresentationInjected { + hooks: { + /** Live presentation policy derived from the accepted work-details mode. */ + presentation: ObservableSnapshot + } +} + /** Full props of one keyed Chat renderer. */ export type ChatNodeViewProps = PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> @@ -139,8 +155,8 @@ export interface PerformanceUsageInjected { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { hooks: { - /** Persisted completed-Turn transcript presentation. */ - transcriptView: SnapshotStore + /** Live presentation policy derived from the accepted work-details mode. */ + presentation: ObservableSnapshot } keyedHooks: { /** Resolve the stable source for one Chat Node key. */ diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 80f8654e7c..86833382c0 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -74,6 +74,8 @@ export interface ChatLocationNodeIndex { export interface ChatTurnProcessPresentation { readonly turn: number readonly spec: TurnProcessSpec + /** Whether the loaded window contains this Turn's `turn/start`; folding is decided per Turn on this fact. */ + readonly turnStarted: boolean readonly turnClosed: boolean readonly hasExternalProcess: boolean readonly compactAnswer: boolean diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index a49ae6031b..98a33a6c8b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -16,6 +16,7 @@ function samePresentation( return left === right || (left !== undefined && right !== undefined && left.spec === right.spec && left.turn === right.turn + && left.turnStarted === right.turnStarted && left.turnClosed === right.turnClosed && left.hasExternalProcess === right.hasExternalProcess && left.compactAnswer === right.compactAnswer) @@ -64,6 +65,7 @@ function derivePresentation( return { turn, spec, + turnStarted: location.turn.start !== undefined, turnClosed: location.turn.status === 'closed', hasExternalProcess, compactAnswer, diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c2fe3a5d0..6bef23a2c5 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -30,11 +30,13 @@ export type { export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' +export type { ChatPresentationPolicy } from './presentation-policy.ts' +export { derivePresentationPolicy, presentationPolicyFor } from './presentation-policy.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, - CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, - TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, + TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UsePresentation, } from './contract/slots.ts' export type { TurnProcessSpec, diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 51d3adca00..a0440b4682 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -35,10 +35,11 @@ export const zh = { 'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置', 'settings.links.sidebar': '内置浏览器', 'settings.links.newTab': '浏览器新标签页', - 'settings.transcript.title': '对话显示', - 'settings.transcript.description': '控制已完成轮次的过程内容', - 'settings.transcript.normal': '标准', - 'settings.transcript.compact': '紧凑', + 'settings.transcript.title': '工作过程展示', + 'settings.transcript.description': '控制轮次和步骤的默认展开方式', + 'settings.transcript.compact': '简洁', + 'settings.transcript.detailed': '详细', + 'settings.transcript.expanded': '完全展开', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', @@ -149,10 +150,11 @@ export const en = { 'settings.links.description': 'Choose where HTTP(S) links in chat open', 'settings.links.sidebar': 'Built-in browser', 'settings.links.newTab': 'New browser tab', - 'settings.transcript.title': 'Conversation display', - 'settings.transcript.description': 'Controls process content in completed turns', - 'settings.transcript.normal': 'Normal', + 'settings.transcript.title': 'Work details', + 'settings.transcript.description': 'Controls how turns and steps expand by default', 'settings.transcript.compact': 'Compact', + 'settings.transcript.detailed': 'Detailed', + 'settings.transcript.expanded': 'Expanded', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts new file mode 100644 index 0000000000..9221afa711 --- /dev/null +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -0,0 +1,70 @@ +/** + * Runtime vocabulary derived from the persisted work-details mode. Renderers + * and seats select single fields of this policy; none of them compares the + * mode enum, so adding a mode changes only the table below. + */ + +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { TranscriptViewMode } from '../chat-settings.ts' + +/** Presentation capabilities that one work-details mode enables. */ +export interface ChatPresentationPolicy { + /** Mode this policy was derived from; for diagnostics, never for branching in renderers. */ + readonly mode: TranscriptViewMode + /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ + readonly foldCompletedTurns: boolean + /** + * How consecutive process rows between Assistant replies present: `collapsed` + * groups them behind a step-group row that starts closed; `none` renders + * every row directly under the whole-Turn control with no group rows. + */ + readonly stepGrouping: 'collapsed' | 'none' + /** Whether a settled reasoning row previews its first line beside the Think title. */ + readonly settledReasoningPreview: boolean +} + +const POLICIES: Readonly> = { + compact: { + mode: 'compact', + foldCompletedTurns: true, + stepGrouping: 'collapsed', + settledReasoningPreview: false, + }, + detailed: { + mode: 'detailed', + foldCompletedTurns: true, + stepGrouping: 'collapsed', + settledReasoningPreview: true, + }, + expanded: { + mode: 'expanded', + foldCompletedTurns: true, + stepGrouping: 'none', + settledReasoningPreview: true, + }, +} + +/** + * Resolve the policy constant for one mode. The same mode always yields the + * same object, so selectors over a policy see stable identities. + * @param mode - persisted work-details mode. + * @returns the mode's presentation policy. + */ +export function presentationPolicyFor(mode: TranscriptViewMode): ChatPresentationPolicy { + return POLICIES[mode] +} + +/** + * Derive a policy observable from the mode observable without a subscription of + * its own: reads are a table lookup and change notifications are the mode's. + * @param mode - live work-details mode. + * @returns observable policy that changes exactly when the mode changes. + */ +export function derivePresentationPolicy( + mode: ObservableSnapshot, +): ObservableSnapshot { + return { + getSnapshot: () => POLICIES[mode.getSnapshot()], + subscribe: listener => mode.subscribe(listener), + } +} diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index 7dc7df7363..2b29abacd9 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -1,18 +1,18 @@ -/** General Settings row for completed-Turn transcript presentation. */ +/** General Settings row for work-details presentation. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { TranscriptViewMode } from '../../chat-settings.ts' +import { TRANSCRIPT_VIEW_MODES, type TranscriptViewMode } from '../../chat-settings.ts' import type { ChatKey } from '../locale.ts' import { PreferenceRow } from './PreferenceRow.tsx' -/** Registration-side transcript preference face. */ +/** Registration-side work-details preference face. */ export interface TranscriptViewRowInjected { hooks: { - /** Persisted transcript preference bound as useTranscriptView. */ + /** Persisted work-details preference bound as useTranscriptView. */ transcriptView: SnapshotStore } - /** Change the completed-Turn transcript presentation. */ + /** Change the work-details presentation. */ setTranscriptView: (mode: TranscriptViewMode) => void } @@ -22,28 +22,26 @@ export type TranscriptViewRowProps = & PropsLocale<'chat'> & InjectFace -const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ - { id: 'normal', label: 'settings.transcript.normal' }, - { id: 'compact', label: 'settings.transcript.compact' }, -] +const LABELS = { + compact: 'settings.transcript.compact', + detailed: 'settings.transcript.detailed', + expanded: 'settings.transcript.expanded', +} as const satisfies Record /** - * Render the completed-Turn transcript mode selector. + * Render the work-details mode selector. * @param props - composed Settings slot props. * @returns the preference row. */ export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) { const mode = useTranscriptView(value => value) - const selectedLabel = mode === 'normal' - ? 'settings.transcript.normal' - : 'settings.transcript.compact' return ( ({ id: option.id, label: t(option.label) }))} + selectedLabel={t(LABELS[mode])} + options={TRANSCRIPT_VIEW_MODES.map(id => ({ id, label: t(LABELS[id]) }))} onSelect={(value) => { setTranscriptView(value as TranscriptViewMode) }} /> ) diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 5534a0139c..a97e17bda8 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -1,13 +1,13 @@ -/** Host-backed completed-Turn transcript presentation policy. */ +/** Host-backed work-details presentation policy. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { - DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, type ChatSettings, type TranscriptViewMode, } from '../chat-settings.ts' -/** Live transcript preference consumed by Chat and its Settings row. */ +/** Live work-details preference consumed by Chat and its Settings row. */ export class TranscriptViewPolicy { /** Reactive current mode; defaults to Compact before Host settings arrive. */ readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE) @@ -22,7 +22,7 @@ export class TranscriptViewPolicy { /** * Publish and persist one explicit user choice. - * @param mode - Normal or Compact transcript presentation. + * @param mode - Compact, Detailed, or Expanded work details. */ setMode(mode: TranscriptViewMode): void { if (this.mode.getSnapshot() === mode) return @@ -33,7 +33,8 @@ export class TranscriptViewPolicy { /** Adopt the latest accepted Host section without writing it back. */ private adopt(): void { const section = this.host.getSnapshot().value - if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return - this.mode.set(section.transcriptView) + if (section === undefined) return + const mode = section.transcriptView === LEGACY_TRANSCRIPT_VIEW_MODE ? 'detailed' : section.transcriptView + if (this.mode.getSnapshot() !== mode) this.mode.set(mode) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 6229537c23..cd0c89e5c5 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -5,7 +5,7 @@ import type {} from '@deepseek-ai/dsh-settings' import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts' export { - CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, } from './chat-settings.ts' diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 55a02209ed..77bc597140 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -2,6 +2,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' @@ -54,6 +55,7 @@ describe('AssistantMarkdown local-path images', () => { it('renders a local image path in closing prose through the same-origin API', () => { const { container } = render( { it('keeps non-absolute destinations inert', () => { const { container } = render( { const face = (row.inject as unknown as () => TranscriptViewRowInjected)() expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') - face.setTranscriptView('normal') - expect(face.hooks.transcriptView.getSnapshot()).toBe('normal') - expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') + face.setTranscriptView('detailed') + expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed') + expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed') b.chatSettings.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 013ed04786..f389653c72 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -16,6 +16,7 @@ import { UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -1044,6 +1045,7 @@ describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( ()} /> case 'assistant-step': - return ()} /> + return ()} usePresentation={props.usePresentation} /> case 'command': return ( { openView('trajectory', callId) }, @@ -1636,7 +1637,7 @@ describe('ChatView', () => { expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') expect(processRow.getAttribute('hidden')).toBe('until-found') - act(() => { h.setTranscriptView('normal') }) + act(() => { h.setTranscriptView('detailed') }) expect(turnProcessControl(view.container)).toBeNull() expect(processRow.getAttribute('hidden')).toBeNull() diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 2a2883ebb3..bc759ada20 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -16,6 +17,7 @@ describe('tails', () => { it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( { // groups is layout noise (no text, no pulse, no interrupted marker). const empty = render( { ) expect(empty.container.firstChild).toBeNull() const blank = render( - , + , ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 8bfb4014b6..d97056af81 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -22,6 +23,7 @@ describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { const view = render( { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( selector(presentationPolicyFor('detailed')) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index dedfa9198f..e1f399208e 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -5,6 +5,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locale.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { useDetailedPresentation } from './presentation-fixture.client.ts' afterEach(() => { cleanup() @@ -20,16 +21,22 @@ describe('ReasoningRow', () => { ])('starts collapsed and preserves manual expansion when $kind arrives', (nextBlock) => { const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } const view = render( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -40,6 +47,7 @@ describe('ReasoningRow', () => { it('follows the latest streaming line, then restores the settled first line', () => { const view = render( { view.rerender( { view.rerender( { it('expands from either Think or the reasoning summary', () => { const view = render( { ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { const view = render( { .join('\n\n') + '\n\nReasoning body.' const view = render( { const first = '## Investigation\n\n**Check persistence**\n\n' const view = render( { const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n') view.rerender( { it('expanded Think drops the inline summary and renders prose without an IN card', () => { const view = render( { it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => { const view = render( { current = () => policy.mode.getSnapshot() expect(policy.mode.getSnapshot()).toBe('compact') - policy.setMode('normal') - expect(policy.mode.getSnapshot()).toBe('normal') - expect(observed).toEqual(['transcriptView=normal:normal']) - expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal') + policy.setMode('detailed') + expect(policy.mode.getSnapshot()).toBe('detailed') + expect(observed).toEqual(['transcriptView=detailed:detailed']) + expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed') }) - it('adopts Host state and ignores identical writes', () => { + it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(policy.mode.getSnapshot()).toBe('normal') - policy.setMode('normal') + expect(policy.mode.getSnapshot()).toBe('detailed') + policy.setMode('detailed') expect(host.set).not.toHaveBeenCalled() host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) @@ -41,7 +41,7 @@ describe('TranscriptViewPolicy', () => { it('adopts an accepted section standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 82c928ac97..a5887a4e1e 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -9,7 +9,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' -import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts' +import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' @@ -34,9 +34,9 @@ function noPendingInteraction() { // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] -function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { - const source = createSnapshotStore(mode) - const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) +function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) { + const source = createSnapshotStore(mode) + const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) }) const props: TranscriptViewRowProps = { usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c164dc8535 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -286,7 +286,7 @@ export const zh = { 'row.inspect': '查看', 'tool.title.search': '搜索', 'tool.title.read': '读取', - 'tool.title.bash': 'Bash', + 'tool.title.bash': '运行命令', 'tool.title.write': '写入', 'tool.title.edit': '编辑', 'tool.title.code': '代码', @@ -295,10 +295,10 @@ export const zh = { 'tool.title.runCordis': '运行 Cordis 插件', 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', - 'tool.title.pwsh': 'Pwsh', + 'tool.title.pwsh': '运行命令', 'tool.title.readImage': '读取图片', - 'tool.title.grep': 'Grep', - 'tool.title.glob': 'Glob', + 'tool.title.grep': '搜索文件内容', + 'tool.title.glob': '查找文件', 'tool.title.webSearch': '网页搜索', 'tool.title.webFetch': '网页获取', 'tool.autoReviewRejected': 'Auto review 已拒绝', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index dde0b3eec5..5e3699e8e5 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 12fe2648e383189198ff972709819e6f1eb4c02f -README.zh.md: 3c6f62fee22c5ad7e750c860724f60ca6dd9fbcf +README.md: 2606bd0dd5895319c9887b15497b2ca6b7a045ce +README.zh.md: cdf816549ea6cf12382b819a493ded7f61ecddd9 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 12fe2648e3..2606bd0dd5 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | -| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | +| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | | `Modal` | Centered dialog over a page mask. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3c6f62fee2..cdf816549e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | -| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | +| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | | `Modal` | 页面遮罩之上的居中对话框。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f79b7e4720..5644772bc0 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,4 +1,4 @@ -import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' @@ -27,10 +27,11 @@ export interface DisclosureRowProps { /** * Render one disclosure header and its controlled expanded content. + * Shallow prop comparison requires stable callbacks and React nodes to skip unchanged renders. * @param props - Visual content, controlled state, and interaction policy. * @returns the disclosure row. */ -export function DisclosureRow({ +export const DisclosureRow = memo(function DisclosureRow({ icon, title, open, @@ -101,4 +102,4 @@ export function DisclosureRow({ {open && children} ) -} +}) diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index d7a9b94228..ebd0910324 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -5,7 +5,7 @@ export const NS = 'skill' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'row.title': 'Skill', + 'row.title': '加载技能', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止',