deepseek-harness Web 布局:折叠侧边栏的 56px 控制栏——从"无法恢复的 UI 死锁"到可验证的布局契约
本文基于 deepseek-harness 仓库的修复档案 2026-07-22-collapsed-sidebar-control-rail 展开:它描述了一个典型的布局死锁问题——侧边栏关闭后持久化宽度偏好 0,求解器把该偏好映射为零宽网格轨道,开关与设置入口全部被裁切,且刷新页面后依旧无法恢复。读完本篇,你会掌握 deepseek-harness Web 客户端三栏布局的"让步链"求解算法、折叠态判定如何以 owner props 形式下发到侧边栏组件、以及"滑动 + 交叉淡变"的折叠动画如何在滑动过程中做到零重排。
问题:零宽轨道如何制造"不可恢复"的 UI
原档案(修复说明)把故障链描述得很精炼:
- 用户点击"关闭侧边栏",布局 store 持久化宽度偏好
0(在 deepseek-harness 的布局模型中,0就是"关闭"); - 三栏布局把
0直接映射为一条宽度为零的 CSS Grid 轨道; - 而侧边栏唯一的开关和设置入口恰好都长在这条被裁切的轨道里——关闭侧边栏的同时移除了所有恢复控件;
- 页面重新加载时 store 读到
0偏好,布局再次求解出零宽轨道,用户被永久锁在"没有侧边栏开关"的状态里。
这是一个"状态写下来就找不回来"的经典反模式:UI 的可达性(reachability)取决于渲染宽度,而渲染宽度又完全由该状态决定。修复的关键不是加一个"重新打开"的按钮,而是让关闭态本身拥有可见的几何存在——即折叠控制栏(collapsed control rail)。
决策一:求解器把"关闭"映射为固定的 56px 控制栏
布局求解逻辑集中在 columns.ts,这是一套"纯让步链"(concession-chain)三栏求解器。契约冻结的几何常量定义了全部固定点:
// packages/client/ui-layout/src/client/columns.ts(节选)
export const CENTER_MIN = 640 // 中心列下限;只有最终兜底允许跌破
export const SIDEBAR_MIN = 264 // 侧边栏拖拽钳制下限
export const SIDEBAR_MAX = 420 // 侧边栏拖拽钳制上限
export const SIDEBAR_DEFAULT = 280 // 未拖拽过的默认宽度
/** 关闭态控制栏:16px 水平内边距之间的一列 24px 图标。 */
export const SIDEBAR_COLLAPSED = 56
export const SIDEBAR_AUTO_COLLAPSE = 1024 // 低于此视口宽度自动折叠(LG 断点)
export const DETAILS_MIN = 300
export const DETAILS_MAX = 520
export const DETAILS_DEFAULT = 360
56px 的来历与档案一致:左右各 16px 水平内边距,中间容纳一列 24px 图标控件(16 + 24 + 16 = 56)。核心映射发生在 computeColumns 中:
// packages/client/ui-layout/src/client/columns.ts#L62-L77(节选)
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
// 侧边栏固定为其偏好值(或控制栏)——它从不让步。
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_MIN)。
return { sidebar: s, center: Math.max(0, viewport - s), details: 0 }
}
从源码结构看,这套求解器有三个值得注意的设计约束:
- 侧边栏轨道是定宽的,从不向视口压力让步。无论展开还是折叠,它只贡献
SIDEBAR_COLLAPSED(56px)或用户偏好宽度;视口变窄时,第一步放不下的缺口由 details 先收缩、再由 details 派生关闭(宽度归零)吸收,最后才是中心列跌破CENTER_MIN。这与档案中"只有 details 会收缩、继而自动关闭"的决策完全对应——对比之下,关闭的 details 才是真正零宽的轨道,因为它没有需要保留的恢复控件(details 由会话上下文打开)。 - 求解是无状态纯函数。档案强调"求解器本身不含断点":
SIDEBAR_AUTO_COLLAPSE(1024px 的自动折叠断点)不在computeColumns里消费,而是由AppFrame在调用前决定"有效偏好"。这让computeColumns成为 (viewport, preferences) 的纯函数——窗口重新拉宽后布局自动恢复,不需要滞回(hysteresis)状态。 - 偏好值跨越 store 边界,求解时二次钳制。调用方可能提供过期范围,所以
clampWidth在求解器入口再执行一次。
决策二:AppFrame 以 owner props 判定折叠,而不是看轨道宽度
AppFrame.tsx 是注册进内置 root 插槽的三栏外壳,拥有网格轨道、拖拽手柄和子插槽的渲染决策。档案要求"根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断",源码中的实现是:
// packages/client/ui-layout/src/client/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)
这里有两层判定,分别对应两种折叠来源:
- 手动关闭(宽视口):
panels.sidebar === 0,即持久化偏好为关闭; - 窄视口自动折叠:视口小于 1024px 时侧边栏自动收进控制栏,但用户仍可手动重新展开——这通过 store 中的
narrowExpanded覆盖位实现(见 stores.ts:窄视口下toggleSidebar只翻转narrowExpanded,不触碰宽度偏好,因此重新拉宽窗口能完整还原挤压前的布局)。
求解完成后,AppFrame 把折叠状态与实时宽度作为 owner props 在渲染点直接传给侧边栏插槽:
// packages/client/ui-layout/src/client/AppFrame.tsx#L188-L198(节选)
<div className={css.sidebarCol}>
{renderSlot('sidebar', {
collapsed: sidebarCollapsed,
width: cols.sidebar,
})}
</div>
注释点明了意图:关闭的侧边栏保持插槽挂载,只是宽度变为控制栏;组件通过 owner 参数看到自己的渲染状态。由于 collapsed 跟随求解结果,窄视口的派生自动折叠同样渲染控制栏 UI,而不仅是手动关闭。
折叠时还有两处与档案对应的具体行为:
-
移除尺寸调整手柄。折叠轨道是定宽的,没有拖拽意义,因此侧边栏手柄条件渲染:
{!sidebarCollapsed && <DragHandle side="sidebar" ... />}(AppFrame.tsx#L214)。拖拽采用 pointer capture + rAF 节流,且拖拽基点取自手势开始时的渲染宽度——抓取一个被让步链钳制过的面板不会跳回存储偏好。 -
轨道过渡曲线。frame 对
grid-template-columns应用过渡,定义在 AppFrame.module.css:/* 轨道宽度过渡:deepsuite 侧栏曲线 */ transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out); /* 拖拽期间暂停过渡,避免缓动轨道脱离指针 */ .frame[data-dragging] { /* ... */ }两个变量由 ui-theme 的 base 表提供(base.css):
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1)与--ds-transition-duration-slow: 0.3s。档案同时说明拖拽期间与prefers-reduced-motion下过渡暂停——CSS 通过data-dragging标记实现前者,prefers-reduced-motion由样式表统一处理。
决策三:SidebarRoot 的滑动 + 交叉淡变
SidebarRoot.tsx 只关心列内几何,不关心轨道本身。档案描述的"滑动 + 交叉淡变"在源码中分四个阶段实现:
1. settle 计时器:宽态内容延迟卸载。
// packages/client/ui-sidebar/src/client/SidebarRoot.tsx#L26-L27
/** 宽内容卸载延迟;与 150ms 宽内容淡出保持一致。 */
const COLLAPSE_SETTLE_MS = 150
wide = !collapsed || !settled 决定宽态内容(品牌标识、文字标签、输入框、会话树)是否仍在树上:折叠开始时尚未 settle,宽内容保持挂载并以内联样式冻结在展开宽度上原地淡出(150ms),滑动中的网格列自然裁切它——滑动途中不发生任何重排。settle 之后宽内容真正卸载,随之退订会话列表、离开渲染树与可访问性树;展开则立即重新挂载。
2. 宽度冻结:lastWideWidth。
// packages/client/ui-sidebar/src/client/SidebarRoot.tsx#L73-L74
const lastWideWidth = useRef(width)
if (!collapsed) lastWideWidth.current = width
非折叠时持续记录当前宽度,折叠动画期间用它作为内联宽度,配合 .fading 类完成原地淡出。
3. 控制栏控件:与展开态同序、同行为。
settle 后,控件行从同一水平偏移进入 56px 控制栏,自上而下与展开态顺序一致:打开/折叠开关(toggle)、新建会话、以及由 sidebar.workspaces 注册方承载的浏览区(新建工作区、搜索)。每个控制栏控件带 tooltip 并保持与展开态对应控件一致的行为。其中搜索图标点击时通过 owner 下发的 expandSidebar 回调展开侧边栏、滑动结束后聚焦搜索框,这一回调就在渲染点注入:
// packages/client/ui-sidebar/src/client/SidebarRoot.tsx#L205-L208
{renderSlot('sidebar.workspaces', {
wide,
expandSidebar: () => { if (collapsed) toggleSidebar() },
})}
开关控件的静息/悬停双形态也在这里:控制栏静息时显示鲸鱼品牌标记(renderSlot('sidebar.brand.mark', { size: 24 }, { fallback: <FishLogo size={24} /> })),悬停时切换为面板图标作为展开暗示(SidebarRoot.tsx#L169-L186)。
4. 关键词保持。 搜索关键词由根组件(workspaces 注册方)持有,折叠往返后保留——这一点由插槽参数 wide 的切换而非组件卸载来驱动,控制栏形态下输入状态不丢失。
一个容易忽略的细节:首次加载就直接处于折叠态(比如刷新后读到 0 偏好)时,everWide 标记为 false,控制栏静态渲染,不会播放淡入动画——避免用户看到一闪而过的"延迟隐藏图标"。
曾考虑的替代方案及其否决理由
原档案记录了三个被否决的方案,理由值得作为布局决策的参考:
| 替代方案 | 否决理由 |
|---|---|
| 在中心列上方渲染展开按钮 | 只能恢复开关,无法保留常驻设置区域;且侧边栏 UI 会被两个包分别持有 |
| 保留零宽轨道、让控制栏溢出显示 | 控制栏与中心列重叠;命中测试与响应式几何关系脱离网格布局 |
| 保持完整侧边栏树挂载、用裁切隐藏 | 隐藏控件仍留在语义树中,并继续订阅和渲染;而折叠态实际只需要少数控件 |
第三条尤其点出修复的本质:控制栏不是"把整个侧边栏变窄",而是一套独立的、更小订阅面的控件集,宽态内容在 settle 时彻底卸载。
后果与验证基线
档案"后果"一节列出的行为契约,在当前源码中均可对应:
- 折叠侧边栏占用 56px,不再把全部宽度让给中心列;展开时恢复偏好宽度与拖拽行为。注意一个演进细节:stores.ts 的注释说明"偏好即宽度"——宽视口下关闭会写入
0、重新打开恢复契约默认值SIDEBAR_DEFAULT(280px),即关闭会遗忘拖拽宽度,这与档案早期"已存储宽度保持不变"的表述相比是后续收紧的契约,阅读旧档案时以源码注释为准。 - 设置入口持续可见,但保留既有占位行为,不引入账户或设置页面。
- 三层验证基线:布局求解器测试固定紧凑宽度(columns.client.spec.ts);侧边栏组件测试固定可见控件(sidebar-styles.client.spec.ts);
AppFrame的 owner props 下发与手柄移除逻辑由 app-frame.client.spec.tsx 覆盖。档案还提到基于真实构建产物的无密钥 Web 冒烟测试固定了折叠与恢复行为。
小结:把"可达性"写进布局契约
这次修复的通用启示可以归纳为三点:
- 任何会持久化的 UI 状态,其"关闭"形态必须保留最小的几何存在,否则恢复路径与该状态互为因果,形成刷新后仍成立的死锁;
- 状态判定放在 owner 侧(
AppFrame的 owner props),几何计算放在纯函数求解器(computeColumns),两者通过"有效偏好"衔接,断点逻辑不污染求解器; - 动画与语义树解耦:滑动期间冻结宽度、零重排,settle 后才卸载宽内容——视觉连续性与可访问性树的精简可以按各自的时间表完成,互不等待。
对 deepseek-harness 这类"Everything is a Plugin" 的客户端而言,控制栏方案还额外满足了包边界约束:侧边栏 UI 的几何归 ui-layout,控件与内容归 ui-sidebar 及插槽注册方,折叠态没有引入任何跨包的状态通道。
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 StartedRust0623
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