首页
/ deepseek-harness Web 布局:折叠侧边栏的 56px 控制栏——从"无法恢复的 UI 死锁"到可验证的布局契约

deepseek-harness Web 布局:折叠侧边栏的 56px 控制栏——从"无法恢复的 UI 死锁"到可验证的布局契约

2026-09-04 17:53:38作者:柯茵沙

本文基于 deepseek-harness 仓库的修复档案 2026-07-22-collapsed-sidebar-control-rail 展开:它描述了一个典型的布局死锁问题——侧边栏关闭后持久化宽度偏好 0,求解器把该偏好映射为零宽网格轨道,开关与设置入口全部被裁切,且刷新页面后依旧无法恢复。读完本篇,你会掌握 deepseek-harness Web 客户端三栏布局的"让步链"求解算法、折叠态判定如何以 owner props 形式下发到侧边栏组件、以及"滑动 + 交叉淡变"的折叠动画如何在滑动过程中做到零重排。

问题:零宽轨道如何制造"不可恢复"的 UI

原档案(修复说明)把故障链描述得很精炼:

  1. 用户点击"关闭侧边栏",布局 store 持久化宽度偏好 0(在 deepseek-harness 的布局模型中,0 就是"关闭");
  2. 三栏布局把 0 直接映射为一条宽度为零的 CSS Grid 轨道;
  3. 而侧边栏唯一的开关和设置入口恰好都长在这条被裁切的轨道里——关闭侧边栏的同时移除了所有恢复控件;
  4. 页面重新加载时 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)

这里有两层判定,分别对应两种折叠来源:

  1. 手动关闭(宽视口):panels.sidebar === 0,即持久化偏好为关闭;
  2. 窄视口自动折叠:视口小于 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 冒烟测试固定了折叠与恢复行为。

小结:把"可达性"写进布局契约

这次修复的通用启示可以归纳为三点:

  1. 任何会持久化的 UI 状态,其"关闭"形态必须保留最小的几何存在,否则恢复路径与该状态互为因果,形成刷新后仍成立的死锁;
  2. 状态判定放在 owner 侧(AppFrame 的 owner props),几何计算放在纯函数求解器(computeColumns,两者通过"有效偏好"衔接,断点逻辑不污染求解器;
  3. 动画与语义树解耦:滑动期间冻结宽度、零重排,settle 后才卸载宽内容——视觉连续性与可访问性树的精简可以按各自的时间表完成,互不等待。

对 deepseek-harness 这类"Everything is a Plugin" 的客户端而言,控制栏方案还额外满足了包边界约束:侧边栏 UI 的几何归 ui-layout,控件与内容归 ui-sidebar 及插槽注册方,折叠态没有引入任何跨包的状态通道。

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