kind:"web" 的条目。规范 id 是 w:<url>;fork 用 nextPopupWebTabId 写成 w:<url>:popup:…。叶子本身不因浮动而消失。
tabs[] / groups[] / splitWebTabId / activeId
工程记录 · 与当前代码同步 · 已落地
本文回答:一个网页叶子在界面里此刻出现在哪里、被谁控制、agent 能对它做什么。散落的交互规则都从同一条不变量推出,而不是一串补丁。
会话 S 与网页 W 的配对是唯一一等关系。分屏与悬浮窗是同一配对的两种呈现。一个 WebTab 叶子同一时刻只有一个原生 WebContentsView 矩形;聊天 PiP 直接呈现该 Page 的可交互原生视图。同一叶子作为普通顶部标签活动时,隐藏聊天预览及其预留空间,原生页面占满内容区;网页标签与其 PiP 不得同时显示,也不把页面换成遮罩或截图。
配对来自 agent 在 S 打开 W、用户把 W 拖进 S 的分屏/组、收起时建立,或 agent 操作一张尚未配对的页。show() 与 registerPipPair 写入配对表;覆盖函数只决定这份配对此刻画在哪个宿主。独立网页在被 agent 操作之前没有配对,也就没有「收起到悬浮窗」。
kind:"web" 的条目。规范 id 是 w:<url>;fork 用 nextPopupWebTabId 写成 w:<url>:popup:…。叶子本身不因浮动而消失。
tabs[] / groups[] / splitWebTabId / activeId
pairedOwnerByTabId。一个会话可配对多张网页;任一侧关闭才清掉。悬浮窗槽位每会话只有一个,显示 agent 当前工作页,其余配对页驻留标签条。
模块级 Map,随 show() / registerPipPair 登记
(tabId, ownerTabId):此刻哪张配对页占据悬浮槽。切走会话不改绑定;hide() 才把绑定挪进 background 记忆。换绑另一张页不会抹掉旧页的配对。
useWebTabPip 单例,不持久化
pipCoversCenter(tabId, ownerTabId, center) 从「绑定 × 当前激活标签」纯函数推导。组件不另存「显隐」状态。
pane · chat PiP · none
因此系统的一等状态是配对,加上布局(谁在条上、谁成组、谁激活)、活跃绑定和 background 记忆。分屏与悬浮是配对的两种呈现,不是两套关系。
复合组 groups[] 至多两个成员;条上投影为一个条目。用户建组走 groupTab;PiP「展开为分屏」走 setSplitWebTab,把当前会话与该叶子配成一对。splitWebTabId 是兼容指针,规范化时若叶子尚未入组且激活的是会话,才物化成 group。
agent 在同一会话连续 open / activate 多张页时,悬浮槽跟随注意力:新工作页 show 进槽位(或 revealAgentWebTab 把未配对、或已配对到当前会话但不可见的页装进去并写入配对),先前那张回到标签条。已配对到其他会话的页不进入这条路径,不劫持。
同一份绑定——ex.dev 属于会话 A——在不同 activeId 下的呈现。点选场景查看覆盖函数哪一条挡住了宿主。
end() 只清绑定;叶子仍在 center-tabs store,除非 closeTab。切会话不是迁移,只是覆盖函数对同一绑定取不同值。| 字段 / 动作 | 含义 |
|---|---|
tabId · ownerTabId | 活跃绑定。二者同时有值或同时为 null;pipBoundTabId() 据此判定遮罩。 |
backgroundTabId · backgroundOwnerTabId | hide() 留下的恢复票据。唯一读者是 WebPreviewChip。 |
rect | 小窗几何,仅内存。clampPipRect 限制在 .center-pane-chat 内,最小 240×160。hide/end 都不清 rect。未拖动时默认落在会话页头部(.chat-view-controls:视角切换 + ⋯)下方右上角;拖动后以 inline rect 为准。 |
show(tabId, ownerTabId) | 登记 pairedOwnerByTabId,建立活跃绑定并清空 background。全局单例槽位,新绑定覆盖旧绑定;旧页的配对仍在。 |
hide() | 清活跃绑定,写入 background。语义:用户暂时收起,可一键恢复。 |
end() | 绑定与记忆全清。语义:这个网页不再是任何会话的悬浮窗。 |
pipCoversCenter小窗渲染当且仅当 pipCoversCenter 成立:pipCoverBase 与 owner 校验同时为真,且 activeId 是 owner 会话。分屏仍独占原生 bounds(splitWebTabId === tabId 时小窗不画)。打开该网页标签时覆盖为假,预览隐藏,绑定仍保留。
kind === "web";visibleIds — 分屏已经在画它,不再叠一层;splitWebTabId !== tabId;activeId === ownerTabId。PiP 只作为聊天悬浮,浮在 .center-pane-chat。页面标签活动时组件返回 null,不为原生几何预留停靠区。store 订阅 center-tabs:owner 或叶子被删 → end();background 引用失效 → 清记忆。
配对表可以记下多张页,活跃绑定只有一份。呈现是 pipCoversCenter 的输出(条件 5)。切到会话 B 时 store 仍持有 (ex.dev, A),组件因 live === false 不画;切回 A 同一函数再为真。这不是两次事件。
「切回重现」只对活跃绑定成立。用户在小窗点 ✕ 走的是 hide(),绑定进入 background,切回 A 出现的是 composer 里的「显示网页预览」,不是自动弹出的小窗。
pipCoversCenter 在 activeId === tabId 时为假。绑定(tabId / ownerTabId)与跟随偏好保留。WebTabPane 保持完整工具栏,.webFrame 占满可用内容区,不为 PiP 预留停靠区,也不渲染 PipBoundMask。返回 owner 会话时覆盖再为真,除非用户走了 hide()。
ensureWebView,原生 bounds 只来自测得的 .webFrame。CollapseToPipButton 与 BrowserMenu 的 collapseToPip 用同一解析:已绑定 →「返回『X』的悬浮窗」;未绑定有目标 →「浮动到『X』」;无目标 → 不渲染按钮、不传菜单项。独立网页没有这条操作。show(id, B) 覆盖全局绑定。打开后等 waitForWebTabReady(2s)→ bridge.webTab.activate → webtab_result。失败且非 split 时 restorePriorActiveTabAfterFailedWebOpen 还原焦点。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()。
| 规则 / 概念 | 落点 |
|---|---|
配对表、活跃绑定、background、show/hide/end | apps/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) |
下面是代码与产品语义之间已经存在的缝,不是待办清单里的新设计。
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 重现」只在同一渲染进程生命周期内成立。
后退、前进和新标签按钮均宽 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 可视验收是分别执行的发布要求。
全部应用路由,包括设置、Agents、Abilities、应用、会话历史和 Scheduler,均记录为选中标签的页面访问。可见标签名称随当前页面变化。管理路由采用页面或设置分区名称;会话、文件和网页保留现有内容标题。
后退、前进恢复当前标签自身的路由和名称,切换标签恢复该标签当前页面。重载和转移保留管理页面访问,不再删除。访问页面不重命名底层会话,也不改变其他标签。
分屏只容纳聊天区内容。从分屏里的标签访问页面时,页面在分屏之后另开一个顶层标签,分屏的成员和布局保持不变。恢复出的分屏成员如果已带页面路由,会离开所在分屏。
复用 navigationRoute 和 pageHistory,名称在展示时由路由派生,不增加第二份标题或历史存储。未知路由显示路径,避免保留旧内容标题。
验收:同一标签依次访问会话、Agents、Abilities、应用和设置分区,检查可见名称、后退/前进、切换另一标签、持久化及转移。后台会话标题变化在返回会话后仍可见。
从其他页面恢复详情地址时,先加载已导出的主页面,再把临时主页面地址替换为保存的详情地址,最后记录访问历史。重复的标签激活请求只执行一次导航。主页面提交前再次切换标签时,迟到的提交恢复最后选择的目标,不记录为访问历史。切换 Provider、技能和插件详情标签不能触发整页重载。
Provider 选择以当前路由参数为准。列表响应只能为未指定详情的页面选择默认项,不能覆盖 URL 指定的详情。切换详情不重新加载 Provider 列表。
动态路由名称及页面访问保留通过独立规格和质量审查。状态库与 React 导航检查覆盖当前名称、独立历史和持久化。前端构建、静态检查和双语文档检查通过;安装版 App 验收分别记录。