首页
/ DeepSeek Harness 折叠侧边栏控制轨:零宽度锁死问题的 56px Rail 修复方案

DeepSeek Harness 折叠侧边栏控制轨:零宽度锁死问题的 56px Rail 修复方案

2026-09-04 18:03:42作者:毕习沙Eudora

本篇基于 DeepSeek Harness(以下简称 dsh)仓库中一份已归档的 Bug 修复 Agent Note(.agents/notes/archived/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md)展开,解析 Web 客户端三栏布局中"侧边栏折叠后所有恢复控件消失"这一锁死问题的完整修复方案:折叠状态为何映射为 56px 固定控制轨、布局求解器如何保证侧边栏列永不收缩,以及 AppFrameSidebarRoot 之间通过 owner props 协作完成的 slide + crossfade 动画细节。读完本文,你可以掌握该布局的几何契约(columns.ts 常量表)、两状态切换的动画时序,以及配套测试如何锁定这些行为。

问题:折叠侧边栏会锁死所有恢复入口

修复前的行为链条非常清晰,全部记录在原 Agent Note 的 Problem 一节:

  1. 用户点击侧边栏的关闭动作时,布局持久化了一个 0 宽度偏好(width preference = 0);
  2. 布局把该偏好直接映射为一条 0 宽度的 grid track;
  3. 侧边栏中仅有的两个"自救"控件——侧边栏开关(toggle)设置入口——恰好都位于这条被裁剪掉的轨道内部;
  4. 于是折叠操作把每个可见的恢复控件一并移除,用户无法再展开侧边栏;
  5. 刷新页面后,关闭偏好被保留,锁死状态被完整复现。

换句话说,问题不只是"宽度动画不自然",而是一个可用性死锁:偏好存储(persisted preference)与几何映射(track width)之间缺少一个中间态。修复的核心思路就是给"关闭"这个语义一个非零的渲染宽度,让恢复控件永远有一个可见的落脚点。

决策:折叠态映射为 56px 固定控制轨

Note 的 Decision 一节给出了最终方案,可以拆解为四条要点:

  • 几何映射:布局把"侧边栏已关闭"(持久化宽度为 0)映射为固定常量 SIDEBAR_COLLAPSED = 56px——即两侧 16px 水平内边距之间的一条 24px 图标列;
  • 求解器契约:侧边栏 track 在求解器中是定宽的——无论展开还是折叠,它都从不向视口压力让步(never concedes);只有 details 列会被压缩,随后被自动关闭;
  • 边界与偏好:collapsed 状态下侧边栏保留右侧边框,而存储的展开宽度原封不动,展开时可直接恢复拖拽宽度;
  • 状态判定来源:AppFrame 依据持久化宽度偏好而非解析后的 track 宽度来判定折叠态,折叠时移除拖拽手柄,并把 collapsed 作为 owner props 从渲染站点传入 sidebar slot。

常量定义位于 columns.ts,其 JSDoc 直接说明了这条契约:"a closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while closed details resolve to zero width"。注意一个关键不对称:关闭的 details 解析为 0 宽度(子树保持挂载但不占位),而关闭的 sidebar 保留紧凑 rail——这正是修复锁死问题的几何基础。

从源码结构看,这个 56px 的取值在 UI 层同样有对应实现:SidebarRoot.module.css 的注释说明 rail 内的控件是 36×36 的盒子,折叠态根节点切换为 padding: 18px 10px 6px 的 rail 几何,使图标在 56px 轨道内居中。

布局求解器:永不收缩的三栏让步链

computeColumns 是纯函数,输入为 (viewport, sidebar 偏好, details 偏好),输出三列解析宽度。其让步链按契约固定排序,完整逻辑见 columns.ts:

export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
  // 侧边栏固定在偏好宽度(或 rail)上——它从不让步
  const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
  const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)

  // 步骤 1:所有列都以偏好宽度放得下
  if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 }

  // 步骤 2:details 向其最小值收缩
  const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN)
  if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 }

  // 步骤 3:自动关闭 details(派生结果,偏好不动),
  // center 吸收剩余缺口(可能跌破 CENTER_MIN)
  return { sidebar: s, center: Math.max(0, viewport - s), details: 0 }
}

其中 sidebar === 0 ? SIDEBAR_COLLAPSED : ... 这一行就是整个修复的几何锚点:持久化的"0"偏好在这里被翻译为 56px 轨道,后续所有步骤都不再触碰 s

合同冻结的几何常量(定义于 columns.ts):

常量 含义
CENTER_MIN 640 中栏地板,仅最终 fallback 可跌破
SIDEBAR_MIN / SIDEBAR_MAX 264 / 420 侧边栏拖拽夹取范围
SIDEBAR_DEFAULT 280 未拖拽前的侧边栏宽度
SIDEBAR_COLLAPSED 56 关闭态 rail:16px 边距间一条 24px 图标列
SIDEBAR_AUTO_COLLAPSE 1024 视口低于此值时侧边栏自动折叠为 rail
DETAILS_MIN / DETAILS_MAX 300 / 520 details 拖拽夹取范围
DETAILS_DEFAULT 360 未拖拽前的 details 宽度

一个值得注意的设计:求解器是无滞回(hysteresis-free)的纯函数——输出仅是 (viewport, preferences) 的函数,所以窗口重新拉宽时布局自动恢复,无需额外状态。偏好在求解边界处被重新夹取,因为偏好跨越 store 边界,调用方仍可能传入过期范围。

SIDEBAR_AUTO_COLLAPSE 断点不在求解器内消费,而是由 AppFrame 在求解前决定有效侧边栏偏好,从而保持求解器"无断点"。窄视口下的手动展开走另一条语义:它翻转 narrowExpanded 覆盖位而非宽度偏好,因此拉宽窗口后自动折叠前的布局得以完整恢复。

偏好存储:宽度即偏好,关闭即"遗忘"

布局状态是纯数值偏好(0 = 关闭),定义于 stores.ts:

type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean }

动作集与本次修复直接相关的是 toggleSidebar:

toggleSidebar: (d) => {
  if (d.narrow) d.narrowExpanded = !d.narrowExpanded
  else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
}

从源码结构看,注释点明了一条重要约定:"The preference IS the width, so closing a panel forgets its drag width —— reopening restores the contract default。"即偏好就是宽度本身:关闭时宽度偏好被写为 0,重开时恢复为契约默认值 280 而非记住上次拖拽宽度。这正是 Decision 中"stored expanded width remains untouched"的另一面——在宽视口下折叠只影响求解与渲染层,不会破坏偏好语义;窄视口下则完全不动宽度偏好,只翻转 narrowExpanded

AppFrame:owner props、手柄移除与轨道过渡

AppFrame.tsx 是注册进内置 root slot 的三栏外壳,拥有 grid track 与拖拽手柄。与本次修复相关的三处关键代码:

1. 折叠态由持久化偏好(及窄视口派生)决定,而非解析宽度(见 AppFrame.tsx#L146-L152):

const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow])
const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
const sidebarPreference = sidebarCollapsed
  ? 0
  : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details)

collapsed 的判定来源是偏好(加上窄视口派生的自动折叠),这与 Note 中"AppFrame marks the sidebar collapsed from the persisted width preference rather than from the resolved track width"一致。若反过来从解析后的 track 宽度判断,56px 的 rail 本身又会反过来改变判定输入,形成自指。

2. 折叠时移除拖拽手柄(见 AppFrame.tsx#L213-L215):

{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} ... />}

56px rail 是定宽列,拖拽无意义,故手柄仅在展开态渲染;details 手柄则按 cols.details > 0 条件渲染。

3. 把 collapsed 作为 owner props 从渲染站点传入 sidebar slot(见 AppFrame.tsx#L188-L198):

<div className={css.sidebarCol}>
  {/* 渲染站点 slot 调用:关闭态侧边栏以紧凑 rail 宽度保持 slot 挂载 */}
  {renderSlot('sidebar', {
    collapsed: sidebarCollapsed,
    width: cols.sidebar,
  })}
</div>

这就是 Note 所说"passes collapsed to the sidebar slot as owner props from the render site"。sidebar slot 的契约类型(SidebarOwnerProps,含 collapsed: booleanwidth: number)可在 slot-catalog.ts 中检索到,其注释为:"True when the sidebar is closed (the column renders the compact control rail)。"

轨道过渡定义在 AppFrame.module.css 中,与 Note 描述完全对应:

/* 帧与手柄在 deepsuite sider 曲线上过渡轨道宽度和手柄 left */
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
/* 拖拽期间暂停:缓动轨道会把列边缘从指针上脱开 */
.frame[data-dragging] { transition: none; }
@media (prefers-reduced-motion: reduce) { .frame { transition: none; } }

两个 CSS 变量 --ds-ease-in-out--ds-transition-duration-slow 由 ui-theme 的 base sheet 提供。过渡在两处被暂停:拖拽期间(手柄以指针节奏写宽度,缓动会使列边缘脱离手柄)与 prefers-reduced-motion

SidebarRoot:slide + crossfade 与 rail 控件

SidebarRoot.tsx 读取 owner 传入的 collapsed prop,把折叠实现为滑动(slide)+ 交叉淡入淡出(crossfade),而非形态插值(morph)。

阶段一:冻结宽度淡出。 折叠开始时,展开内容以行内样式冻结在其展开宽度上(SidebarRoot.tsx#L70-L74lastWideWidth ref),在原位 150ms 内淡出,同时 AppFrame 的滑动 grid 轨道把它裁掉——滑动过程中不发生任何重排:

// 折叠动画期间保持挂载(.collapsed .wide 淡出),settle 时卸载,展开时立即重挂载
const [settled, setSettled] = useState(collapsed)
useEffect(() => {
  if (!collapsed) { setSettled(false); return }
  const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
  return () => { window.clearTimeout(timer) }
}, [collapsed])
const wide = !collapsed || !settled

CSS 侧对应 .fading:

/* 折叠阶段一:冻结宽度内容整体 150ms 原位淡出;settle 时子节点卸载/切入 rail 布局 */
.fading > * {
  opacity: 0;
  transition: opacity 150ms var(--ds-ease-in-out);
}

COLLAPSE_SETTLE_MS = 150 与 150ms 淡出严格匹配,展开方向则用 200ms 的 wide-in 关键帧把 wide-only 内容淡回。

阶段二:settle 时卸载宽态内容并切入 rail。 到 settle 点,wide-only 内容(品牌、标签、输入框、会话树)卸载——这同时释放了 sessions 订阅,并把渲染树与可访问性树中不需要的控件一并清掉——而控制行则切换到 rail 布局(展开开关、新建会话、新建工作区、搜索,自上而下顺序与展开态行一致),并在滑动结束的剩余 150ms 内淡入:

/* rail-in:4 个上部控件从 rail 右缘(translateX(49px))滑入,与轨道过渡收尾对齐 */
.railIn .iconButton, .railIn .newSession, .railIn .regionArea {
  animation: rail-in 150ms var(--ds-ease-in-out) backwards;
}
.railIn .footArea {
  animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;  /* 底部设置位只淡入 */
}

railIn 类只加在一次实时的折叠上(由 everWide ref 判定):直接以折叠态冷启动(例如刷新时偏好就是关闭)会静态渲染 rail,不会出现延迟隐藏的图标闪现。

rail 控件逐一继承展开态行为。 每个 rail 控件保持其展开对应物的行为:搜索图标会展开侧边栏并在滑动结束后聚焦搜索框;搜索查询保存在 root 上,折叠往返(round trip)后依然保留;每个控件都带 tooltip;而展开开关的静止态是鲸鱼标记(whale mark,品牌 mark slot),hover 时切换为面板图标——这组行为在 SidebarRoot.tsx#L169-L186 与 CSS 的 .collapsed .toggle 规则 中实现:

/* 折叠时:静止为鲸鱼 mark(品牌墨色、无 hover 圆底),hover 时显示面板图标 */
.collapsed .toggle .panelIcon { display: none; }
.collapsed .toggle:hover .panelIcon { display: inline; }
.collapsed .toggle:hover .railMark { display: none; }

被否决的备选方案

Note 的 Alternatives 一节记录了三个被否决的方向,其取舍逻辑对同类布局问题有参考价值:

  • 在中栏上方渲染一个展开按钮 ——否决:它只能恢复 toggle 这一个控件,恢复不了持久化的设置区,而且把侧边栏 chrome 拆给了两个包属主;
  • 保留 0 宽度 track,让 rail 溢出 ——否决:rail 会覆盖中栏,且 hit testing 与响应式几何同 grid 脱钩;
  • 保持完整侧边栏树挂载、仅用裁剪隐藏 ——否决:隐藏控件仍留在语义树中持续订阅与渲染,而折叠态只需要两个控件。

三者分别对应"恢复不完整"、"几何契约破坏"与"隐藏状态残留",最终选定的 rail 方案同时满足恢复完整、几何自洽与状态精简三个目标。

影响与测试锁定

行为影响(Note 的 Consequences 一节):

  • 折叠侧边栏占用 56px,而非把全部宽度让给中栏;展开时恢复持久化宽度与拖拽行为;
  • 设置入口保持可见,但沿用其既有的占位行为——本次修复不引入账户或设置界面;
  • 断言由三层测试锁定(见下)。

测试覆盖,与 Consequences 中"布局求解器测试钉住紧凑宽度、侧边栏组件测试钉住可见控件、web 冒烟测试钉住折叠与恢复"一一对应:

小结

这次修复的本质是一次语义与几何的重新对齐:"关闭"在偏好层仍是 0,但在渲染层被解释为一个 56px 的固定 rail 契约——恢复控件(toggle + 设置)永远有可见的落脚点,拖拽手柄、过渡与内容树则按两状态各自裁剪。整个方案由 columns.ts 的纯函数求解器、AppFrame.tsx 的 owner props 传递与 SidebarRoot.tsx 的 slide + crossfade 动画共同支撑,并通过求解器、帧、组件与真实 bundle 冒烟四级测试钉住行为,是可作为"布局折叠态死锁"参考的完整案例。

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

项目优选

收起
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