首页
/ deepseek-harness Think 行交互契约:用 expandOnRowClick 策略构建单一可访问披露目标

deepseek-harness Think 行交互契约:用 expandOnRowClick 策略构建单一可访问披露目标

2026-09-04 22:01:49作者:余洋婵Anita

本文基于 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 上:

DisclosureRowpackages/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 && expandOnRowClickDisclosureRow.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=0aria-expanded,指针点击和键盘(EnterSpace,见 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.tsxkind: '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>
  )
}

(见 ReasoningRow.tsx#L27-L65

几个值得注意的实现细节:

  1. 单一状态,多入口。指针点击行内任意位置(Think 标题、推理摘要、分隔符)、EnterSpace 都调用同一个 onToggle,翻转的是同一个 useState 本地状态——不存在两个按钮各自维护一份展开状态的问题(这正是原始决策中否决“渲染独立 title 与 summary 两个按钮”方案的原因,见下文“备选方案”)。
  2. 摘要随流式状态切换running 期间摘要取最后一行latestLine),流结束后回到首行firstLine);配合 useThrottledVisualUpdate 节流滚动,使摘要在流式期间自动跟随到文本末尾(scrollLeft = scrollWidth - clientWidth),并带 data-follow-end 标记(ReasoningRow.tsx#L29-L38)。
  3. 展开体是纯散文。展开后内联摘要消失,正文直接渲染为 thinkBody,不套 Input/Output(IN/OUT)卡片——与工具行的 IN/OUT 契约刻意区分。
  4. 运行态的可访问性补位running 时插入一个视觉隐藏的状态文本(t('row.running')),因为行上的动画提示本身是颜色/纯视觉信号。

通用工具行契约的对照:ToolRow 的策略使用

决策记录强调,通用工具行的契约是“行点击选中详情、仅前导控件展开参数”。作为对照,看工具行组件 ToolRow.tsx

  • 可展开性由内容决定:const expandable = body !== null || outputText !== null || card !== nullToolRow.tsx#L126-L127),即有输入、输出或专属卡片(terminal/diff/read/search/web/ask-question)时才 expandable
  • 展开状态同样是组件本地的 useStateToolRow.tsx#L110),toggleExpand 简单翻转(ToolRow.tsx#L134-L136);
  • 折叠摘要支持 summarySuffix(在省略号裁切前先保住尾部片段)、文件路径可点击打开(openFilestopPropagation,并有 fileLinkKeyDown 阻止 Enter/Space 冒泡到行级 keydown——注释明确说明了这是键盘版 stopPropagation,避免链接激活被误当作展开切换,见 ToolRow.tsx#L137-L147);
  • 从当前源码结构看,ToolRow 渲染 DisclosureRow 时也传入了 expandOnRowClickkeepContentWhenOpenToolRow.tsx#L154-L165),前者让整行成为披露目标,后者使展开后摘要仍保持内联(keepContentWhenOpenDisclosureRow 中体现为 {(keepContentWhenOpen || !open) && collapsedContent}DisclosureRow.tsx#L99)。

这里的分层关系值得强调:策略开关的默认值始终是 false(基元层不改变任何既有消费方的行为),启用与否由各业务行组件显式声明——这正是“opt-in 策略”的含义。原始决策记录将这一政策归纳为:披露的所有权(disclosure ownership)在推理行与工具调用行之间本就不同,通用行组件只携带一个可选策略,而不强制统一的交互语义(Consequences 一节原文见 Agent Note)。

验证:组件 spec 钉住两个入口 + keyless 浏览器回放

决策记录的 Verification 一节描述了双层验证,仓库中均可对应到具体测试:

组件级 specreasoning-row.client.spec.tsx 通过真实渲染管线(AssistantMarkdown 消息块)验证 ReasoningRow,核心用例:

  1. “expands from either Think or the reasoning summary”spec#L89-L106):用 getByRole('button') 确认整行是唯一披露按钮;先点击摘要文本,断言 aria-expanded === 'true' 且展开正文可见;再点击标题(中文 locale 下为“思考”),断言 aria-expanded === 'false'——即标题与摘要两个点击目标确实共享同一披露状态;
  2. “expanded Think drops the inline summary and renders plain prose, no IN card”spec#L108-L122):展开后内联摘要消失(全文仅剩一份推理文本)、不存在 IN 标签与 ioCardthinkBody 存在——钉住了 Think 行与工具行 IN/OUT 契约的边界;
  3. 流式摘要跟随用例(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-expandedfalse → 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)可以概括为两点:

  1. Think 行获得了更大的指针命中目标与键盘披露语义role="button" + tabIndex=0 + aria-expanded + Enter/Space),而其他工具行的交互保持不变——未启用的消费方依旧得到“行点击做别的、前导控件负责展开”的原契约;
  2. 通用行基元只携带一个可选策略DisclosureRowexpandOnRowClick,缺省 false),因为推理行与工具调用行的披露所有权本就不同。策略以显式 opt-in 形式存在,避免了用全局行为改写去解决单个行的可访问性问题。

对仓库后续开发者的实践提示:如果你要新增一类“整行即披露”的紧凑流程行(例如新的系统提示行、上下文注入行——SystemPromptRow.tsxContextInjectionRow.tsxTurnUsageDisclosure.tsx 均已是 DisclosureRow 的消费方),直接复用 DisclosureRow 并按需传入 expandOnRowClick;而若你的行点击承担选中类语义,则不要启用该策略,保持前导 button 契约,并用 [data-expandable] / aria-expanded 断言钉住行为。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384