fix(ui-chat): hide infrastructure rows and retain turn triggers

This commit is contained in:
lsdsjy authored and yixiangihsiang committed 2026-09-18 17:26:32 +08:00
1 parent f2cf7272ef
commit 776efdf321
12 files changed
+82 -126

No files matched your search

@@ -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: 52200b74853a320dfeff10e03227190a0bbfe87f
2026-09-14-step-process-disclosures.zh.md: df121667ca72c07a70a635477403688662241ab3
2026-09-14-step-process-disclosures.md: 8d9a1aef968080301288d8fbb1964180ff99fac3
2026-09-14-step-process-disclosures.zh.md: acad7dd5dc5c7f5c6a0f4db59d30b6487dfaa8f8
@@ -35,3 +35,5 @@ The grouping changes presentation only and adds no Session events or model input
Non-human Turn triggers use durable Inbox claims rather than every context injection. Next-turn claims and first-step next-step claims without a competing next-turn claim or human batch input receive independent notice seats above the process control. Policy injections do not qualify. Recorded source plus recognized producer framing supplies the title; missing names use recorded identifiers and unknown status formats stay neutral. User-attributed programmatic requests cannot be distinguished reliably and keep their existing presentation. Notice expansion survives whole-Turn folding.
Turn actions are anchored after the recorded Turn end so later reasoning, Tools, and steering stay above the footer. The latest Turn keeps actions visible only when its final content is a response; other endings and historical Turns reveal actions on hover or keyboard focus. The feedback target remains the last durable text response.
Chat filters system prompts, ordinary context injections, and permission command rows before grouping, navigation, and visible-content checks. Filtering is presentation-only: durable records remain available to trajectory inspection. Waking context is classified as a separate turn-trigger notice and stays visible, because it explains why a new reply started. Metadata alone cannot create an empty process disclosure.
@@ -35,3 +35,5 @@ Status: implemented
非人工轮次触发原因以持久化 Inbox 领取记录为依据。next-turn 领取,以及没有竞争的 next-turn 领取、同批没有人工输入的首步 next-step 领取,在过程控件上方拥有独立提示 seat。策略注入不符合条件。标题使用日志来源及已识别的生产方文本格式;名称缺失时使用记录的标识,未知状态格式保持中性。归因为 user 的程序请求无法可靠区分,保留原有展示。提示展开状态不受整轮折叠影响。
轮次操作栏锚定在记录的轮次结束之后,使后续推理、工具和 steering 均位于页脚上方。最后一轮仅在最终内容为回复时常显操作;其他结尾和历史轮次通过悬停或键盘聚焦显示。反馈对象仍为最后一条持久化文字回复。
Chat 在分组、导航和可见内容检查之前过滤系统提示词、普通上下文注入和 permission 命令行。过滤仅影响展示,持久化记录仍可在轨迹中查看。唤起轮次的上下文被分类为独立轮次发起提示并保持可见,用于解释新回复的触发原因。只有元数据时不生成空过程折叠。
+2 -2
View File
@@ -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: 84249e02d0268770f48dda66bece003d87648cf5
README.zh.md: 440e94a2e756ff6ff3959999148f0406f6cc1272
README.md: 2e6e83e0c3d914977b5630071f862f41a2e6e8b1
README.zh.md: 40eedb544ee352a7e01f2e922489ba41adca1db7
+1 -1
View File
@@ -67,7 +67,7 @@ The completed-turn action footer sits at the bottom of its Turn, 20px below the
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. While streaming, the collapsed summary stays on one line and follows the latest reasoning line. After settlement, Compact shows only `Think`, while Detailed and Expanded preview the first line.
Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. All three modes retain the whole-Turn disclosure and default completed Turns to collapsed. Detailed defaults step groups to collapsed and allows manual toggles. Expanded renders steps directly inside the whole-Turn disclosure, without secondary titles, disclosure controls, or bounded group scroll areas. 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. Detailed appends its command, search queries, URL, file path, or description after a space; reasoning text is excluded and completed titles omit these details. 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 at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Running, cancelled, and failed Turns stay expanded with a disabled control and no chevron. Missing start timestamps omit the duration. A Turn with no process rows or inline reasoning keeps the elapsed label but hides its chevron and disables disclosure. 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.
Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. All three modes retain the whole-Turn disclosure and default completed Turns to collapsed. Detailed defaults step groups to collapsed and allows manual toggles. Expanded renders steps directly inside the whole-Turn disclosure, without secondary titles, disclosure controls, or bounded group scroll areas. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. System prompts, ordinary context injections, and permission commands have no Chat row in any Work details mode. Non-human turn-trigger notices remain visible above the whole-Turn control; hidden metadata does not create process groups or collapse arrows. 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. Detailed appends its command, search queries, URL, file path, or description after a space; reasoning text is excluded and completed titles omit these details. 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 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 at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Running, cancelled, and failed Turns stay expanded with a disabled control and no chevron. Missing start timestamps omit the duration. A Turn with no process rows or inline reasoning keeps the elapsed label but hides its chevron and disables disclosure. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, 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; System prompt rows are not rendered. 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.
-----
+1 -1
View File
@@ -67,7 +67,7 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,折叠摘要保持单行并跟随最新思考行。结算后,简洁模式只显示「思考」,详细和完全展开模式显示首行预览。
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。三种模式均保留一级整轮折叠,已完成轮次默认收起。详细默认收起步骤组,允许手动切换;完全展开在一级折叠内直接平铺步骤,不显示二级标题、折叠控件或限高的组内滚动区。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。开场系统提示词独立显示于开场 User 上方。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段。详细模式在类别后用空格追加该工具的命令、搜索词、URL、文件路径或描述,不包含思考正文,完成后不再显示这些细节。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。展开区在 400px 与视口高度的一半之中取较小值作为最大高度,超出后内部滚动。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题显示「已处理…」(英文为 `Worked for …`),每秒更新用时,最少显示 1 秒。正常完成后固定时长,最少显示 1 秒,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。进行中、取消或失败的轮次始终展开,控件不可点击且不显示箭头。缺少起点时间戳时省略时长。没有过程行或内联推理的轮次保留用时标题,但隐藏箭头并禁用折叠。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 保持行的位置,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。收起整轮会把内部过程组和工具详情重置为折叠;隐藏的过程 renderer 重新挂载,seat 保持稳定。会话作用域存储记录完成后的手动展开正文 generation;不同正文 generation 均默认收起。
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。三种模式均保留一级整轮折叠,已完成轮次默认收起。详细默认收起步骤组,允许手动切换;完全展开在一级折叠内直接平铺步骤,不显示二级标题、折叠控件或限高的组内滚动区。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。所有工作过程展示模式均不显示系统提示词、普通上下文注入和 permission 命令行。非人工轮次发起提示仍独立显示在一级折叠上方;隐藏的元数据不产生过程组或折叠箭头。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段。详细模式在类别后用空格追加该工具的命令、搜索词、URL、文件路径或描述,不包含思考正文,完成后不再显示这些细节。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。展开区在 400px 与视口高度的一半之中取较小值作为最大高度,超出后内部滚动。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题显示「已处理…」(英文为 `Worked for …`),每秒更新用时,最少显示 1 秒。正常完成后固定时长,最少显示 1 秒,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。进行中、取消或失败的轮次始终展开,控件不可点击且不显示箭头。缺少起点时间戳时省略时长。没有过程行或内联推理的轮次保留用时标题,但隐藏箭头并禁用折叠。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词行不显示。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 保持行的位置,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。收起整轮会把内部过程组和工具详情重置为折叠;隐藏的过程 renderer 重新挂载,seat 保持稳定。会话作用域存储记录完成后的手动展开正文 generation;不同正文 generation 均默认收起。
-----
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import { memo } from 'react'
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNode } from '../contract/chat-nodes.ts'
@@ -29,7 +30,8 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
if (snapshot.timeline.turnOrder.at(-1) !== data.turn) return false
const last = snapshot.locations.getTurn(data.turn)
.map(key => snapshot.nodes.get(key) as ChatNode | undefined)
.findLast(candidate => candidate?.kind !== 'turn-tail' && candidate?.kind !== 'turn-process')
.findLast(candidate => candidate !== undefined && isVisibleChatNode(candidate)
&& candidate.kind !== 'turn-tail' && candidate.kind !== 'turn-process')
if (last?.kind !== 'assistant-step') return false
const block = last.data.blocks.findLast(candidate =>
(candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '')
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
/** Consecutive process material, separated by visible assistant responses and turn boundaries. */
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
@@ -28,7 +29,6 @@ const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry',
*/
export function processRanges(snapshot: ChatSnapshot): ProcessRange[] {
const ranges: ProcessRange[] = []
const responseTurns = new Set<number | undefined>()
let pending: ProcessSeat[] = []
let pendingTurn: number | undefined
const flush = (closed: boolean): void => {
@@ -40,13 +40,12 @@ export function processRanges(snapshot: ChatSnapshot): ProcessRange[] {
}
for (const key of snapshot.order) {
const node = snapshot.nodes.get(key) as ChatNode | undefined
if (node === undefined || node.visibility === 'hidden') continue
if (node === undefined || !isVisibleChatNode(node)) continue
const location = node.location
const turn = location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined
if (pending.length > 0 && turn !== pendingTurn) flush(true)
pendingTurn = turn
if (INDEPENDENT.has(node.kind) || turn === undefined
|| (node.kind === 'system-prompt' && !responseTurns.has(turn))) {
if (INDEPENDENT.has(node.kind) || turn === undefined) {
// The synthetic whole-turn control must not split an ongoing process range.
if (node.kind === 'turn-process') {
ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn })
@@ -59,7 +58,6 @@ export function processRanges(snapshot: ChatSnapshot): ProcessRange[] {
pending.push({ nodeKey: key, assistantPart: 'reasoning' })
}
if (hasAssistantReplyContent(node.data.blocks)) {
responseTurns.add(turn)
flush(true)
ranges.push({ key, seats: [{ nodeKey: key, assistantPart: 'response' }], process: false, closed: true, turn })
}
@@ -0,0 +1,14 @@
/** Chat-only row visibility; durable events and trajectory inspection remain intact. */
import type { ChatNode } from './chat-nodes.ts'
/**
* Exclude infrastructure rows while retaining independent turn-trigger notices.
* @param node - projected Chat node.
* @returns whether the node contributes a visible Chat row.
*/
export function isVisibleChatNode(node: ChatNode): boolean {
return node.visibility === 'visible'
&& node.kind !== 'system-prompt'
&& node.kind !== 'context'
&& !(node.kind === 'command' && node.data.name === 'permission')
}
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts'
@@ -82,7 +83,7 @@ export function turnNavigationItem(
): TurnNavigationItem | undefined {
const loaded = locations.getTurn(turn)
.map(key => nodes.get(key))
.filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible')
.filter((node): node is ChatNode => node !== undefined && isVisibleChatNode(node as ChatNode))
const user = loaded.find(node => node.kind === 'user')
const anchor = user ?? loaded[0]
if (anchor === undefined) return undefined
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
@@ -49,7 +50,7 @@ function derivePresentation(
let compactAnswer = true
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if (node === undefined || node.kind === 'turn-process') continue
if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger')
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
&& (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
@@ -620,7 +620,7 @@ describe('ChatView', () => {
[{ kind: 'error', error: { code: 'UNKNOWN', message: 'failed' } }, 'Failed', '处理失败'],
] as const)('keeps %s turns expanded with a disabled title', (reason, english, chinese) => {
const snapshot = chatSnapshotFixture({
nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')],
nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')],
turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 10_000, endTime: 12_000 }]]),
})
@@ -1597,109 +1597,45 @@ describe('ChatView', () => {
expect(members[0]?.getAttribute('hidden')).toBeNull()
})
it('folds injected Context in place with the rest of the Turn process', () => {
const h = makeHarness({
nodes: [
user(1, 'question'),
context(2, 'runtime policy changed', 1),
reasoningAssistant(3, 'inspect the repository', 1, 1),
toolResult(4, 'a'),
assistant(5, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 6]]),
})
it.each(['compact', 'detailed', 'expanded'] as const)('hides infrastructure rows without empty disclosures in %s', (mode) => {
const source = withSystemPrompt(chatSnapshotFixture({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1),
command({ seq: 2.5, name: 'permission', args: 'read-only' }),
assistant(3, 'final answer', 1, 1)],
turnEnds: new Map([[1, 4]]),
}), new ChatSnapshotBuilder())
const h = makeHarness({ chat: source })
act(() => { h.setTranscriptView(mode) })
const view = render(<h.ChatView {...h.props} />)
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(members).toHaveLength(3)
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
expect(contextRow).not.toBeNull()
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
fireEvent(contextRow!, new Event('beforematch'))
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="context"]')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="command"]')).toBeNull()
expect(view.container.querySelector('[data-step-process]')).toBeNull()
expect(turnProcessControl(view.container)?.querySelector('svg')).toBeNull()
expect(view.getByText('final answer')).toBeTruthy()
})
it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
const builder = new ChatSnapshotBuilder()
const initial = withSystemPrompt(chatSnapshotFixture({
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
}), builder)
const h = makeHarness({ chat: initial }, { running: true })
const view = render(<h.ChatView {...h.props} />)
const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
act(() => {
h.set({
chat: withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
],
}), builder),
running: true,
})
})
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
expect(promptRow.getAttribute('hidden')).toBeNull()
act(() => {
h.set({
chat: withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
assistant(6, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 7]]),
}), builder),
running: false,
})
})
const toggle = turnProcessControl(view.container)!
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
fireEvent.click(toggle)
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
})
it('folds Context under the fallback title when every summary count is zero', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
it.each(['compact', 'detailed', 'expanded'] as const)('retains a waking context notice above the turn control in %s', (mode) => {
const source = chatSnapshotFixture({
nodes: [context(1, 'goal continuation', 1), reasoningAssistant(2, 'work', 1, 1),
assistant(3, 'answer', 1, 2)],
turnEnds: new Map([[1, 4]]),
})
const chat = new ChatSnapshotBuilder().replace({
nodes: source.nodes.values().map(node => node.kind === 'context' ? { ...node, kind: 'turn-trigger' } : node),
timeline: source.timeline,
})
const h = makeHarness({ chat })
act(() => { h.setTranscriptView(mode) })
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已完成工作' })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
fireEvent.click(toggle)
expect(contextRow?.getAttribute('hidden')).toBeNull()
const notice = view.container.querySelector('[data-chat-flow-kind="turn-trigger"]')!
expect(notice).not.toBeNull()
expect(notice.closest('[hidden]')).toBeNull()
const kinds = renderedFlowKinds(view.container)
expect(kinds.indexOf('turn-trigger')).toBeLessThan(kinds.indexOf('turn-process'))
fireEvent.click(turnProcessControl(view.container)!)
fireEvent.click(turnProcessControl(view.container)!)
expect(notice.closest('[hidden]')).toBeNull()
})
it('keeps ordinary spacing when steering separates the process control from its answer', () => {
@@ -1751,12 +1687,12 @@ describe('ChatView', () => {
fireEvent.click(toggle)
expect((toggle as HTMLButtonElement).disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBeNull()
act(() => { h.set({ nodes: [user(1, 'question'), context(2, 'working context', 1)] }) })
act(() => { h.set({ nodes: [user(1, 'question'), reasoningAssistant(2, 'working context', 1, 0)] }) })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.querySelector('svg')).toBeNull()
const group = view.container.querySelector('[data-step-process]')!
expect(group.getAttribute('hidden')).toBeNull()
act(() => { h.set({ nodes: [user(1, 'question'), context(2, 'working context', 1), ...(hasAnswer ? [assistant(3, 'answer')] : [])],
act(() => { h.set({ nodes: [user(1, 'question'), reasoningAssistant(2, 'working context', 1, 0), ...(hasAnswer ? [assistant(3, 'answer')] : [])],
running: false, turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 10_000, endTime: 12_000 }]]) }) })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
@@ -1821,7 +1757,7 @@ describe('ChatView', () => {
})
it('resets nested process disclosures when the whole Turn closes', () => {
const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')],
const h = makeHarness({ nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')],
turnEnds: new Map([[1, 4]]) })
const view = render(<h.ChatView {...h.props} />)
const outer = turnProcessControl(view.container)!
@@ -1855,7 +1791,7 @@ describe('ChatView', () => {
})
it('removes secondary groups in Expanded and restores collapsed groups in Detailed', () => {
const h = makeHarness({ nodes: [user(1, 'question'), context(2, 'work', 1), assistant(3, 'answer')],
const h = makeHarness({ nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')],
turnEnds: new Map([[1, 4]]) })
const view = render(<h.ChatView {...h.props} />)
act(() => { h.setTranscriptView('detailed') })
@@ -1866,7 +1802,7 @@ describe('ChatView', () => {
fireEvent.click(turnProcessControl(view.container)!)
expect(view.container.querySelector('[data-step-process]')).toBeNull()
expect(view.container.querySelector('[data-step-process-body]')).toBeNull()
const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]')!
const contextRow = view.container.querySelector('[data-chat-flow-kind="assistant-step"]')!
expect(contextRow.parentElement?.hasAttribute('data-chat-flow')).toBe(true)
expect(contextRow.closest('[hidden]')).toBeNull()
expect(view.getByText('answer')).toBeTruthy()
@@ -1920,20 +1856,20 @@ describe('ChatView', () => {
it('folds when the process controller first appears off-tail', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
nodes: [user(1, 'question'), reasoningAssistant(2, 'runtime policy', 1, 0)],
running: true,
})
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]')
readerScroll(scroller, 100)
act(() => { h.set({
nodes: [
user(1, 'question'),
context(2, 'runtime policy', 1),
reasoningAssistant(2, 'runtime policy', 1, 0),
assistant(3, 'final answer', 1, 1),
],
running: false,
@@ -1947,20 +1883,20 @@ describe('ChatView', () => {
it('keeps a focused process row visible when a live Turn completes', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
nodes: [user(1, 'question'), reasoningAssistant(2, 'runtime policy', 1, 0)],
running: true,
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByRole('button', { name: '正在分析请求' }))
const contextToggle = view.getByRole('button', { name: '上下文注入' })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
const contextToggle = view.getByRole('button', { name: '思考' })
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]')
contextToggle.focus()
expect(document.activeElement).toBe(contextToggle)
act(() => { h.set({
nodes: [
user(1, 'question'),
context(2, 'runtime policy', 1),
reasoningAssistant(2, 'runtime policy', 1, 0),
assistant(3, 'final answer', 1, 1),
],
running: false,
@@ -1981,7 +1917,7 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [
...(kind === 'ordinary' ? [user(1, 'question')] : []),
context(2, 'goal continuation', 1),
reasoningAssistant(2, 'goal continuation', 1, 0),
assistant(3, 'working', 1, 1),
assistant(4, 'final answer', 1, 2),
],
@@ -2001,7 +1937,7 @@ describe('ChatView', () => {
it('folds a truncated Turn when its start arrives even with more history unloaded', () => {
const h = makeHarness({
nodes: [context(2, 'goal continuation', 1), assistant(3, 'working', 1, 1),
nodes: [reasoningAssistant(2, 'goal continuation', 1, 0), assistant(3, 'working', 1, 1),
assistant(4, 'final answer', 1, 2)],
turnEnds: new Map([[1, 5]]),
hasMore: true,