Center Tabs 与复合分屏 Tab 设计
本页定义顶部 tab 条目、复合分屏 tab、整体切换、持久化和跨窗口转移。会话内部数据所有权仍由 Web state layer 定义。
BASELINE IMPLEMENTED · TOOLBAR PICKER REVIEWED01 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 持久化。
复合 tab 的左侧成员
复合 tab 的右侧成员
顶部只有整个复合条目处于选中状态。两个 pane 视觉权重相同,没有 pane-level active 样式。
02 原问题与实施结果
原问题:CompoundTabItem 曾把 group.memberIds 分别渲染为多个 TabItem。每个 segment 都有 click target、close button、active 和 aria-selected,顶部因此错误地表达“当前选中了左侧或右侧成员”。
原问题:normalizeCenterTabsPayload() 曾根据活动 session 与 splitWebTabId 自动 regroup。切换到另一个会话后,Web 会被重新挂到新会话旁边,表现为浏览器固定在窗口右侧。
当前视觉缺口:桌面顶部栏使用比内容区更深的 --tabrow-bg,选中条目仍直接使用 --bg-primary。在深色主题中两者亮度接近,在浅色主题中选中面也缺少明确的反向层级,导致选中与未选中条目难以快速区分。
已修正的交互缺口:旧版 Web toolbar 的 SplitButton.toggleSplit() 会调用 setSplitWebTab(),并根据当前 route 激活或创建 session/draft。现在 toolbar 与顶部 tab 右键菜单复用同一个 SplitViewPicker 和 groupTab() 提交路径;选择 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 tab | session、file、web、builtin 或 NTP 内容对象 | tabs[] |
| Single entry | 顶部一个普通 tab,包含一个 leaf tab | centerTabStripEntries() 生成的 {kind:"tab", tabId} |
| Split entry | 顶部一个复合 tab,固定包含两个 leaf tab | CenterTabGroup.memberIds,上限为 2 |
| Active entry | 包含 activeId 的普通条目或 group;顶部按整个条目显示选中 | activeId + groups[] 的派生结果 |
| DOM focus | 键盘当前所在元素,只服务输入和无障碍 | 浏览器 DOM,不进入产品持久化状态 |
硬性不变量
- 每个 leaf tab 恰好属于一个 entry;不能同时是普通 tab 和某个 split entry 的成员。
- split entry 恰好有两个不同成员;本设计不支持第三个 pane。
- 顶部顺序由规范化后的
tabs[]与groups[]投影;group 的两个成员在 leaf 顺序中保持连续。 - 顶部只有一个条目级选中样式。
activeId与focusedId仍用于路由和内部 bookkeeping,但不产生成员 segment 或 pane-level active 样式。 - 点击 pane 内部可以更新内部会话路由,但不能改变 entry membership、entry order 或顶部复合条目的整体选中样式。
- 选择另一个顶部 entry 必须整体替换 center body;旧 split 的两个 pane 同时停止显示。
- 打开、关闭、分屏、拆分、显式移动和 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; // 当前兼容格式仍为窗口级比例
}
不保存的状态
- 不增加 pane-level selected 状态。DOM focus 与内部
focusedId不映射为 pane 边框或顶部成员高亮。 - 不增加新的窗口级 Web pin。Web 是否可见由包含它的 active group 决定;
splitWebTabId只保留旧入口兼容。 - 不增加成员级顶部 selected 状态。两个 leaf tab 各自提供图标、标题、dirty 和成员关闭入口,但 active 状态仍只属于外层复合条目。
- 本次不升级 ratio schema;version 2 的
splitRatio仍按原兼容行为保存。
复合标签结构
复合条目使用一个外层容器和两个 1fr 等宽成员槽位。每个槽位显示自己的 favicon/类型图标、单独截断的标题和成员关闭按钮;标题长度不得改变两侧宽度。外层只有一个背景、一个 active 状态、一个 role="tab" 和一个 group drag target。不再显示额外的前置分屏图标或拖动手柄。
06 状态转移
| 当前状态 | 动作 | 目标状态 | 顶部结果 |
|---|---|---|---|
| active split(A,W) | 点击普通 entry B | active=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.5 | source W 保持固定;确认前不改变状态,确认后立即显示所选 pair |
| split(A,W) | Separate split | 生成相邻 single A、single W,并保持 A active | A 在原 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
- Web toolbar 从 single(W) 创建 split 时始终显示现有
SplitViewPicker。W 固定为 source,候选项来自splitCandidates(tabs, groups, W);只有用户选择 target 后才通过groupTab(target, W, 1)创建复合条目。 groupTab()接受一个此前未组成 pair 的 source/target 后,在同一次持久状态提交中写入新 group、把activeId设为用户刚选择的成员,并写入splitRatio=0.5。选择 session 时,这个 activeId 变化还会触发现有 session route 同步,保证 composer 与所选 pane 使用同一 session;不能只激活 Web 成员而保留旧会话路由,也不能继承旧 ratio。- 关闭 picker、按 Escape、点外部或没有候选项都不修改 tabs、groups、activeId、route 或 session store;不得为创建分屏自动创建 draft/session。
openWebTabInSplit(url)在当前复合条目已有 Web 时复用该 Web 成员并导航,不创建不可见的第三成员。- 相同 deterministic URL 已属于另一个非活动复合条目时,激活该 owner composite;不能把 Web 移到当前会话,也不能在不可见状态下返回成功。
- session 已属于完整 split 时,不替换现有成员、不创建第三 pane。普通顶部 activation 不能重新组合成员。
07 顶部显示、pane 显示与无障碍
顶部 tab strip
- 每个 entry 对应一个
role="tab"。split entry 仍只有一个 tab target;两个成员槽位是视觉内容,不是两个可独立选择的 tab。 - 只有包含
activeId的 entry 使用 active 背景与aria-selected="true"。 - active 背景由共享
--tab-active-bg控制,普通条目与复合条目必须一致。浏览器模式保持--bg-primary,继续与内容区及 fillet 连续;桌面模式根据元素实际color-scheme使用light-dark(black, white):浅色向黑色、深色向白色混合 18%。因此内置主题和声明了正确color-scheme的 custom 主题使用同一规则。未选中条目继续透明显示--tabrow-bg,不增加边框或 active 动画。 - 桌面最顶部全局 tab strip 固定为
40px;普通 tab 与复合分屏 tab 都固定为28px并垂直居中,使上下各保留6px。tab 使用固定8px圆角,不使用半高圆角或悬浮阴影形成胶囊。成员关闭按钮继续使用20px点击区域;独立的新增与主菜单按钮保持28px。 - 条目生命周期保留可见的几何动画:普通 Tab 新建时以
180ms ease-out展开,关闭时以160ms ease-in收缩;关闭 split 的单个成员时,该成员淡出,外层复合条目同步在160ms内收缩为 single 宽度。active 切换仍然即时,split 仍只有整个条目可拖动。 - 两个成员槽位固定等宽,各自显示图标和省略标题;长标题只在本侧截断,不能压缩另一侧。两侧使用相同背景和文字层级,不显示当前侧高亮。
- 可访问名称为“Split tab: {left label} and {right label}”。两个成员关闭按钮分别标记“Close {member label}”;关闭后复合条目原位降为剩余 single tab。
- 拖动、键盘移动、中键关闭和跨窗口 transfer 都作用于整个 entry。成员槽位不注册 drag subject;移动单个成员前必须通过右键菜单或 pane 内按钮执行 Separate split。
两个 pane
- 两个 pane 使用相同背景、边框宽度和视觉层级。点击后不得新增强调边框或改变 divider 方向图标。
- divider 只表示可调整尺寸,不表示哪侧 active;键盘 focus outline 只满足操作可见性,不等于 pane 被产品选中。
- 拖动 divider 或使用其左右方向键后,第一 pane 的实际宽度必须随
splitRatio改变;会话 pane 作为第一 pane 时不得被通用.center-pane-chatflex 规则重新均分。比例继续限制在既有最小宽度范围内并通过 version 2 payload 持久化。 - 本次不新增基于宽度或 focus 隐藏、重排任一 pane 的逻辑。两个 pane 继续使用既有左右布局、最小宽度和 divider 约束。
- split entry 变为 inactive 时,两个 pane 都从可见布局中移除。Electron native WebContentsView 同步隐藏并注销 visible bounds。
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
- split 中只有一个 session 时,窗口级 session 摘要可绑定该唯一 session。
- split 中有两个 session 时,pane 交互可以更新内部 session 路由和控件归属,但顶部仍只显示整个复合条目选中,不显示当前侧。
- 两个 session 各自的消息、composer、任务状态和局部控件由各自 pane 持有。
09 持久化、恢复与兼容
Version 2 normalization
- 按首次出现去重 tabs 和 group 成员;每个 leaf tab 最多属于一个 group。
- group 只保留前两个合法成员;少于两个成员时 group 解散,剩余 leaf tab 继续作为普通顶部条目。
- 旧三成员 group 恢复为一个双成员复合条目和一个普通条目,不丢失第三个 leaf tab。
- 当
splitWebTabId已属于某个合法 group 时,normalization 保留该所有权;切换activeId不得重新组合。 - 只有 Web 尚未属于 group 且兼容入口明确请求 split 时,才允许与当前 session 建组。
activeId无效时选择第一个 leaf tab;顶部再根据它所属的 group 派生整个 active entry。
兼容边界
- 不修改 storage key、payload version、tab id、group id 或 transfer journal 版本。
focusedId保留给会话路由、URL 和内部控件所有权;顶部不根据它渲染成员 selected 状态。visibleIds在两成员上限下等于可显示成员集合,不再用于隐藏第三成员。- 跨窗口 group payload 超过两个成员时返回
group-full,不部分插入。
10 关闭、移动、跨窗口转移与失败
| 动作 | 结果 | 原子性要求 |
|---|---|---|
| 关闭复合顶部 tab | 关闭两个成员;dirty 成员沿用现有保护 | 任一成员拒绝关闭时,不提交半关闭 entry |
| 关闭单侧 pane | 复合 entry 原位降为另一成员的 single entry | tabs 与 groups 通过同一 store commit 规范化 |
| 拖动复合 tab | 整个 entry 改变顶部位置 | 成员 left/right 顺序不变 |
| 转移复合 tab 到新窗口 | 两个成员与 group 元数据一起转移 | journal prepare/commit/rollback 覆盖整个 entry |
| 转移单侧 | 先显式 Separate split,再转移生成的 single entry | 不提供隐式 segment drag |
| 目标窗口拒绝 transfer | 源窗口完整恢复 split entry | 不能只恢复一个成员 |
失败与并发
- native WebContentsView 未 ready、CDP target 丢失或 bounds 暂时不可用时,split entry 不自动拆分或替换成员;对应 pane 显示现有加载/错误状态。
- inactive split entry 的后台 title、favicon、dirty 或任务状态更新只更新 leaf tab 数据,不激活 entry。
- 连续顶部 activation 同步更新
activeId;不增加队列或锁,且不能修改 group membership。 - transfer 继续使用现有 journal;group 与 tabs 在同一 payload transaction 中提交。
11 实施边界与可执行验收
设计权威:本页定义 center tab 与 split entry 行为;state-layer.md 定义 session 数据所有权;web-tab-native-bounds.html 定义 native Web bounds。
生产范围:center-tab-groups.ts、center-tabs-persistence.ts、center-tabs-store.ts、center-tabs 组件与 hooks、desktop-bridge.ts。既有 AppShell pane 解析、transfer journal 与 pending projection 继续复用。
测试范围:check:center-tabs、check:compound-tabs、check:web-split、desktop webtab/transfer checks、TypeScript 和 production build。
顶部复合 tab 视觉约束
生产范围:web/components/center-tabs/tab-items.tsx 和 center-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.tsx、labelOf() 和现有 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 绑定。
当前实现约束
- version 2 normalization 保持已被 group 拥有的 Web owner,不随
activeId改变。 - group 最多包含两个成员;恢复旧三成员状态时,第三个成员保留为普通条目。
CompoundTabItem只有一个 tab target 和一个 drag target;菜单、键盘、拖动和 Cmd+W 以完整条目为单位。- Runtime Web open、非活动 owner composite 激活、dirty 关闭、roving tabindex、drag root 与 post-drag synthetic click 均由现有检查覆盖。
- 顶部条目显示两个等宽成员格、成员图标、标题和关闭入口,不显示前置
Columns2图标;成员展示层不截获 pointer focus。 - 成员最小宽度为 136px;关闭中的按钮使用
aria-disabled并继续截获 pointer,避免下一次按压触发 group drag。 - 默认 18100 安装版中,两个成员宽度为
131.67px / 131.67px,包含一个role=tab、一个 selected target 和两个关闭按钮;从任一成员正文拖动均保持成员 ID 与 group 不变。 - 组合选择器保证 ratio 变化会反映到两侧 pane 宽度;定向检查、Web 全量检查、TypeScript、production build、desktop checks、Python checks 与文档检查覆盖该约束。
- 默认 18100 安装版中,会话位于第一 pane 时从
557.27px / 480.01px拖到460.26px / 577.01px,反向拖动为533.25px / 504.02px;交换成员顺序后两侧同样随 ratio 改变。重新选择复合条目和刷新页面后,ratio 与 bounding width 均保持一致,并已恢复原成员顺序。
验收标准
- 整体选中:顶部 split(A,W) 只有一个 active 背景和一个
aria-selected=true,内部没有两个 tab target;同时必须清楚显示两个成员身份。 - 顶部几何:Electron 最顶部全局 tab strip 为
40px;普通与复合 tab 都为28px,上下间距各为6px。关闭按钮保持20px点击区域,新增与主菜单按钮保持28px。 - 主题对比:桌面深色主题的选中条目明显亮于未选中栏面;桌面 light 与 beige-light 的选中条目明显深于未选中栏面。普通 tab 与 split tab 使用同一个 active token,hover、focus、drag 和非选中背景不改变。
- 等宽成员:两个成员槽位的 bounding width 相等;任一标题变长后仍相等,并且只在自己的槽位内省略。
- 无 pane 选中:依次点击左右 pane,顶部 DOM、class、entry 状态和 pane 边框不变化;只允许自然 DOM focus 变化。
- 整体切换:从 split(A,W) 选择 B 后,A 与 W 同时不可见,B 单独显示;Electron 中 W 的 native view 同时隐藏。
- 整体恢复:重新选择 split(A,W) 后,两侧内容、滚动/会话状态和 ratio 恢复。
- 宽度调整:从 divider 向左或向右拖动后,两侧 bounding width 按受约束的
splitRatio改变;刷新或重新选择该 split entry 后恢复相同比例。会话位于第一 pane 与第二 pane 两种顺序都成立。 - 不固定 Web:连续选择 session、file、builtin、NTP 和另一个 web entry,W 都不跟随、不 regroup、不改变 entry order。
- 局部路由:左右 toolbar、composer、close 与 Computer Use 都作用于事件来源的精确 tab/session/target。
- 创建与拆分:两个 single entries 合成一个 split entry;Separate 后恢复为相邻 singles,顺序确定。
- 显式成员选择:从 Web toolbar 点击 Split 后,当前 Web 固定为 source,必须选择另一个可用 tab 才创建分屏;取消前后 tabs、groups、activeId 和 session 数量完全不变。
- 新组合初始状态:在 ratio=0.35、0.5、0.65 三种旧状态下分别提交新 pair,成功后都必须得到
splitRatio=0.5,activeId等于用户刚选择的成员,center body 立即只显示这两个成员;若该成员是 session,pathname、runtimeState.currentSessionId、composer 和 Page binding 必须全部指向它。同一次 store mutation 只写一个持久 payload。 - 并发与焦点:picker 打开后 target 若被其他操作分组,旧选择提交必须拒绝且原 group 不变;空候选时 Close 获得焦点;outside click 关闭后焦点停留在实际点击目标。
- 关闭:两个成员关闭按钮分别关闭对应 leaf 并原位折叠为 single;Cmd+W/entry close 关闭整个 split;dirty 拒绝不产生半状态。
- 移动与 transfer:从任一成员的非按钮区域拖动都只产生 group drag,复合 tab 作为一个单位 reorder 和跨窗口转移;不存在成员 drag 或拖动拆分,rollback 同时恢复两个成员。
- 显式拆分:Separate split 只由右键菜单或 pane 内现有退出分屏按钮触发;拖动不能调用
ungroupTab。 - 恢复:version 2 payload 不新增错误 pair;三成员 group 规范化为一个双成员 split 加一个 single。
- 窄窗口:两个 pane 仍属于同一 active entry,保持既有左右布局与最小宽度约束,不根据 focus 隐藏或重排一侧。
- 回归:single tab、Home/NTP 原位替换、bookmark、file draft、session composer、browser iframe 和 native WebContentsView 不退化。
明确排除
- 窗口级 Web pin、pane active/inactive 状态、active pane 视觉、pane-local tab strip。
- 三个以上 pane、三成员复合 tab、任意二维网格。
- tab group 名称、颜色、云同步、收藏或自动分类。
- 新的 MRU、动画系统、拖动算法重写或新增依赖。
- 外部浏览器 profile、隐藏浏览器、sidecar 或 headless fallback。
- Windows 专用实现、测试、打包、兼容,以及 OS Keychain/keyring/Credential Manager。
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.820 与 0.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 和最小宽度约束。