fix(ui-chat): align work detail modes and disclosure titles

This commit is contained in:
lsdsjy authored and yixiangihsiang committed 2026-09-18 17:26:31 +08:00
1 parent ad1ddfd0b1
commit 687ee6e2c7
15 files changed
+167 -53

No files matched your search

+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: 32037167f779ae6e7d83700ec7002b996f434a12
README.zh.md: dcb51f868a48a3b927d934a72de13b4243108391
README.md: 84249e02d0268770f48dda66bece003d87648cf5
README.zh.md: 440e94a2e756ff6ff3959999148f0406f6cc1272
+2 -2
View File
@@ -65,9 +65,9 @@ The completed-turn action footer sits at the bottom of its Turn, 20px below the
<a id="turn-process-folding"></a>
## Turn Process Folding
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, the collapsed row shows only `Think`.
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`. Compact folds completed Turns; Detailed and Expanded omit the whole-Turn control. Detailed defaults step groups to collapsed; Expanded defaults them to expanded. Both allow manual step toggles. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. The opening System prompt remains independently visible before the opening User. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Expanded ranges scroll within the smaller of 400px and half the viewport height. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Worked for …` (Chinese `已处理…`), ticking each second with a minimum of 1s. Normal completion fixes the duration at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Cancelled and failed Turns stay expanded with a disabled control. Missing start timestamps omit the duration. A 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. 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.
-----
+2 -2
View File
@@ -65,9 +65,9 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
<a id="turn-process-folding"></a>
## 轮次过程折叠
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,折叠摘要保持单行并跟随最新思考行;结算后,折叠行只显示「思考」。
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,折叠摘要保持单行并跟随最新思考行。结算后,简洁模式只显示「思考」,详细和完全展开模式显示首行预览。
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。简洁折叠已完成轮次;详细和完全展开不显示整轮控件。详细默认收起步骤组,完全展开默认展开步骤组,两者均允许手动切换。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。开场系统提示词独立显示于开场 User 上方。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段。重试行独立展示,不归入二级过程组。下一条 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`,不主动改写存储;用户选择后仅保存三个新值。开场系统提示词独立显示于开场 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 均默认收起。
-----
@@ -26,6 +26,9 @@ export function localPathMediaUrl(protocol: string, origin: string, value: strin
}
export interface AssistantMarkdownProps {
/** Hide settled reasoning previews in Compact work details. */
compactTranscript?: boolean
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
@@ -44,7 +47,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,
compactTranscript = true, blocks, streaming, interrupted, renderMessageImages,
reasoningHidden = false, revealProcess, mentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
@@ -89,7 +92,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
hidden={reasoningHidden}
reveal={revealProcess}
>
<ReasoningRow text={block.text} running={streaming && i === last} t={t} />
<ReasoningRow compact={compactTranscript} text={block.text} running={streaming && i === last} t={t} />
</ProcessReasoning>,
)
break
@@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
compactTranscript = true, node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@@ -28,6 +28,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
return (
<AssistantMarkdown
compactTranscript={compactTranscript}
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
@@ -70,7 +70,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
}, [actions, processSpec, alwaysOpen])
const processWindowReady = processSpec !== undefined
&& processPresentation !== undefined
&& compactTranscript
&& processPresentation.turn === processSpec.turn
&& (!historyIncomplete || processPresentation.turnStarted)
const processMember = routedNode !== undefined
@@ -126,9 +125,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
renderMessageImages,
fileMentions,
turnProcess,
compactTranscript,
}, [
node, cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, turnProcess,
loadImage, renderMessageImages, fileMentions, turnProcess, compactTranscript,
])
if (routedNode === undefined || owner === null) return null
const turnData = turnDataOf(routedNode)
@@ -6,6 +6,11 @@ import { markdownLabels } from '../markdown-labels.ts'
import a11yCss from './accessibility.module.css'
import css from './ReasoningRow.module.css'
function firstLine(text: string): string {
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
}
function latestLine(text: string): string {
const visible = text.trimEnd()
const newline = visible.lastIndexOf('\n')
@@ -14,18 +19,20 @@ function latestLine(text: string): string {
/**
* Render one assistant reasoning block as the Think disclosure row. The
* settled collapsed row shows only its title. Streaming summaries omit
* double-asterisk markers; expanded content renders the complete Markdown
* with secondary typography.
* settled collapsed row shows only its title in Compact mode. Other modes
* preview the first line. Streaming summaries omit double-asterisk markers;
* expanded content renders the complete Markdown with secondary typography.
* @param props.compact - whether work details use Compact mode.
* @param props.text - complete or streaming reasoning text.
* @param props.running - whether this block is the streaming tail.
* @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 function ReasoningRow({ text, running, compact, t }: { compact: boolean; text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
const [expanded, setExpanded] = useState(false)
const labels = useMemo(() => markdownLabels(t), [t])
const summary = running ? latestLine(text).replaceAll('**', '') : null
const showSummary = running || !compact
const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
return (
<div
@@ -46,7 +53,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
expandable
expandOnRowClick
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={running ? (
collapsedContent={showSummary ? (
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-follow-end={running || undefined}>
@@ -4,6 +4,7 @@
.title {
display: flex;
max-width: 100%;
align-items: center;
gap: 6px;
padding: 8px 0;
@@ -31,3 +32,10 @@
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -17,28 +17,27 @@ type ListProps = SeatProps & { readonly useChat: ChatViewSlotProps['useChat']; r
/** Ordered chat seats with configurable process defaults between assistant responses. */
export const ChatNodeList = memo(function ChatNodeList({ useChat, expandedSteps, ...seatProps }: ListProps) {
const snapshot = useChat(value => value)
return processRanges(snapshot).map(range => range.process
? <StepProcess key={range.key} range={range} expandedSteps={expandedSteps}
nodes={range.seats.map(seat => snapshot.nodes.get(seat.nodeKey) as ChatNode)} {...seatProps} />
: <ChatNodeSeat key={range.key} {...seatProps} {...range.seats[0]} />)
return processRanges(snapshot).flatMap(range => expandedSteps && range.process
? range.seats.map(seat => <ChatNodeSeat key={`${seat.nodeKey}:${seat.assistantPart ?? ''}`} {...seatProps} {...seat} />)
: range.process
? <StepProcess key={range.key} range={range}
nodes={range.seats.map(seat => snapshot.nodes.get(seat.nodeKey) as ChatNode)} {...seatProps} />
: <ChatNodeSeat key={range.key} {...seatProps} {...range.seats[0]} />)
})
function StepProcess({ range, nodes, expandedSteps, ...seatProps }: SeatProps & {
function StepProcess({ range, nodes, ...seatProps }: SeatProps & {
readonly range: ProcessRange
readonly nodes: readonly ChatNode[]
readonly expandedSteps: boolean
}) {
const { useChatNodeProcess, useStore, actions, t } = seatProps
const [open, setOpen] = useState(expandedSteps)
useEffect(() => { setOpen(expandedSteps) }, [expandedSteps])
const [open, setOpen] = useState(false)
const bodyId = useId()
const presentation = useChatNodeProcess(range.seats[0].nodeKey)
const spec = presentation?.spec
const stored = useStore(state => spec === undefined ? undefined : storedTurnProcessEntry(state, spec.turn))
const alwaysOpen = (presentation !== undefined && !presentation.turnClosed)
|| nodes.some(turnProcessAlwaysOpen)
const outerFoldable = seatProps.compactTranscript
&& (!seatProps.historyIncomplete || presentation?.turnStarted === true)
const outerFoldable = (!seatProps.historyIncomplete || presentation?.turnStarted === true)
&& spec !== undefined
const outerHidden = outerFoldable && !alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0)
const revealOuter = useCallback(() => {
@@ -52,12 +51,15 @@ function StepProcess({ range, nodes, expandedSteps, ...seatProps }: SeatProps &
const bodyRef = useSearchableHidden(!open, reveal)
const summary = processActivity(nodes)
const label = processTitle(summary, range.closed, t)
const detail = !seatProps.compactTranscript && !range.closed
? summary.runningDetail
: ''
return (
<div ref={rootRef} className={css.root} data-chat-flow-key={range.key}
data-chat-anchor-key={range.key} data-chat-turn={range.turn} data-step-process>
<button type="button" className={css.title} aria-expanded={open} aria-controls={bodyId}
onClick={(event) => { event.currentTarget.focus(); setOpen(!open) }}>
<span>{label}</span>
<span className={detail === '' ? undefined : css.label}>{detail === '' ? label : `${label} ${detail}`}</span>
<IconChevronDownOutlineRegular className={css.chevron} />
</button>
<div ref={bodyRef} id={bodyId} className={`${css.body} ${flowCss.processBody}`} data-step-process-body>
@@ -23,6 +23,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
return () => { clearInterval(timer) }
}, [ticking])
if (!turnProcess.foldable) return null
const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn?.status === 'open'
const reason = turn?.end?.data.reason.kind
const duration = turn?.start === undefined ? undefined
@@ -41,7 +42,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
disabled={!turnProcess.hasContent || turnProcessAlwaysOpen(node)}
disabled={!canCollapse}
aria-expanded={turnProcess.hasContent ? open : undefined}
onClick={(event) => {
event.currentTarget.focus()
@@ -49,7 +50,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
}}
>
<span className={css.label}>{label}</span>
{turnProcess.hasContent && <IconChevronDownOutlineRegular className={css.chevron} />}
{canCollapse && <IconChevronDownOutlineRegular className={css.chevron} />}
</button>
)
})
@@ -90,18 +90,42 @@ function activity(name: string): ProcessActivity {
return 'tools'
}
function liveToolDetail(argsRaw: string): string {
let args: unknown
try {
args = JSON.parse(argsRaw)
} catch {
// Partial argument JSON has no complete detail to preview.
return ''
}
if (args === null || typeof args !== 'object') return ''
for (const key of ['command', 'cmd', 'queries', 'query', 'pattern', 'url', 'file_path', 'path', 'description']) {
if (key in args) {
const value: unknown = Reflect.get(args, key)
if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
const detail = value.join(', ').replace(/\s+/g, ' ').trim()
if (detail !== '') return detail
}
if (typeof value === 'string' && value.trim() !== '') return value.replace(/\s+/g, ' ').trim()
}
}
return ''
}
/**
* Rank categories by distinct call count, breaking ties by first appearance.
* @param nodes - process members, including recursive tools.
* @returns all ranked categories and the latest running tool category.
* @returns all ranked categories and the latest running tool category and argument preview.
*/
export function processActivity(nodes: readonly ChatNode[]): {
counts: readonly { kind: ProcessActivity; count: number }[]
running: ProcessActivity | undefined
runningDetail: string
} {
const counts = new Map<ProcessActivity, number>()
const seen = new Set<string>()
let running: ProcessActivity | undefined
let runningDetail = ''
let runningTime = -Infinity
const visit = (tool: ToolCallBlock): void => {
if (seen.has(tool.callId)) return
@@ -111,6 +135,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
const kind = activity(call.name)
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
runningDetail = liveToolDetail(tool.argsRaw)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
@@ -123,6 +148,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
return {
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
running,
runningDetail,
}
}
@@ -78,6 +78,9 @@ export interface ChatNodeTurnDataInjected {
/** Stable owner currency delivered to a keyed Chat renderer. */
export interface ChatNodeOwnerProps {
/** Hide settled reasoning previews in Compact work details. */
readonly compactTranscript?: boolean
cwd?: string | undefined
/** Open the current source file of a skill referenced by a sent message. */
openSkill: (name: string) => void
@@ -629,6 +629,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: chinese }) as HTMLButtonElement
expect(toggle.disabled).toBe(true)
expect(toggle.querySelector('svg')).toBeNull()
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(view.container.querySelector('[data-step-process]')?.hasAttribute('hidden')).toBe(false)
@@ -655,6 +656,36 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: plural })).toBeTruthy()
})
it.each([
['bash', { command: 'git status', description: 'Inspect changes' }, '正在运行命令 git status'],
['web_search', { queries: ['Cordis plugins', 'DSH tools'] }, '正在搜索网页 Cordis plugins, DSH tools'],
['web_fetch', { url: 'https://example.com/docs' }, '正在访问网页 https://example.com/docs'],
['read', { file_path: '/src/main.ts' }, '正在读取文件 /src/main.ts'],
])('shows live %s details only in Detailed secondary titles', (name, args, expected) => {
const h = makeHarness({
nodes: [userInTurn(1, 'inspect', 2), reasoningAssistant(2, 'private reasoning', 2)],
runningCalls: [{ ...runningCall('work', name), argsRaw: JSON.stringify(args) }],
running: true,
})
const view = render(<h.ChatView {...h.props} />)
const title = () => view.container.querySelector('[data-step-process] > button')?.textContent
const abstract = title()
act(() => { h.setTranscriptView('detailed') })
expect(title()).toBe(expected)
expect(title()).not.toContain('private reasoning')
act(() => { h.setTranscriptView('expanded') })
expect(title()).toBeUndefined()
act(() => { h.setTranscriptView('compact') })
expect(title()).toBe(abstract)
act(() => {
h.setTranscriptView('detailed')
h.setChat({ runningCalls: [], partial: { turn: 2, step: 2, blocks: [
{ kind: 'reasoning', text: 'private reasoning' },
] } })
})
expect(title()).toBe('正在分析请求')
})
it('closes a work range on streamed response text and keeps its expansion while more work runs', () => {
const h = makeHarness({
nodes: [userInTurn(1, 'inspect', 2), context(2, 'private runtime context', 2), reasoningAssistant(2.5, 'initial reasoning', 2)],
@@ -1722,6 +1753,7 @@ describe('ChatView', () => {
expect(toggle.getAttribute('aria-expanded')).toBeNull()
act(() => { h.set({ nodes: [user(1, 'question'), context(2, 'working context', 1)] }) })
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')] : [])],
@@ -1730,6 +1762,7 @@ describe('ChatView', () => {
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(group.getAttribute('hidden')).toBe('until-found')
expect((toggle as HTMLButtonElement).disabled).toBe(false)
expect(toggle.querySelector('svg')).not.toBeNull()
act(() => { vi.advanceTimersByTime(3_000) })
expect(toggle.textContent).toBe('Took 2s')
view.unmount()
@@ -1748,7 +1781,7 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
act(() => {
h.set({
@@ -1760,7 +1793,7 @@ describe('ChatView', () => {
})
const toggle = turnProcessControl(view.container)!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(processRow().getAttribute('hidden')).toBe('until-found')
})
it('switches completed Turns between the persisted Expanded and Compact modes', () => {
@@ -1770,18 +1803,21 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 5]]),
})
const view = render(<h.ChatView {...h.props} />)
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(processRow().getAttribute('hidden')).toBe('until-found')
act(() => { h.setTranscriptView('expanded') })
expect(turnProcessControl(view.container)).toBeNull()
expect(processRow.getAttribute('hidden')).toBeNull()
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(processRow().getAttribute('hidden')).toBe('until-found')
fireEvent.click(turnProcessControl(view.container)!)
expect(processRow().getAttribute('hidden')).toBeNull()
fireEvent.click(turnProcessControl(view.container)!)
act(() => { h.setTranscriptView('compact') })
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(processRow().getAttribute('hidden')).toBe('until-found')
})
it('resets nested process disclosures when the whole Turn closes', () => {
@@ -1798,21 +1834,46 @@ describe('ChatView', () => {
expect(inner.getAttribute('aria-expanded')).toBe('false')
})
it('switches secondary defaults between Detailed and Expanded and permits manual toggles', () => {
it('previews settled reasoning only in Detailed and Expanded work details', () => {
const final = {
...assistant(3, 'answer', 1, 1),
blocks: [
{ kind: 'reasoning' as const, text: '**Inspect the session**\nCheck persistence' },
{ kind: 'text' as const, text: 'answer' },
],
}
const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText('Inspect the session')).toBeNull()
for (const mode of ['detailed', 'expanded'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(view.queryByText('Check persistence')).toBeNull()
}
act(() => { h.setTranscriptView('compact') })
expect(view.queryByText('Inspect the session')).toBeNull()
})
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')],
turnEnds: new Map([[1, 4]]) })
const view = render(<h.ChatView {...h.props} />)
act(() => { h.setTranscriptView('detailed') })
expect(turnProcessControl(view.container)).toBeNull()
const body = view.container.querySelector('[data-step-process-body]')!
const toggle = view.container.querySelector<HTMLButtonElement>('[data-step-process] > button')!
expect(body.getAttribute('hidden')).toBe('until-found')
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[data-step-process-body]')?.getAttribute('hidden')).toBe('until-found')
act(() => { h.setTranscriptView('expanded') })
expect(body.hasAttribute('hidden')).toBe(false)
fireEvent.click(toggle)
expect(body.getAttribute('hidden')).toBe('until-found')
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
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"]')!
expect(contextRow.parentElement?.hasAttribute('data-chat-flow')).toBe(true)
expect(contextRow.closest('[hidden]')).toBeNull()
expect(view.getByText('answer')).toBeTruthy()
act(() => { h.setTranscriptView('detailed') })
fireEvent.click(toggle)
const body = view.container.querySelector('[data-step-process-body]')!
expect(body.getAttribute('hidden')).toBe('until-found')
fireEvent.click(view.container.querySelector<HTMLButtonElement>('[data-step-process] > button')!)
expect(body.hasAttribute('hidden')).toBe(false)
})
@@ -40,7 +40,9 @@ describe('process activity summary', () => {
it('reports the newest running nested call even when an older root is visited later', () => {
const root = { ...call('code', 'run_code'), subCalls: [{ ...call('read', 'read'), time: 3, argsRaw: '{' }] }
expect(processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }])).running).toBe('read')
const summary = processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }]))
expect(summary.running).toBe('read')
expect(summary.runningDetail).toBe('')
})
it.each([
@@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:232',
},
{
key: 'conversation.chat.commandview',
@@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:220',
},
{
key: 'conversation.chat.node',
@@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
],
ownerProps: [
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Hide settled reasoning previews in Compact work details. */\n readonly compactTranscript?: boolean\n\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
],
ownerPropsReferences: [
'MarkdownFileMentions',
@@ -294,7 +294,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
},
{
key: 'conversation.chat.turnTail',
@@ -1129,7 +1129,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:214',
},
{
key: 'conversation.plan-review.actions',