Center Tabs 与复合分屏 Tab 设计

本页定义顶部 tab 条目、复合分屏 tab、整体切换、持久化和跨窗口转移。会话内部数据所有权仍由 Web state layer 定义。

BASELINE IMPLEMENTED · TOOLBAR PICKER REVIEWED

01 OpenProgram 的设计结论

分屏是一个复合顶部 tab,不是窗口级固定布局。复合 tab 包含两个内容视图。选择另一个顶部 tab 时,左右两侧一起隐藏;再次选择该复合 tab 时,两个视图和比例一起恢复。

左右两侧没有产品级“当前选中侧”。点击左侧或右侧不会改变顶部样式,不显示 active pane 边框、方向图标或成员高亮,也不持久化 focusedId

顶部只选择条目,但必须看得出两个成员。单 tab 是一个条目,分屏 pair 也是一个条目。复合条目内部显示两个等宽成员槽位,各自保留图标、截断标题和成员关闭按钮;它们不产生独立 aria-selected、active 背景或拖动对象。

Web 不固定在整个窗口。Web 只在包含它的复合条目被选择时显示。切换到另一个会话、文件或页面条目时,Web 必须同时隐藏,不能跟随新的条目重新组合。

创建分屏必须显式选择两个成员。用户从某个 Web toolbar 点击 Split 时,该 Web tab 是固定 source;界面必须先列出同一窗口内可用的其他 single tabs,用户选择 target 后才调用 groupTab()。不得根据 route、active session、draft 或 tab 顺序自动选择另一成员。

新分屏从确定状态开始。合法 target 提交后,新复合条目必须立即成为 active entry,并把当前窗口的 splitRatio 重置为 0.5。此前其他分屏或已拆分条目留下的比例不能影响新组合;用户随后拖动 divider 产生的新比例仍按 version 2 payload 持久化。

New chat 会话标题 bilibili.com 文件 Programs
会话
复合 tab 的左侧成员
网页
复合 tab 的右侧成员

顶部只有整个复合条目处于选中状态。两个 pane 视觉权重相同,没有 pane-level active 样式。

02 原问题与实施结果

原问题:CompoundTabItem 曾把 group.memberIds 分别渲染为多个 TabItem。每个 segment 都有 click target、close button、activearia-selected,顶部因此错误地表达“当前选中了左侧或右侧成员”。

原问题:normalizeCenterTabsPayload() 曾根据活动 session 与 splitWebTabId 自动 regroup。切换到另一个会话后,Web 会被重新挂到新会话旁边,表现为浏览器固定在窗口右侧。

当前视觉缺口:桌面顶部栏使用比内容区更深的 --tabrow-bg,选中条目仍直接使用 --bg-primary。在深色主题中两者亮度接近,在浅色主题中选中面也缺少明确的反向层级,导致选中与未选中条目难以快速区分。

已修正的交互缺口:旧版 Web toolbar 的 SplitButton.toggleSplit() 会调用 setSplitWebTab(),并根据当前 route 激活或创建 session/draft。现在 toolbar 与顶部 tab 右键菜单复用同一个 SplitViewPickergroupTab() 提交路径;选择 target 前不修改布局或会话状态。

已实施:group 通过 centerTabStripEntries() 投影为一个顶部条目;切换普通条目只改变 activeId,不改变原 group 的成员和顺序。复合条目只渲染一个 role="tab" 和一个 group drag target,内部两个等宽成员格分别提供自己的图标、标题和关闭按钮。

已修复回归:divider 的 pointer drag 原本会更新并持久化 splitRatio,但左侧会话 pane 同时匹配 .center-split-primary 与后加载的 .center-pane-chat;后者的 flex: 1 覆盖主 pane 的固定宽度,使两侧在拖动后仍被 flex 成等宽。现在使用组合选择器保持主 pane 的 flex: 0 0 auto,ratio 可以直接控制实际 pane bounding width;顶部复合 tab、group ownership 和拖动语义未改变。

当前错误
选择 B
[A + W] → [B + W]
目标行为
选择 B 条目
[A + W] 隐藏,B 显示

scrollIntoView() 只会放大可见跳动,不是状态错误的来源。只删除滚动或动画不能修复 Web 固定、成员选中和自动 regroup。

03 概念、所有权与不变量

概念含义权威状态
Leaf tabsession、file、web、builtin 或 NTP 内容对象tabs[]
Single entry顶部一个普通 tab,包含一个 leaf tabcenterTabStripEntries() 生成的 {kind:"tab", tabId}
Split entry顶部一个复合 tab,固定包含两个 leaf tabCenterTabGroup.memberIds,上限为 2
Active entry包含 activeId 的普通条目或 group;顶部按整个条目显示选中activeId + groups[] 的派生结果
DOM focus键盘当前所在元素,只服务输入和无障碍浏览器 DOM,不进入产品持久化状态

硬性不变量

  1. 每个 leaf tab 恰好属于一个 entry;不能同时是普通 tab 和某个 split entry 的成员。
  2. split entry 恰好有两个不同成员;本设计不支持第三个 pane。
  3. 顶部顺序由规范化后的 tabs[]groups[] 投影;group 的两个成员在 leaf 顺序中保持连续。
  4. 顶部只有一个条目级选中样式。activeIdfocusedId 仍用于路由和内部 bookkeeping,但不产生成员 segment 或 pane-level active 样式。
  5. 点击 pane 内部可以更新内部会话路由,但不能改变 entry membership、entry order 或顶部复合条目的整体选中样式。
  6. 选择另一个顶部 entry 必须整体替换 center body;旧 split 的两个 pane 同时停止显示。
  7. 打开、关闭、分屏、拆分、显式移动和 transfer 可以改变 entries;普通 activation 不得改变 entries。

04 官方参考与取舍

参考官方行为OpenProgram 采用OpenProgram 拒绝或修改
Chrome Split View 一个 opened tab 内同时显示两个网站;支持关闭单侧、交换和 Separate split view;官方同时定义 active/inactive view、粗细边框和 active 方向图标。 split 与一个顶部 tab 的生命周期关联;支持关闭单侧与显式拆分。 不采用 active/inactive view、pane 选中边框和方向图标。OpenProgram 的 pane 操作从事件来源精确路由,不需要全局 active side。
Chrome Tabs and Tab Groups tab 切换与 tab reorder 分离;顺序和 group 由显式 drag/add/remove 改变。 普通 activation 只改变当前 entry;拖动才改变 entry 顺序。 不引入 group 名称、颜色、同步或收藏。split entry 不是通用 Chrome tab group。
VS Code Editor Groups 每个 editor group 有自己的 active editor、tab strip 和 focus。 只采用 pane 内容与布局可独立渲染的实现经验。 不采用 active group、每个 pane 独立 tab strip、任意网格或 pane-local tab history。
VS Code Custom Layout 支持 Split in Group,并提供 first、second 和 other side 的显式 focus 命令。 采用同一容器内渲染两个内容视图和可调整 divider。 不采用 side focus 作为产品状态;OpenProgram 不提供 first/second side 选中命令。

Chrome 官方设计中的 pane active 状态不是本设计的要求。OpenProgram 的修改点是:复合条目整体选中,pane 只保留各自局部控件和自然 DOM focus。

05 已实施状态模型

本次采用兼容性最小改动,持久化格式保持 version 2。groups[] 是复合条目的所有权;centerTabStripEntries() 在显示层把 leaf tabs 和 groups 投影为一个有序顶部列表。这样不需要迁移已有窗口数据,也不改变 transfer journal 的 payload 边界。

interface CenterTabsPersistedPayload {
  version: 2;
  tabs: CenterTab[];
  activeId: string | null;       // 内部路由;顶部按所属 entry 整体选中
  groups: CenterTabGroup[];      // 每组恰好两个成员
  splitWebTabId: string | null;  // 兼容旧入口,不再触发跨 group reparent
  splitRatio: number;            // 当前兼容格式仍为窗口级比例
}

不保存的状态

复合标签结构

复合条目使用一个外层容器和两个 1fr 等宽成员槽位。每个槽位显示自己的 favicon/类型图标、单独截断的标题和成员关闭按钮;标题长度不得改变两侧宽度。外层只有一个背景、一个 active 状态、一个 role="tab" 和一个 group drag target。不再显示额外的前置分屏图标或拖动手柄。

06 状态转移

当前状态动作目标状态顶部结果
active split(A,W)点击普通 entry Bactive=B;A 与 W 一起隐藏split(A,W) 原位保留,不 regroup
active=B点击 split(A,W)A 与 W 一起恢复,ratio 恢复整个 split entry 选中
active split(A,W)点击 A 或 W pane只发生局部输入/DOM focus顶部完全不变
single A + single W在 W 点击 Split,并在 picker 选择 A用 active split(W,A) 替换 W,移除 A entry;ratio=0.5source W 保持固定;确认前不改变状态,确认后立即显示所选 pair
split(A,W)Separate split生成相邻 single A、single W,并保持 A activeA 在原 split 位置,W 紧随其后
split(A,W)拖动任一非按钮区域A/W 按原内部顺序共同移动只能 reorder/transfer 完整复合条目,不能拖出单个成员
split(A,W)点击 W 成员关闭按钮原位折叠为 single A不移动其他 entry
split(A,W)Cmd+W / entry close关闭 A 与 W按 entry 邻居选择下一个 active entry
split(A,W)右键 Separate / pane 内退出分屏生成相邻 single A、single W拆分必须来自显式命令,不是拖动结果

Web toolbar 与 Runtime

07 顶部显示、pane 显示与无障碍

顶部 tab strip

两个 pane

08 操作路由与会话语义

取消 pane-level active 状态不等于取消精确目标。所有操作必须从事件来源或显式 tab id 路由,不能读取“最后聚焦 pane”作为业务所有权。

操作目标解析禁止行为
Web Back/Forward/Home/Reload/address使用该 toolbar 所属 WebTabPane.tabId读取全局 active web 或 focusedId
Composer send使用该 session pane 的 sessionId把另一 pane 的 session 当作当前会话
Computer Use / native target使用调用中的 windowId、tabId 和精确 targetId从当前可见 Web 中任意选一个
Cmd+W / entry close关闭包含 activeId 的整个 entry根据最后 DOM focus 猜测关闭某一 pane
成员关闭按钮使用按钮绑定的 leaf tab id关闭整个 split 或另一成员
Pane-local close按钮所属 leaf tab关闭整个 split 或另一成员

共享 session UI

09 持久化、恢复与兼容

Version 2 normalization

兼容边界

  1. 不修改 storage key、payload version、tab id、group id 或 transfer journal 版本。
  2. focusedId 保留给会话路由、URL 和内部控件所有权;顶部不根据它渲染成员 selected 状态。
  3. visibleIds 在两成员上限下等于可显示成员集合,不再用于隐藏第三成员。
  4. 跨窗口 group payload 超过两个成员时返回 group-full,不部分插入。

10 关闭、移动、跨窗口转移与失败

动作结果原子性要求
关闭复合顶部 tab关闭两个成员;dirty 成员沿用现有保护任一成员拒绝关闭时,不提交半关闭 entry
关闭单侧 pane复合 entry 原位降为另一成员的 single entrytabs 与 groups 通过同一 store commit 规范化
拖动复合 tab整个 entry 改变顶部位置成员 left/right 顺序不变
转移复合 tab 到新窗口两个成员与 group 元数据一起转移journal prepare/commit/rollback 覆盖整个 entry
转移单侧先显式 Separate split,再转移生成的 single entry不提供隐式 segment drag
目标窗口拒绝 transfer源窗口完整恢复 split entry不能只恢复一个成员

失败与并发

11 实施边界与可执行验收

设计权威:本页定义 center tab 与 split entry 行为;state-layer.md 定义 session 数据所有权;web-tab-native-bounds.html 定义 native Web bounds。

生产范围:center-tab-groups.tscenter-tabs-persistence.tscenter-tabs-store.ts、center-tabs 组件与 hooks、desktop-bridge.ts。既有 AppShell pane 解析、transfer journal 与 pending projection 继续复用。

测试范围:check:center-tabscheck:compound-tabscheck:web-split、desktop webtab/transfer checks、TypeScript 和 production build。

顶部复合 tab 视觉约束

生产范围:web/components/center-tabs/tab-items.tsxcenter-tabs.module.css;回归入口为 web/scripts/check-center-tabs.mjs 与相关 check-chat-ui.mjs 断言。

检查契约:npm --prefix web run check:center-tabs 必须证明一个 role=tab、两个 1fr 成员槽位、两个成员关闭按钮和唯一 group drag subject,并拒绝前置 Columns2 图标。

不改:version 2 payload、group normalization、active entry 推导、pane 渲染、Runtime Web ownership、transfer journal、pointer drag 状态机和跨窗口协议。拖动继续只做整个 entry 的 reorder/transfer;不新增依赖、动画系统或成员拖动。

证据:定向 RED/GREEN、TypeScript、Web 全量检查、production build、desktop webtab/transfer、Python desktop checks、文档构建/断链、diff 检查、两个独立 review,以及默认 18100 安装版的实际相等宽度和切换行为。

分屏宽度回归修复任务说明

生产范围:web/app/styles/base.css;测试范围限定为现有公开入口 web/scripts/check-web-split.mjs。不修改 pointer handler、ratio store、持久化 schema、pane 顺序或最小宽度常量。

public-entry RED:npm --prefix web run check:web-split 必须先证明第一 pane 同时为 center-split-primary center-pane-chat 时,主 pane 的固定 flex 规则具有足够 specificity,不会被通用 chat pane flex 覆盖。GREEN 后默认安装版必须证明真实 divider pointer drag 同时改变 store ratio 与两个 pane 的 bounding width。

证据:定向 RED/GREEN、Web 全量检查、TypeScript、production build、desktop webtab/transfer checks、Python desktop checks、文档构建/断链、diff 检查、两个独立 review,以及默认 18100 安装版的拖动前后 ratio、pane width 和刷新恢复数据。

Web toolbar 显式成员选择任务说明

生产范围:web/components/center-tabs/web-tab-pane.tsx;复用 split-view-picker.tsxlabelOf() 和现有 groupTab(),不新增 store 字段、持久化格式、弹窗组件或依赖。

public-entry RED 证据:npm --prefix web run check:web-split 曾因 toolbar 未渲染 SplitViewPicker subjectId={tabId} 而失败;旧实现同时包含 setSplitWebTab(tabId)openDraftSessionTab()newSession() 的自动配对路径。实现后,同一检查要求 picker 存在并拒绝上述自动路径。

目标行为:single Web 的 Split 点击只打开 picker;picker 列出所有合法其他 single tabs,选中后才合并。提交成功时新 pair 立即成为 active entry,两个成员立即显示且 divider 为 50/50;不能继续显示原先 active 的其他 entry,也不能继承旧 ratio。已在 split 中的 Web 点击同一按钮执行现有 Separate 行为。取消、空候选和提交拒绝都不自动选择或创建 tab。

提交与焦点约束:选择候选时必须用最新 tabs/groups 重新执行 splitCandidates() 校验;候选已被其他操作加入分屏时拒绝提交,不得把它从现有 owner 移出。空候选时焦点进入 picker 的 Close;Escape、Close 与成功选择恢复 toolbar trigger,outside pointer dismissal 保留用户新点击元素的焦点。

明确排除:不修改候选排序、不增加搜索/MRU、不允许第三 pane、不改变右键菜单、drag、transfer、ratio 或 Computer Use target 绑定。

当前实现约束

  1. version 2 normalization 保持已被 group 拥有的 Web owner,不随 activeId 改变。
  2. group 最多包含两个成员;恢复旧三成员状态时,第三个成员保留为普通条目。
  3. CompoundTabItem 只有一个 tab target 和一个 drag target;菜单、键盘、拖动和 Cmd+W 以完整条目为单位。
  4. Runtime Web open、非活动 owner composite 激活、dirty 关闭、roving tabindex、drag root 与 post-drag synthetic click 均由现有检查覆盖。
  5. 顶部条目显示两个等宽成员格、成员图标、标题和关闭入口,不显示前置 Columns2 图标;成员展示层不截获 pointer focus。
  6. 成员最小宽度为 136px;关闭中的按钮使用 aria-disabled 并继续截获 pointer,避免下一次按压触发 group drag。
  7. 默认 18100 安装版中,两个成员宽度为 131.67px / 131.67px,包含一个 role=tab、一个 selected target 和两个关闭按钮;从任一成员正文拖动均保持成员 ID 与 group 不变。
  8. 组合选择器保证 ratio 变化会反映到两侧 pane 宽度;定向检查、Web 全量检查、TypeScript、production build、desktop checks、Python checks 与文档检查覆盖该约束。
  9. 默认 18100 安装版中,会话位于第一 pane 时从 557.27px / 480.01px 拖到 460.26px / 577.01px,反向拖动为 533.25px / 504.02px;交换成员顺序后两侧同样随 ratio 改变。重新选择复合条目和刷新页面后,ratio 与 bounding width 均保持一致,并已恢复原成员顺序。

验收标准

  1. 整体选中:顶部 split(A,W) 只有一个 active 背景和一个 aria-selected=true,内部没有两个 tab target;同时必须清楚显示两个成员身份。
  2. 顶部几何:Electron 最顶部全局 tab strip 为 40px;普通与复合 tab 都为 28px,上下间距各为 6px。关闭按钮保持 20px 点击区域,新增与主菜单按钮保持 28px
  3. 主题对比:桌面深色主题的选中条目明显亮于未选中栏面;桌面 light 与 beige-light 的选中条目明显深于未选中栏面。普通 tab 与 split tab 使用同一个 active token,hover、focus、drag 和非选中背景不改变。
  4. 等宽成员:两个成员槽位的 bounding width 相等;任一标题变长后仍相等,并且只在自己的槽位内省略。
  5. 无 pane 选中:依次点击左右 pane,顶部 DOM、class、entry 状态和 pane 边框不变化;只允许自然 DOM focus 变化。
  6. 整体切换:从 split(A,W) 选择 B 后,A 与 W 同时不可见,B 单独显示;Electron 中 W 的 native view 同时隐藏。
  7. 整体恢复:重新选择 split(A,W) 后,两侧内容、滚动/会话状态和 ratio 恢复。
  8. 宽度调整:从 divider 向左或向右拖动后,两侧 bounding width 按受约束的 splitRatio 改变;刷新或重新选择该 split entry 后恢复相同比例。会话位于第一 pane 与第二 pane 两种顺序都成立。
  9. 不固定 Web:连续选择 session、file、builtin、NTP 和另一个 web entry,W 都不跟随、不 regroup、不改变 entry order。
  10. 局部路由:左右 toolbar、composer、close 与 Computer Use 都作用于事件来源的精确 tab/session/target。
  11. 创建与拆分:两个 single entries 合成一个 split entry;Separate 后恢复为相邻 singles,顺序确定。
  12. 显式成员选择:从 Web toolbar 点击 Split 后,当前 Web 固定为 source,必须选择另一个可用 tab 才创建分屏;取消前后 tabs、groups、activeId 和 session 数量完全不变。
  13. 新组合初始状态:在 ratio=0.35、0.5、0.65 三种旧状态下分别提交新 pair,成功后都必须得到 splitRatio=0.5activeId 等于用户刚选择的成员,center body 立即只显示这两个成员;若该成员是 session,pathname、runtimeState.currentSessionId、composer 和 Page binding 必须全部指向它。同一次 store mutation 只写一个持久 payload。
  14. 并发与焦点:picker 打开后 target 若被其他操作分组,旧选择提交必须拒绝且原 group 不变;空候选时 Close 获得焦点;outside click 关闭后焦点停留在实际点击目标。
  15. 关闭:两个成员关闭按钮分别关闭对应 leaf 并原位折叠为 single;Cmd+W/entry close 关闭整个 split;dirty 拒绝不产生半状态。
  16. 移动与 transfer:从任一成员的非按钮区域拖动都只产生 group drag,复合 tab 作为一个单位 reorder 和跨窗口转移;不存在成员 drag 或拖动拆分,rollback 同时恢复两个成员。
  17. 显式拆分:Separate split 只由右键菜单或 pane 内现有退出分屏按钮触发;拖动不能调用 ungroupTab
  18. 恢复:version 2 payload 不新增错误 pair;三成员 group 规范化为一个双成员 split 加一个 single。
  19. 窄窗口:两个 pane 仍属于同一 active entry,保持既有左右布局与最小宽度约束,不根据 focus 隐藏或重排一侧。
  20. 回归:single tab、Home/NTP 原位替换、bookmark、file draft、session composer、browser iframe 和 native WebContentsView 不退化。

明确排除

Full gate manifest

npm --prefix web run check:center-tabs
npm --prefix web run check:compound-tabs
npm --prefix web run check:web-split
npm --prefix web run check
web/node_modules/.bin/tsc -p web/tsconfig.json --noEmit
npm --prefix web run build
npm --prefix desktop run check:webtabs
npm --prefix desktop run check:tab-transfer-store
python -m pytest -q tests/component/webui/test_desktop_checks.py
python -m tools.docs_site.checklinks
git diff --check

12 实现与验证状态

顶部复合 tab、分屏宽度调整与 Web toolbar 显式成员选择均已实现并通过独立规格与质量复核。

toolbar picker 的 focused checks、TypeScript、production build、Desktop WebTab/transfer checks 与文档链接检查通过。默认安装版验证了:选择前不创建 group;选择指定 target 后只生成该精确 pair;Escape 不改持久化状态;非空候选首项获得焦点;空候选 Close 获得焦点;outside dismissal 保留实际点击目标焦点。验收结束后恢复原 tabs、groups、activeId、URL 与 split ratio。

主题化 active 对比度:设计、实现、独立 specification/quality review 与默认安装版双主题验收均已完成。深色模式下,选中复合 tab 背景亮度为 OKLCH 0.373,tab 栏为 0.210;浅色模式下分别为 0.8200.906。未选中 tab 保持透明,并显示 tab 栏背景。

桌面顶部几何:最顶部全局 tab strip 固定为 40px,普通与复合 tab 固定为 28px 并垂直居中,上下各保留 6px;关闭按钮为 20px,新增与主菜单按钮为 28px。默认 18100 安装版实测 strip 为 39.995px、普通 tab 为 27.999px、tab 顶部位置为 5.998px;独立 specification 与 quality review 均为 PASS。

顶部 split entry 有两个等宽成员格、两套图标、标题和关闭入口,以及一个外层 active、tab 和 drag target;不存在成员拖动拆分或成员级 selected 状态。

默认安装版保留显式 Separate 入口;拖动左右任一成员正文只移动整个 group。切换普通 entry 后原 group 不改 owner、不 regroup,重新选择后恢复原两侧内容和 ratio。divider 可在两个方向调整 pane 宽度,会话位于任一侧时均遵守同一 ratio 和最小宽度约束。