工程记录 · 与当前代码同步 · 已落地

网页标签的呈现位置 center tab · 复合分屏 · 会话悬浮窗 (PiP)

读者:维护 apps/web 中心区的开发者 范围:渲染进程内的呈现与绑定 不含:跨窗口 transfer、主进程 View 几何

本文回答:一个网页叶子在界面里此刻出现在哪里、被谁控制、agent 能对它做什么。散落的交互规则都从同一条不变量推出,而不是一串补丁。

1 · 概念模型

会话 S 与网页 W 的配对是唯一一等关系。分屏与悬浮窗是同一配对的两种呈现。一个 WebTab 叶子同一时刻只有一个原生 WebContentsView 矩形;聊天 PiP 直接呈现该 Page 的可交互原生视图。同一叶子作为普通顶部标签活动时,隐藏聊天预览及其预留空间,原生页面占满内容区;网页标签与其 PiP 不得同时显示,也不把页面换成遮罩或截图。

配对来自 agent 在 S 打开 W、用户把 W 拖进 S 的分屏/组、收起时建立,或 agent 操作一张尚未配对的页。show() 与 registerPipPair 写入配对表;覆盖函数只决定这份配对此刻画在哪个宿主。独立网页在被 agent 操作之前没有配对,也就没有「收起到悬浮窗」。

叶子 identity center-tabs store 里 kind:"web" 的条目。规范 id 是 w:<url>;fork 用 nextPopupWebTabId 写成 w:<url>:popup:…。叶子本身不因浮动而消失。 tabs[] / groups[] / splitWebTabId / activeId
配对 pairing 会话↔网页的持久关系,记在 pairedOwnerByTabId。一个会话可配对多张网页;任一侧关闭才清掉。悬浮窗槽位每会话只有一个,显示 agent 当前工作页,其余配对页驻留标签条。 模块级 Map,随 show() / registerPipPair 登记
绑定 binding 全局至多一份活跃 (tabId, ownerTabId):此刻哪张配对页占据悬浮槽。切走会话不改绑定;hide() 才把绑定挪进 background 记忆。换绑另一张页不会抹掉旧页的配对。 useWebTabPip 单例,不持久化
宿主 host 真正画出页面的地方。由 pipCoversCenter(tabId, ownerTabId, center) 从「绑定 × 当前激活标签」纯函数推导。组件不另存「显隐」状态。 pane · chat PiP · none

因此系统的一等状态是配对,加上布局(谁在条上、谁成组、谁激活)、活跃绑定和 background 记忆。分屏与悬浮是配对的两种呈现,不是两套关系。

复合组 groups[] 至多两个成员;条上投影为一个条目。用户建组走 groupTab;PiP「展开为分屏」走 setSplitWebTab,把当前会话与该叶子配成一对。splitWebTabId 是兼容指针,规范化时若叶子尚未入组且激活的是会话,才物化成 group。

agent 在同一会话连续 open / activate 多张页时,悬浮槽跟随注意力:新工作页 show 进槽位(或 revealAgentWebTab 把未配对、或已配对到当前会话但不可见的页装进去并写入配对),先前那张回到标签条。已配对到其他会话的页不进入这条路径,不劫持。

2 · 场景图

同一份绑定——ex.dev 属于会话 A——在不同 activeId 下的呈现。点选场景查看覆盖函数哪一条挡住了宿主。

3 · 状态与迁移

3.1 单个叶子的位置状态机

WebTab 呈现位置状态机 center、split、PiP 绑定、background 记忆、已关闭。绿色边进入 PiP,灰色边离开 PiP,红色边清除绑定。 center 独立中心标签,pane 是宿主 split group / splitWebTabId 右窗格 绑定 PiP(owner = S) store: tabId + ownerTabId 画不画 = pipCoversCenter(activeId) background 记忆 backgroundTabId backgroundOwnerTabId 叶子关闭 / 绑定清空 agent op=open(会话且不走 split) ensure(Exclusive)WebTab + show(id, active.id) collapseWebTabToPip 全屏接管 end + setActive collapse(先解组) 展开为分屏 end + setSplitWebTab groupTab / setSplitWebTab ✕ hide() WebPreviewChip → show owner / 叶子关闭 → end() 遮罩「结束浮动」也 end,叶子留在 store
图 1 — 位置状态机。end() 只清绑定;叶子仍在 center-tabs store,除非 closeTab。切会话不是迁移,只是覆盖函数对同一绑定取不同值。

3.2 PiP store 字段

字段 / 动作含义
tabId · ownerTabId活跃绑定。二者同时有值或同时为 null;pipBoundTabId() 据此判定遮罩。
backgroundTabId · backgroundOwnerTabIdhide() 留下的恢复票据。唯一读者是 WebPreviewChip。
rect小窗几何,仅内存。clampPipRect 限制在 .center-pane-chat 内,最小 240×160。hide/end 都不清 rect。未拖动时默认落在会话页头部(.chat-view-controls:视角切换 + ⋯)下方右上角;拖动后以 inline rect 为准。
show(tabId, ownerTabId)登记 pairedOwnerByTabId,建立活跃绑定并清空 background。全局单例槽位,新绑定覆盖旧绑定;旧页的配对仍在。
hide()清活跃绑定,写入 background。语义:用户暂时收起,可一键恢复。
end()绑定与记忆全清。语义:这个网页不再是任何会话的悬浮窗。

3.3 覆盖函数 pipCoversCenter

小窗渲染当且仅当 pipCoversCenter 成立:pipCoverBase 与 owner 校验同时为真,且 activeId 是 owner 会话。分屏仍独占原生 bounds(splitWebTabId === tabId 时小窗不画)。打开该网页标签时覆盖为假,预览隐藏,绑定仍保留。

  1. 叶子仍在且 kind === "web";
  2. 叶子不在当前激活组的 visibleIds — 分屏已经在画它,不再叠一层;
  3. splitWebTabId !== tabId;
  4. 当前激活标签是会话;
  5. activeId === ownerTabId。

PiP 只作为聊天悬浮,浮在 .center-pane-chat。页面标签活动时组件返回 null,不为原生几何预留停靠区。store 订阅 center-tabs:owner 或叶子被删 → end();background 引用失效 → 清记忆。

4 · 各场景行为

R1每会话一个悬浮槽;切走消失,切回重现当前工作页

配对表可以记下多张页,活跃绑定只有一份。呈现是 pipCoversCenter 的输出(条件 5)。切到会话 B 时 store 仍持有 (ex.dev, A),组件因 live === false 不画;切回 A 同一函数再为真。这不是两次事件。

「切回重现」只对活跃绑定成立。用户在小窗点 ✕ 走的是 hide(),绑定进入 background,切回 A 出现的是 composer 里的「显示网页预览」,不是自动弹出的小窗。

R2直接打开被绑定的网页 → 隐藏聊天预览,原生页面占满内容区

pipCoversCenter 在 activeId === tabId 时为假。绑定(tabId / ownerTabId)与跟随偏好保留。WebTabPane 保持完整工具栏,.webFrame 占满可用内容区,不为 PiP 预留停靠区,也不渲染 PipBoundMask。返回 owner 会话时覆盖再为真,除非用户走了 hide()。

  • desktop 继续 ensureWebView,原生 bounds 只来自测得的 .webFrame。
  • 聊天 PiP 复用同一个 native view,支持点击、滚动与输入。拖动通过 requestAnimationFrame 合并位置更新,真实页面保持显示;打开顶部标签后交还视图,不创建第二个 Page。

R5工具栏「收起到悬浮窗」:目标解析

收起按钮的 owner 解析 已绑定回原 owner;否则同组会话;否则配对表;都没有则不显示按钮。 collapseWebTabToPip 工具栏 / BrowserMenu ① 已绑定且 owner 仍在 回原 owner,不换绑 ② 同组伙伴是 session pipCollapseTargetFor ③ 配对表里的 owner 都没有 → 不渲染按钮 解析到 owner 后统一执行 1. splitWebTabId===tabId → setSplitWebTab(null);否则在组里 → ungroupTab 2. setActive(ownerTabId)  3. show(tabId, ownerTabId) — 不重载页面 无 session 时返回 false,绑定不变
图 2 — CollapseToPipButton 与 BrowserMenu 的 collapseToPip 用同一解析:已绑定 →「返回『X』的悬浮窗」;未绑定有目标 →「浮动到『X』」;无目标 → 不渲染按钮、不传菜单项。独立网页没有这条操作。

5 · agent 交互

R3op=open 的布局与 fork

agent op=open 决策树 桌面宽屏且组内已有网页则走 split;会话否则走 PiP(含 fork);非会话前台打开。 webtab.command op=open installDesktopMenuHandlers active 是 session? 桌面 + 宽度够 split 且组内已有 web 成员? → split:openWebTabInSplit(url) 随后 registerPipPair(id, active.id);顶替本组窗格,不弹小窗 → PiP:绑定给当前会话 pipOpenMustFork(url, active.id) 真 = webTabId(url) 正被另一会话绑定 → ensureExclusiveWebTab(popup id)不劫持 假 → ensureWebTab 复用规范叶子;随后 show(id, active.id) 浏览器或窄窗:isDesktopSplitLayoutAvailable 为假,会话一律走这里 → 前台:openWebTab(url) 激活的是文件 / 终端 / NTP 等,不做小窗
图 3 — owner 自己重复打开同 URL 复用叶子。非 owner 且规范叶子已被绑定才 fork。fork 保住的是叶子身份,不是 A 的槽位:随后的 show(id, B) 覆盖全局绑定。打开后等 waitForWebTabReady(2s)→ bridge.webTab.activate → webtab_result。失败且非 split 时 restorePriorActiveTabAfterFailedWebOpen 还原焦点。

R4op=close:结构性权限,不是打开者记账

closeAgentWebTabResult(tabId, tabs, groups) 是纯函数:

条件结果
叶子不存在或非 web{ok:false, reason:"tab_not_found"}
出现在任一 group 的 memberIds{ok:false, reason:"tab_in_user_layout"}
其余(独立标签、PiP 绑定、background){ok:true} → closeTab;若它是 PiP 叶子,订阅随之 end()

「进过用户布局(组)」即视为用户资产。agent 打开的叶子一旦被拖进组,就失去关闭权。反过来说:用户自己打开的独立网页,agent 也能关——实现不按打开者记账。

会话读哪个页面

surfaceRefForChat(sessionId) 的优先级与宿主模型一致:该会话组内可见 web 窗格 → 会话激活时的 splitWebTabId → 会话激活且为 owner 时的 PiP 叶子。都无、或 bounds 未上报,则不带 surface。visibleWebTab / visibleWebTabById 用 peekLiveWebTabPipId,停在非 owner 上的绑定不进入 agent 可见集合。op=preview/activate 的目标若不可见,revealAgentWebTab 在活跃中心页是会话、目标是 web tab、且该页未配对或已配对到当前会话时,把它 show 进该会话的悬浮槽(未配对则同时建立配对),等 webview ready 再执行。已配对到其他会话的页拒绝,不劫持,操作不跑。split 打开后 registerPipPair 登记归属,不经过 show()。

6 · 实现映射

规则 / 概念落点
配对表、活跃绑定、background、show/hide/endapps/web/lib/browser/web-tab-pip-store.ts · pairedOwnerByTabId / pipPairedOwnerFor / useWebTabPip
R1 切走消失 / 切回重现同上 · pipCoverBase / pipCoversCenter / peekLiveWebTabPipId
R3 fork同上 · pipOpenMustFork;创建叶子在 center-tabs-store.ts 的 ensureWebTab / ensureExclusiveWebTab;id 在 center-tab-ids.ts 的 webTabId / nextPopupWebTabId
R5 收起目标pipCollapseTargetFor(同组会话 → 配对表 → null)/ collapseWebTabToPip;无目标时 CollapseToPipButton 返回 null,BrowserMenu 不传 collapseToPip
agent 交互揭示(未配对或本会话配对)同上 · revealAgentWebTab;desktop-bridge.ts 的 op=preview/activate 在 reveal 成功后 waitForWebTabReady 再跑。split 路径 registerPipPair
owner / 叶子关闭时清绑定与配对web-tab-pip-store.ts 末尾 useCenterTabs.subscribe
小窗:live、拖拽、bounds、截图、展开 / 全屏 / ✕apps/web/components/center-tabs/web-tab-pip.tsx · WebTabPip;挂在 app-shell.tsx 的 .center-body 内,默认 .webPip 右上、top:78px(头部按钮下方)
R2 打开页面隐藏预览web-tab-pip-store.ts 的 pipCoversCenter 仅在 owner 会话为真;web-tab-pane.tsx 的 .webFrame 占满内容区,无停靠门户
截图缓存usePipSnapshots / getSnapshot / setSnapshot;仅不支持原生视图的旧 bridge 使用 capture 降级;现代桌面 PiP 不轮询截图
agent open / close / 回执apps/web/lib/desktop/desktop-bridge.ts · installDesktopMenuHandlers 内 WS 分支;closeAgentWebTabResult;restorePriorActiveTabAfterFailedWebOpen
会话↔网页配对、可见性同上 · surfaceRefForChat(PiP 回退读 peekWebTabPipId / peekWebTabPipOwnerId)/ visibleWebTab / visibleWebTabById(用 peekLiveWebTabPipId)/ browserPageInventory
桌面分屏是否可用app-shell.tsx 调 setDesktopSplitLayoutAvailable(isDesktop && activeKind==="session" && isSplitLayoutAvailable(width));阈值在 split-layout.ts(360+480+6)
✕ 后的恢复入口apps/web/components/chat/composer/environment-row/chips/web-preview-chip.tsx · WebPreviewChip
split / group 本体center-tabs-store.ts(groupTab / ungroupTab / setSplitWebTab / openWebTabInSplit);center-tab-groups.ts(MAX_CENTER_TAB_GROUP_MEMBERS = 2);center-tabs-persistence.ts 规范化
钉住行为的检查apps/web/scripts/runtime/check-web-split.mjs(覆盖、收起、fork、close、surface)

7 · 已知不一致

下面是代码与产品语义之间已经存在的缝,不是待办清单里的新设计。

fork 不管 hide 之后的记忆。pipOpenMustFork 只看活跃绑定。A 点 ✕ 后会话 B 开同 URL,走 ensureWebTab 复用同一叶子并 show(id, B)——show 清掉 background,A 的恢复 chip 无声消失。「不劫持」的准确范围是「正在显示的悬浮窗」,不是「某会话的悬浮窗资产」。

fork 只认规范叶子。判定是 webTabId(url) === boundTabId。已被 fork 出去的 popup id 再被第三方打开时,条件为假,ensureWebTab 拿到的是另一片规范叶子——结果碰巧正确,守护的却是 id 形态而不是「任何被绑定的同 URL 叶子」。每次 fork 留下一个孤儿叶子,靠 op=close 或用户手关。

close 不按打开者记账。产品说法是「agent 能关自己打开的 tab,不能关用户布局里的 tab」。实现只看「是否在 group」。用户自己打开的独立网页可以被关;agent 打开后又被拖进组的不能关。

fork 不进入 split 路径。桌面宽屏且组内已有 web 时,openWebTabInSplit 会改已有窗格的 URL,不调用 pipOpenMustFork。同 URL 冲突只在 PiP 分支上成立。

「不劫持」只管叶子,不管槽位。R3 的 fork 让 B 得到新实例,但同一句 show(id, B) 覆盖全局唯一绑定。A 的规范叶子还在,绑定没了;切回 A 小窗不重现。用户对另一张网页点「收起到悬浮窗」同样静默顶掉旧绑定。产品句「不劫持」若理解为「A 的悬浮窗资产不被拿走」,实现没有做到。

show 不解组。agent 把已在别人 group 里的规范叶子绑成自己的 PiP 时,不调用 ungroupTab。回到该组时组成员还在;若该叶子是 activeId,覆盖为假,预览隐藏,原生页面占满内容区。

region 口径不统一。surfaceRefForChat 把未入组的配对页(含 PiP)标成 region:"right";browserPageInventory 把未入组且可见的页标成 "center"。agent 看到的左右/中心与库存不一致。

条件 4 被条件 5 蕴含。所有 show 的 owner 都是 session 标签,activeId === ownerTabId 已保证 kind === "session"。pipCoverBase 仍保留这条,是为 WebPreviewChip 在恢复前单独复用 pipCoversCenter 做兜底。

PiP 不进 version-2 payload。刷新后绑定、background、小窗位置全部丢失,叶子退回普通中心标签。「切回 owner 重现」只在同一渲染进程生命周期内成立。

8 · 会话标签前进与后退

标签栏最左侧显示后退、前进;只使用当前标签自己的访问历史,默认入口页也属于该标签的历史。网页仍使用网页自己的导航控件。

← → [会话 B ×] [网页 ×] +

当前标签历史:会话 A → 会话 B。后退恢复 A,前进恢复 B;后退后选择 C 则移除 B 的前进记录。

公共入口可观察结果
侧栏、历史页、深链接打开会话当前会话标签或新标签页原位导航;其他类型页面可以选择已有会话标签或新建会话标签。
后退 / 前进每个标签独立保存历史和游标;导航目标已在其他标签显示时仍只替换当前标签,保留其他标签。重复打开当前会话不增加历史。
草稿与 ACK历史保留草稿 ID,输入仍按会话 ID 保存。后台确认更新全部对应历史记录,不激活后台会话。
重新加载 / 删除会话历史随 version-2 标签持久化;旧标签按单条历史兼容。已知删除会话从全部历史移除,不能由后退恢复。

实现采用现有 Zustand 标签库、布局规范化与路由激活路径。标签 ID 标识标签实例,sessionId 标识当前会话;不再根据会话 ID 推断标签身份。页面历史记录页面身份、会话元数据、内容路由和文件位置,不复制输入或消息。新建标签遇到已有 ID 时分配唯一后缀。源窗口转移删除在异步确认前后核对准备时的会话历史;导航改变快照则取消旧转移,回滚不覆盖新的导航状态。

路由同步只响应 pathname 变化;会话确认由 markSessionReady 更新。用户标签导航由当前标签的 sessionId / draft 变化触发,避免旧 currentSessionId 的中间更新覆盖新导航。删除只依据已加载列表中确定移除的 ID,未加载记录不算删除。

比较范围:保留本页现有网页、split 与 PiP 参考与边界;会话历史是应用内状态,不采用浏览器全局 History 作为每标签记录,不改变网页前进后退、服务端协议或权限。同一会话可以由独立标签实例显示;恢复时保留各标签的身份、内容和历史。不同 sessionId 即使标题相同也保留。

实现状态

每标签独立导航及默认入口恢复已实现,独立规格审查和质量审查通过。导航、路由、文件重命名与历史草稿转移由公开入口测试覆盖。

标签控件

后退、前进和新标签按钮均宽 28px,间距 8px。空标签列表容器不占布局空间,新标签按钮没有左侧分隔线。

关闭最后一个标签

最后一个可见标签可完全关闭;窗口和空标签栏保持打开,中心内容区为空。加号显式打开新标签。会话拥有的隐藏页面仍可从资源访问,不作为关闭后的回退目标。关闭先规范化分屏组,再选择可见相邻项。空状态跨路由变化与重载保留,不自动创建草稿会话。窗口间移动标签保留既有窗口生命周期。

验收:通过关闭按钮或键盘命令关闭最后标签后,不残留可见标签或中心内容;显式打开操作创建所请求的标签。

可交互悬浮页面

用户提供的 Codex 截图仅作视觉参考,不能证明其内部实现。采用持续可见内容、直接输入和按帧合并拖动。复用既有 Electron WebContentsView 与可见 bounds 集合,不创建另一 Page 或转发图像坐标。移动时原生视图保持显示;每动画帧更新宿主变换和原生 bounds,仅释放时持久化几何信息。原生输入保留既有浏览器控制与权限行为。

普通浏览器对 HTTP(S) 使用沙箱 iframe。这是独立嵌入文档,不连接 Desktop Page;登录状态和 frame-ancestor 限制遵循浏览器策略。受限 URL 显示不可用信息并保留打开页面入口。缺少可见视图支持的旧 Desktop bridge 保留带标签的图像回退。隐藏、切换或卸载预览时释放 bounds 和观察器,不关闭 Page。菜单与对话框遮挡原生内容。

验收:公开 WebTabPip 挂载精确 tab id,在当前 Desktop bridge 不截图,拖动中更新原生 bounds 而不重建或隐藏页面,保留指针清理与会话隔离,并提供无 body 点击覆盖层的 iframe 交互。原生 App 验收覆盖变化计数器、点击、输入、滚动及拖动中持续更新。

实现状态

源码实现通过独立规格和质量审查。安装版 App 验收与验证结果分别记录;源码审查不能证明原生渲染性能。

标签按下、松开与资源绑定

按下标签使用与拖动相同的背景和背景模糊,不增加按下描边或阴影,不改变当前页面、路由或会话。键盘聚焦保留 focus-visible 提示。没有拖动时,松开后的普通点击才激活标签。拖动、Escape、取消或失焦均取消该次点击。标签在顶部标签栏内和栏外拖动时,与分屏标题的拖动预览共用背景、背景模糊和阴影 CSS 变量。栏内排序和分离窗口意图不会覆盖这些效果。设置 → General 提供透明度(0–100%)、背景模糊(0–40px)、阴影强度(0–100%)、即时预览与恢复默认。默认透明度 20%、背景模糊 16px、饱和度 1.2,阴影 alpha 为原强度的 25%。文字、图标和控件保持不透明,仅模糊预览背后的内容。localStorage 持久化归一化后的数值,首帧主题脚本在水合之前应用,并通过 storage 事件同步同源窗口。切换主题保留这些偏好。非法或缺失字段使用默认值,有限数值限制在对应范围内;模糊和阴影设为零则关闭对应效果。普通按钮与 Resources 描边不受影响。标签栏内保留排序,栏外松开保留现有窗口转移事务。

“新窗口”提示固定在顶部标签栏内,不进入原生网页范围。CSS 层级无法覆盖 Electron 子视图;栏内提示无需隐藏网页或创建另一层原生窗口。继续使用现有指针捕获与窗口转移事务。

把单个网页标签拖到已存在且正在显示的会话的 Resources,松开后将原有 Page 绑定到该会话。Resources 未选中时,只有导航按钮区域可接收并高亮,下面显示的其他视图不是资源拖放目标。Resources 已展开时,下方资源内容区也可接收,并显示从导航按钮下方开始的大框;导航按钮与内容区不合并成一个矩形,不相互覆盖。拖放提示只显示一层边框,不模糊内容。提示矩形与目标外边缘对齐,不向内缩进;导航按钮描边使用与按钮相同的圆角。成功后展示 Resources,当前会话、网页身份、顶部标签和原有关联均保留。会话通过现有资源治理使用网页,不中断或覆盖其他执行的正在进行的操作。组合标签、未发送草稿及其他标签类型不作为绑定来源或目标。

服务端验证 owner 权限、会话及已注册桌面连接中的窗口、标签和原生页面身份,使用 BrowserResourceStore 保存关联,不复制 URL。失败时提示错误,不切换页面、不分离窗口、不显示虚假绑定。连接变化拒绝过期结果;取消拖动不发送绑定命令。键盘激活和右键菜单保持现有行为。

验收与实现状态

行为测试覆盖按下与松开、拖动或取消后的点击抑制、Resources 目标、原页面绑定、权限与过期身份、失败和重复绑定。执行现有 Web、桌面、资源、契约检查及安装版 App 验收。源码已包含这些行为;发布前必须完成独立审查与上述验收。

会话文件预览

用户提供的桌面截图约定左侧会话、右侧文档、输出文件行,以及文件变化后的刷新按钮。复用现有 CenterTab 分组和 DocumentWindow,保持全局宽度、字体、拖动、导航和用户创建的布局。

实时成功结束的回复将本地 Markdown 文件链接和 send_file 附件显示为文档行,并在当前聚焦的所属会话右侧自动打开第一个支持预览的输出。历史加载、失败或取消的回复、后台会话不自动打开。已有手动分栏或未保存编辑保持原状,文档行仍允许明确点击打开。同一文件复用所属会话的预览标签。

项目内输出复用项目文档管理;其他绝对路径使用现有会话权限检查的只读附件管理。模型提供的路径不授予权限,不执行文件内容或嵌入原始 HTML。新控件统一使用已有 Lucide 动态图标,支持键盘聚焦和减少动态效果。

可见文档定期检查经过权限验证的文件元数据,变化后显示文件已修改与刷新按钮。刷新始终可用,成功读取磁盘内容后更新预览,绕过浏览器缓存。刷新不丢弃本地编辑、待确认保存或富文本编辑;原有明确丢弃和冲突处理保持有效。隐藏窗格停止轮询,读取失败保留当前内容并支持重试。

验收通过公开回复结束、文件行、标签存储和 DocumentWindow 完成:新输出形成左会话右文件;同一路径不重复;切换文件保留原标签和草稿;后台、历史和重新挂载不改变当前预览;实际 PDF 与文字文件点击刷新后更新。元数据沿用已有目录和会话权限。

指向同一项目文件的逻辑标签共享一个持久编辑器。切换会话时,将现有文件栏显示在当前布局中,保留原生编辑器和草稿。只有没有其他保留标签引用该文件时,关闭标签才关闭文件控制器。如果同一文件的多个标签同时可见,另一栏提供“在此显示”按钮移动现有编辑器,不挂载第二个编辑器。

实现状态

输出文件卡片、所属会话双栏和保留草稿的刷新复用共享标签布局与文件控制器。组件和浏览器检查覆盖这些行为;独立审查、发布检查及已安装 App 的证据在任务验收记录中维护。

原生窗口按钮对齐

桌面 Tab 栏高 40 CSS 像素,导航箭头和 Tab 控件共用垂直中心。macOS 使用原生 hiddenInset 按钮,其控件框高 16 DIP。Electron 的按钮坐标使用设备独立像素,而 Chromium 会按界面缩放比例缩放 Tab 栏,因此固定的原生坐标会产生偏差。

主进程读取所属 renderer 的当前缩放比例,将原生按钮顶部设为 round((40 × zoom − 16) / 2),最小为零。水平坐标为顶部坐标加 1 DIP;当前 macOS 原生红色圆点相对控件框的纵向内缩比横向内缩多 1 DIP,此补偿使圆点到上边框和左边框的可见距离一致。两轴随界面缩放一起更新,Tab 的水平间距保持现有值。创建窗口、文档载入、renderer resize 和退出全屏时重新定位。preload 发送无参数的 resize 通知;主进程只接受所属 BrowserWindow 主文档的通知。内嵌网页、子 frame 和浮层不能调整其他窗口。无需轮询、持久化布局或重置缩放。Windows 保留现有 Window Controls Overlay,Linux 保留原生窗口装饰。

采用 Electron 的 setWindowButtonPosition 和 getZoomFactor。不只依赖 zoom-changed,该事件的官方说明针对鼠标滚轮缩放请求。renderer resize 覆盖菜单和键盘缩放,无需另存缩放状态。用户截图只作为可见对齐要求,不能据此推断其他产品的实现。

验收:正常缩放下,原生按钮与导航和 Tab 控件中心的差异不超过 1 DIP,红色圆点到顶部和左侧边框的距离差异不超过 1 DIP;原生整数坐标允许舍入。初次载入、调整大小、缩放和退出全屏后,各窗口仍满足此要求。极端缩放使 Tab 栏低于原生按钮框时,顶部坐标最小为零。原有按钮、Tab、窗口生命周期和其他平台行为保持一致。

实现状态

共享桌面窗口模块已实现原生按钮对齐。生产检查覆盖实际 preload resize 通知、发送者归属、缩放几何和生命周期接入。独立审查、最终验证和安装 App 可视验收是分别执行的发布要求。

独立标签导航

打开应用管理页面通过应用路由加载组件,这些路由进入选中标签的页面历史。选择标签通过应用外层恢复标签自身内容;会话路由仍使用浅导航。管理详情页保留直达链接。

每个标签拥有一个有序页面历史和游标,默认启动页为第一页。导航替换本标签内容,后退/前进只改变本标签。选择另一标签不记录访问。目标已在其他标签显示时,不重定向重放,也不修改其他标签。不同标签实例可显示同一会话或应用。

页面快照包含页面身份、会话元数据、内容路由和文件视图快照,不嵌套历史。既有 pageHistory 持久化是唯一历史存储。旧会话历史在首次导航时转换。文件树数据由活动标签派生,切换时恢复该标签当前路由和文件视图。后台 ACK/标题变化更新保留页面,不追加历史。

显式新标签操作仍创建标签。启动页选择及会话、Files、Browser 主页内导航替换当前标签。目标身份已属其他标签时,当前槽位获得唯一实例身份。原生浏览器 URL 历史仍由浏览器控件管理,不保留窗口级或跨标签回退历史。

验收:创建历史不同的 A/B,切换并双向遍历,断言另一标签及其游标逐字节不变。覆盖默认启动页、文件/文件夹、会话和应用标签,包括两个标签同目标、草稿 ACK/标题、删除、重载和分组布局引用。

代码组织:center-tabs-store.ts 是公开组合入口;store/ 包含类型状态、共享提交辅助函数及按布局、导航、会话、页面、浏览器、分屏和转移分组的操作。navigation/ 包含纯页面历史、旧会话辅助函数与选择器。操作模块不拥有第二份历史存储。

实现状态

共享标签历史与应用页面边界已通过独立规格及质量审查。前端验证覆盖公开状态库、实际 React 标签激活、持久化与转移、Web 和 Desktop 检查、TypeScript 及干净文档链接。生产构建及安装版 App 验收分别记录。

页面导航与标签名称

全部应用路由,包括设置、Agents、Abilities、应用、会话历史和 Scheduler,均记录为选中标签的页面访问。可见标签名称随当前页面变化。管理路由采用页面或设置分区名称;会话、文件和网页保留现有内容标题。

后退、前进恢复当前标签自身的路由和名称,切换标签恢复该标签当前页面。重载和转移保留管理页面访问,不再删除。访问页面不重命名底层会话,也不改变其他标签。

分屏只容纳聊天区内容。从分屏里的标签访问页面时,页面在分屏之后另开一个顶层标签,分屏的成员和布局保持不变。恢复出的分屏成员如果已带页面路由,会离开所在分屏。

复用 navigationRoute 和 pageHistory,名称在展示时由路由派生,不增加第二份标题或历史存储。未知路由显示路径,避免保留旧内容标题。

验收:同一标签依次访问会话、Agents、Abilities、应用和设置分区,检查可见名称、后退/前进、切换另一标签、持久化及转移。后台会话标题变化在返回会话后仍可见。

从其他页面恢复详情地址时,先加载已导出的主页面,再把临时主页面地址替换为保存的详情地址,最后记录访问历史。重复的标签激活请求只执行一次导航。主页面提交前再次切换标签时,迟到的提交恢复最后选择的目标,不记录为访问历史。切换 Provider、技能和插件详情标签不能触发整页重载。

Provider 选择以当前路由参数为准。列表响应只能为未指定详情的页面选择默认项,不能覆盖 URL 指定的详情。切换详情不重新加载 Provider 列表。

实现状态

动态路由名称及页面访问保留通过独立规格和质量审查。状态库与 React 导航检查覆盖当前名称、独立历史和持久化。前端构建、静态检查和双语文档检查通过;安装版 App 验收分别记录。