deepseek-harness Think 行交互契约:用 expandOnRowClick 策略构建单一可访问披露目标
本文基于 deepseek-harness 仓库中一份已归档的 Bug 修复决策记录(Agent Note),讲解 Web 客户端中“思考”(Think)折叠行如何把标题与推理摘要合并为一个可点击、可键盘操作的披露(disclosure)目标:核心是 DisclosureRow 基元上新增的 opt-in 策略 expandOnRowClick,以及 Think 行(ReasoningRow)对该策略的启用方式。读完本文,你能理解该策略在组件层的完整实现(role/tabIndex/aria-expanded/Enter/Space)、它如何在不破坏通用工具行契约的前提下扩大命中区域,以及如何用组件级 spec 与 keyless 浏览器 e2e 双层验证交互行为。
问题背景:icon-only 展开控件让可见标签“失效”
在 deepseek-harness 的 Web 会话界面里,一条折叠状态的推理(reasoning)条目呈现为一个视觉行:前导图标 + Think 标题 + 单行推理摘要。修复前,这个行的展开/折叠只由前导图标位置的控件承担——即所谓的 icon-only disclosure:
- 视觉上,“
Think”与推理摘要共同构成行的主体信息,但它们本身不可点击,只有 16px 的图标区域才能触发展开; - 对用户和辅助技术而言,描述了隐藏内容的两个可见标签是“inert(无响应)”的,命中区域与语义描述彼此割裂;
- 一个直觉的错误解法是把“点击标题即展开”应用到所有工具行上。但这会打破通用工具行(generic tool row)的既有契约:在通用工具行中,行点击承担“选中详情”(row-to-details selection)的职责,只有前导控件负责展开参数。两种控制语义混在一个交互面上,会让同一点击行为产生两种含义。
原始决策记录的完整表述见 Problem 小节,中文对照见 2026-07-23-thinking-row-disclosure-target.zh.md。
决策:DisclosureRow 基元上的 opt-in 策略
决策的核心是:不改变通用行的默认行为,而是让行基元暴露一个显式策略开关,Think 行选择性启用。从当前仓库源码看,该策略最终落在共享基元 DisclosureRow 上:
DisclosureRow(packages/client/ui-primitives/src/DisclosureRow.tsx)是客户端各包共用的“24px 紧凑流程行”披露基元,其 props 契约中包含:
export interface DisclosureRowProps {
icon: ReactNode
title: string
open: boolean
expandable: boolean
onToggle: () => void
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
/** Keeps `collapsedContent` inline while open. */
keepContentWhenOpen?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
// ...className 家族(row/leading/chevron/title)
}
策略生效的逻辑是 const rowExpands = expandable && expandOnRowClick(DisclosureRow.tsx#L50)。expandOnRowClick 缺省为 false,即默认保持原有契约:
-
启用(
rowExpands === true)时,整行被提升为披露目标(DisclosureRow.tsx#L72-L83):<div className={clsx(css.row, rowClassName)} data-disclosure-row data-expandable={rowExpands || undefined} role={rowExpands ? 'button' : undefined} tabIndex={rowExpands ? 0 : undefined} aria-expanded={rowExpands ? open : undefined} onClick={rowExpands ? onToggle : undefined} onKeyDown={rowExpands ? toggleFromKeyboard : undefined} >整行获得
role="button"、tabIndex=0与aria-expanded,指针点击和键盘(Enter、Space,见 toggleFromKeyboard,并对按键preventDefault防止空格滚动页面)都作用在同一个组件本地展开状态上;此时前导图标降级为普通<span>,不再是独立按钮。 -
未启用时,前导图标仍渲染为带
aria-expanded的原生<button>(DisclosureRow.tsx#L84-L93),点击时stopPropagation后触发onToggle——这正是通用行保留的“前导控件负责展开”契约。
配套的视觉约定在 DisclosureRow.module.css 中:行高 24px(随 Settings 字号偏好轴 --dsh-content-font-delta 联动伸缩)、[data-expandable] 行显示 cursor: pointer、悬停时图标与 chevron 做 100ms 透明度过渡互换(previewChevron 策略)。
Think 行:标题 + 摘要 = 一个可访问披露目标
决策文档中称为 ThinkRow 的组件,在当前源码中对应会话包的 ReasoningRow.tsx(由 AssistantMarkdown.tsx 将 kind: 'reasoning' 消息块渲染为该组件)。其关键实现:
export function ReasoningRow({ text, running, t }: { ... }) {
const [expanded, setExpanded] = useState(false) // 组件本地展开状态
const summary = running ? latestLine(text) : firstLine(text)
// ...
return (
<div className={css.root} data-variant="think" data-state={running ? 'running' : 'ok'}>
{running && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
<DisclosureRow
icon={<IconThinkOutline14 size={14} />}
title={t('message.think')}
open={expanded}
expandable
expandOnRowClick // 启用整行披露策略
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={
<>
<span className={css.separator} aria-hidden />
<span ref={summaryRef} className={css.summary} data-follow-end={running || undefined}>
{summary}
</span>
</>
}
>
<div className={css.thinkBody}>{text}</div>
</DisclosureRow>
</div>
)
}
几个值得注意的实现细节:
- 单一状态,多入口。指针点击行内任意位置(
Think标题、推理摘要、分隔符)、Enter、Space都调用同一个onToggle,翻转的是同一个useState本地状态——不存在两个按钮各自维护一份展开状态的问题(这正是原始决策中否决“渲染独立 title 与 summary 两个按钮”方案的原因,见下文“备选方案”)。 - 摘要随流式状态切换。
running期间摘要取最后一行(latestLine),流结束后回到首行(firstLine);配合useThrottledVisualUpdate节流滚动,使摘要在流式期间自动跟随到文本末尾(scrollLeft = scrollWidth - clientWidth),并带data-follow-end标记(ReasoningRow.tsx#L29-L38)。 - 展开体是纯散文。展开后内联摘要消失,正文直接渲染为
thinkBody,不套 Input/Output(IN/OUT)卡片——与工具行的 IN/OUT 契约刻意区分。 - 运行态的可访问性补位。
running时插入一个视觉隐藏的状态文本(t('row.running')),因为行上的动画提示本身是颜色/纯视觉信号。
通用工具行契约的对照:ToolRow 的策略使用
决策记录强调,通用工具行的契约是“行点击选中详情、仅前导控件展开参数”。作为对照,看工具行组件 ToolRow.tsx:
- 可展开性由内容决定:
const expandable = body !== null || outputText !== null || card !== null(ToolRow.tsx#L126-L127),即有输入、输出或专属卡片(terminal/diff/read/search/web/ask-question)时才expandable; - 展开状态同样是组件本地的
useState(ToolRow.tsx#L110),toggleExpand简单翻转(ToolRow.tsx#L134-L136); - 折叠摘要支持
summarySuffix(在省略号裁切前先保住尾部片段)、文件路径可点击打开(openFile内stopPropagation,并有fileLinkKeyDown阻止 Enter/Space 冒泡到行级 keydown——注释明确说明了这是键盘版stopPropagation,避免链接激活被误当作展开切换,见 ToolRow.tsx#L137-L147); - 从当前源码结构看,
ToolRow渲染DisclosureRow时也传入了expandOnRowClick与keepContentWhenOpen(ToolRow.tsx#L154-L165),前者让整行成为披露目标,后者使展开后摘要仍保持内联(keepContentWhenOpen在DisclosureRow中体现为{(keepContentWhenOpen || !open) && collapsedContent},DisclosureRow.tsx#L99)。
这里的分层关系值得强调:策略开关的默认值始终是 false(基元层不改变任何既有消费方的行为),启用与否由各业务行组件显式声明——这正是“opt-in 策略”的含义。原始决策记录将这一政策归纳为:披露的所有权(disclosure ownership)在推理行与工具调用行之间本就不同,通用行组件只携带一个可选策略,而不强制统一的交互语义(Consequences 一节原文见 Agent Note)。
验证:组件 spec 钉住两个入口 + keyless 浏览器回放
决策记录的 Verification 一节描述了双层验证,仓库中均可对应到具体测试:
组件级 spec。reasoning-row.client.spec.tsx 通过真实渲染管线(AssistantMarkdown 消息块)验证 ReasoningRow,核心用例:
- “expands from either Think or the reasoning summary”(spec#L89-L106):用
getByRole('button')确认整行是唯一披露按钮;先点击摘要文本,断言aria-expanded === 'true'且展开正文可见;再点击标题(中文 locale 下为“思考”),断言aria-expanded === 'false'——即标题与摘要两个点击目标确实共享同一披露状态; - “expanded Think drops the inline summary and renders plain prose, no IN card”(spec#L108-L122):展开后内联摘要消失(全文仅剩一份推理文本)、不存在
IN标签与ioCard、thinkBody存在——钉住了 Think 行与工具行 IN/OUT 契约的边界; - 流式摘要跟随用例(spec#L43-L87):验证
running期间摘要跟随最新行并滚动到行尾(scrollLeft由 0 变为 200),流结束后恢复首行、data-follow-end移除。
keyless 浏览器 e2e。决策记录描述的“keyless 浏览器 fixture 加载真实 sidebar 与 conversation bundle、打开已编写的推理会话、点击摘要与标题并检查披露状态和展开正文”,对应仓库的 Web e2e 体系:如 seeded-history.e2e.ts 等用例以 seed 会话回放方式驱动真实浏览器,通过 [data-expandable] 选择器断言 aria-expanded 的 false → true 翻转——与 DisclosureRow 在启用策略时写出的 data-expandable 标记(DisclosureRow.tsx#L77)直接对应。推理相关的回放场景另见 declared-reasoning.e2e.ts。这类用例无需 API key(keyless 回放模式),与 packages/client/AGENTS.md 中描述的 DSH_SNAPSHOT=replay pnpm run test:web 浏览器冒烟/replay 门禁一致。
备选方案与拒绝理由
原始决策记录完整保留了三个备选方案及拒绝理由,这里原样继承:
| 备选方案 | 拒绝理由 |
|---|---|
| 让每个工具行都可从标题展开 | 通用工具行用行点击做“详情选中”,若共享“点标题展开”会混淆两个控件的语义(conflate two controls) |
| 维持 icon-only 披露(即修复前状态) | 最小的命中目标与描述隐藏内容的标签相互脱节(disconnected from the labels) |
| 为标题和摘要渲染两个独立按钮 | 一个展开状态对应两个控件,产生重复的焦点停靠点(duplicate focus stops)与含糊的语义 |
第一个方案的取舍尤其关键:它意味着“整行即披露目标”不是全局交互风格,而是按行类型显式授予的能力——推理行的行点击没有别的职责,可以安全地承担披露;而带“选中详情”语义的行则不能。
结论:更大的指针目标与键盘语义,且不动其他交互
这个修复的最终后果(Consequences)可以概括为两点:
- Think 行获得了更大的指针命中目标与键盘披露语义(
role="button"+tabIndex=0+aria-expanded+ Enter/Space),而其他工具行的交互保持不变——未启用的消费方依旧得到“行点击做别的、前导控件负责展开”的原契约; - 通用行基元只携带一个可选策略(
DisclosureRow的expandOnRowClick,缺省false),因为推理行与工具调用行的披露所有权本就不同。策略以显式 opt-in 形式存在,避免了用全局行为改写去解决单个行的可访问性问题。
对仓库后续开发者的实践提示:如果你要新增一类“整行即披露”的紧凑流程行(例如新的系统提示行、上下文注入行——SystemPromptRow.tsx、ContextInjectionRow.tsx、TurnUsageDisclosure.tsx 均已是 DisclosureRow 的消费方),直接复用 DisclosureRow 并按需传入 expandOnRowClick;而若你的行点击承担选中类语义,则不要启用该策略,保持前导 button 契约,并用 [data-expandable] / aria-expanded 断言钉住行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00