DeepSeek Harness 折叠侧边栏控制轨:零宽度锁死问题的 56px Rail 修复方案
本篇基于 DeepSeek Harness(以下简称 dsh)仓库中一份已归档的 Bug 修复 Agent Note(.agents/notes/archived/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md)展开,解析 Web 客户端三栏布局中"侧边栏折叠后所有恢复控件消失"这一锁死问题的完整修复方案:折叠状态为何映射为 56px 固定控制轨、布局求解器如何保证侧边栏列永不收缩,以及 AppFrame 与 SidebarRoot 之间通过 owner props 协作完成的 slide + crossfade 动画细节。读完本文,你可以掌握该布局的几何契约(columns.ts 常量表)、两状态切换的动画时序,以及配套测试如何锁定这些行为。
问题:折叠侧边栏会锁死所有恢复入口
修复前的行为链条非常清晰,全部记录在原 Agent Note 的 Problem 一节:
- 用户点击侧边栏的关闭动作时,布局持久化了一个 0 宽度偏好(width preference = 0);
- 布局把该偏好直接映射为一条 0 宽度的 grid track;
- 侧边栏中仅有的两个"自救"控件——侧边栏开关(toggle) 和 设置入口——恰好都位于这条被裁剪掉的轨道内部;
- 于是折叠操作把每个可见的恢复控件一并移除,用户无法再展开侧边栏;
- 刷新页面后,关闭偏好被保留,锁死状态被完整复现。
换句话说,问题不只是"宽度动画不自然",而是一个可用性死锁:偏好存储(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: boolean 与 width: 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-L74 的 lastWideWidth 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 冒烟测试钉住折叠与恢复"一一对应:
- columns.client.spec.ts:验证
sidebar === 0时解析结果为SIDEBAR_COLLAPSED,例如 1920px 视口下{ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 },并验证折叠 rail 仍参与让步链(空间受限时 details 先收缩、后自动关闭); - app-frame.client.spec.tsx:验证帧级轨道
[SIDEBAR_COLLAPSED, 0]与 slot 调用props为{ collapsed: true, width: SIDEBAR_COLLAPSED }; - sidebar-root.client.spec.tsx 及快照 sidebar-snapshot.client.spec.tsx.snap:钉住 rail 可见控件集合与折叠/展开的 DOM 结构。
小结
这次修复的本质是一次语义与几何的重新对齐:"关闭"在偏好层仍是 0,但在渲染层被解释为一个 56px 的固定 rail 契约——恢复控件(toggle + 设置)永远有可见的落脚点,拖拽手柄、过渡与内容树则按两状态各自裁剪。整个方案由 columns.ts 的纯函数求解器、AppFrame.tsx 的 owner props 传递与 SidebarRoot.tsx 的 slide + crossfade 动画共同支撑,并通过求解器、帧、组件与真实 bundle 冒烟四级测试钉住行为,是可作为"布局折叠态死锁"参考的完整案例。
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