# dsh-split-screen 高级工程师审查报告(2026-10-03) 审查对象:`dsh-split-screen` v1.3.0(`lib/client.js`,5,537 行单文件 bundle + `lib/index.js` 空宿主入口) 用户报告症状:① dsh desktop 上频繁出 bug;② 偶发整机卡死/死机;③ 开启插件后选不了 workspace、也选不了 agent mode(标准/极简等)。 --- ## 一、总体判断 这不是一个"写坏了"的插件——CSS 细节、双版本(0.1.x / 0.2.0)兼容层、IME 保护、错误边界都下了很大功夫。 但从架构上有三个**根本性风险决策**,所有用户可见的症状都是它们的下游后果: 1. **"Always on"(常开)设计**:插件一经启用,就用一个 `position:fixed` 全高 overlay 盖住会话列, 并把原生 ChatView/InputBar/Header "再托管"进面板。等于在宿主 UI 之上重建了半个会话界面。 2. **用 DOM 抓取当数据源**:0.2.0 上没有列表 store 时,用 `document.body` 级 MutationObserver + `innerText` 扫描侧栏行来推会话列表。 3. **与宿主抢 Lexical 编辑器根**:面板和被盖住的原生 composer 共享同一个 per-session Lexical editor,插件用 root-listener + 4 个 document 级捕获监听持续"夺回" root element。 凡是"再托管 + 抢资源"的设计,宿主任何小改动都会放大成用户可见故障,且 CPU/布局成本随面板数线性甚至超线性增长。 --- ## 二、症状 → 根因映射 ### 症状③:选不了 workspace / agent mode(兼容性问题的主体) **根因 A — overlay 物理遮挡原生 chrome(最直接)** `.dsv-overlay` 为 `position:fixed; top:0; bottom:0; right:0; left=<侧栏右边缘>`,从侧栏右边一直盖到 窗口右缘、全高。原生会话列的 hero 行(workspace 选择器 + agent preset"标准/极简模式"选择器, 新会话时出现)就在这块区域**下面**,点不到。插件只在面板内再托管了 `conversation.hero.agentPreset` 一个座位,workspace 选择器从未被再托管。更糟的是 `apply()` 里有"空树自动 addPane(当前会话)"的效应: **插件一启用、页面一刷新,哪怕用户根本没分屏,overlay 就已盖上去**——这正是"开了插件就选不了"的路径。 **根因 B — 桌面端几何探测不可靠** `measureColumnLeft()` 假定 shell 是 `frame.firstElementChild == 侧栏` 的三列 grid。desktop 组合 多出标题栏/标签条/其他 chrome 时,firstElementChild 未必是侧栏,`gridTemplateColumns` 正则也可能 抓错轨道 → `left = 0` 或错误值 → **overlay 连侧栏一起盖掉**,什么都选不了。代码对测量失败没有 任何"放弃显示"的保护。 ### 症状②:整机卡死/死机 **根因 C — 全文档 DOM 抓取 + 强制回流风暴(资源主因之一)** 0.2.0 路径 `createDomSessionListSource()`:observer 挂 `document.body`, `childList+characterData+attributes(subtree)` 不过滤自身。任何面板里流式输出 token (characterData 变化)都会触发 `readSidebarSessions()`:全文档 `querySelectorAll` + 每行 `innerText`(**innerText 强制同步 layout**)。N 个流式会话 → observer 每帧触发 → 每行强制回流 × 行数 × 频率 → 渲染进程 CPU 打满。Electron 渲染器 + GPU 进程同时高载, Windows 上整机卡顿甚至"死机感"。这是最典型的 freeze 机器。 **根因 D — Lexical root 抢夺无速率限制(资源主因之二)** `paneEditorGuards` 的 guardRoot 会在"面板宿主"和"被盖住的原生 composer"之间反复 `editor.setRootElement()`,每次调用 Lexical 会**重建编辑器整个 DOM 并重挂监听**。 代码有 alive/dispose/IME 让位,但**没有频控、没有熔断**:原生侧和插件侧一旦进入 对峙(desktop 布局下更容易发生,比如两边都认为自己可见),setRootElement 乒乓 = 无限 DOM 重建循环 = 界面冻死。 **根因 E — 每渲染强制布局** - `FallbackTranscript` 的 `useLayoutEffect` **无依赖数组**,每次渲染读 `scrollHeight`(强制回流), 流式期间每个 token 批次都触发。 - `PaneFrame` 的 measure 效应对整个 frame 挂 `childList+subtree` MutationObserver, 流式期间每次 React commit 都调度 `measure()`(querySelectorAll + 每节点 getBoundingClientRect + getComputedStyle)。 - `useComposerAlignedRow` 挂载后跑 **90 个 rAF tick**(~1.5s)逐帧测量;每个 DockLine 各跑一套。 ### 症状①:desktop 上频繁出 bug **根因 F — 模块级全局监听器不随插件卸载** 两个 `window.addEventListener("keydown", …)`(Alt+T、Alt+Shift+方向键)在**工厂求值时**注册, 永远不摘除。HMR/插件重载后新旧两套监听同时存在,快捷键双触发;`window.__dshSplitView` 在模块作用域赋值。模块级 Map(`paneSessionReferences`/`paneEditorGuards`/`paneImageIntake`/ `paneTypingUnsubs`/`chatScrollMemory`/`entryStoreCache`/`rootStandard.listeners`)清理不全 → 旧会话引用、旧 guard 残留,正是"越用越怪"的温床。 **根因 G — WorkspaceApp 自身无错误边界** `PaneBoundary` 只包再托管的子组件;若 `WorkspaceApp` 本体在 render 中抛错(desktop 服务面差异、 faces 缺失等),异常直接打进 shell 的 overlay outlet,行为随宿主版本不定——轻则白屏, 重则连坐周围布局。 **根因 H — 美学/性能:focused 光晕过重** `.dsv-pane.focused` 四层 box-shadow(36px blur + 30px glow),多面板时 GPU 合成成本可观, 视觉上也偏"选中框"而非"海拔"。 --- ## 三、改进方案(已按此执行,v1.4.0) 总原则:**从"常开再托管"退到"多面板时才接管"**(≤1 个面板 = 原生视图,插件完全不可见、零开销)。 这一个决策同时消灭根因 A/B 的暴露面和 C/D/E 的大部分触发条件。 | # | 改进 | 对应根因 | 类别 | |---|------|---------|------| | 1 | **激活门控**:`leafCount ≥ 2` 才渲染 overlay;单面板/空树时 render null,且不再"自动添加当前会话"。原生 hero 行(workspace + agent preset)永远可点 | A | 健壮性 | | 2 | **四边几何约束 + 失败即退避**:测量会话列左/右/上/下边界(含 desktop 标题栏、details 列),宽度 <360px 或测量不可信 → 不激活并 warn 一次。绝不整窗覆盖 | A/B | 健壮性 | | 3 | **DOM 列表源治理**:`innerText`→`textContent`(零强制回流);读取节流(≥200ms 间隔 + rAF 合并 + 尾随触发);observer 仅在**激活状态**下挂载 | C | 资源 | | 4 | **Lexical guard 加频控 + 熔断**:两次 re-point 间隔 ≥250ms;10s 内 >6 次即熔断该会话的 guard 并 warn 一次,杜绝乒乓死循环 | D | 资源/健壮性 | | 5 | **测量预算**:settle 循环 90→12 tick 且几何稳定即提前退出;TUI 模式不挂测量 observer;`FallbackTranscript` 滚动效果加依赖数组 | E | 资源 | | 6 | **生命周期收敛**:window 键盘监听移入 `apply()` + `ctx.effect` 摘除;`appliedOnce` 防双注册;卸载时清空全部模块级 Map/监听/引用 | F | 健壮性 | | 7 | **顶层错误边界**:`WorkspaceBoundary` 包住 WorkspaceApp,崩溃时静默卸载 overlay 并 console.error——插件失败永远 fail-open,原生 UI 保命 | G | 健壮性 | | 8 | **光晕减重**:focused 状态改为 1px 品牌描边 + 两层克制阴影;保留 reduced-motion 支持 | H | 审美/资源 | | 9 | **拖拽分屏桥**(补 always-on 移除后的入口):非激活时,从侧栏拖会话到会话列任意位置即分屏(ghost 预览 + 边缘分屏/居中平铺),document 级监听同样走 ctx.effect | UX | 审美/功能 | | 10 | **诊断上限**:`paneDebug.faces` 等诊断结构加上限,防无界增长 | F | 资源 | **明确不做(记录为 v2 方向)**:把 5.5k 行单文件拆成 `src/` 多模块 + esbuild 打包。 deploy 格式(`window.__ModuleLoader__.load` 单工厂文件)决定了一次重排收益有限、 而在无法挂载真机验证的本次变更里引入构建链风险大于收益。本次保持单文件、原地加固, 文件头补架构导览。 ## 四、验证方式(不挂载) - `node --check` 语法校验。 - `tests/smoke.mjs`:stub `window/document` + 模块加载器,加载 bundle、执行 `apply()`、 模拟 keydown 注册/清理、模拟树操作(addPane/splitLeaf/closeLeaf 激活门控)、执行卸载清理, 断言无异常、无监听器残留。 - **不执行** `dsh plugin add`,不改动 `\profiles\*` 下任何已安装副本。 --- ## 五、追加排查(2026-10-03 第二轮):cost meter 冲突 / 无法聚焦 / 聚焦后打不了字 用户补充的三个历史症状,逐一在代码里定位(cost meter 为 `dsh-cost-meter`,注册 `conversation.composer.dock` ×3 + `conversation.input.dock` qstrip + 全局 CSS): ### 5.1 "cost meter 在 chatbox 底部打架" - **根因 I — `data-slot` 印章缺失(布局规则一边生效一边不生效)**:cost-meter 用全局 CSS `div:has(>[data-slot="conversation.composer.dock"] .cm-stat-entry){flex-wrap:nowrap;…}` 排版自己的 dock 条目。stock 插座给渲染结果盖 `data-slot=""` 印章,而插件的面板渲染器只盖 `data-slot-id` —— 于是同一条规则在 stock 页生效、在面板里失效,两边 dock 行的 wrap/gap 行为不一致,视觉上"打架"。 **修复(v1.4.1)**:面板渲染器(renderList/renderSlotChain)补盖 `data-slot=""`,与 stock 插座对齐,第三方 `:has()` CSS 在面板内行为一致。 - 次要因素:面板的 `.dsv-dock-cell > *` 用 `!important` 重置条目 padding/margin(为中和 stock 宽度公式),会压掉 cost meter 自身 `.cm-foot` 的左右 padding——视觉偏挤,非破坏性, 保持现状并记录。 ### 5.2 "有时候无法用鼠标点击聚焦到输入框" 两个互相叠加的根因,都实锤在代码路径上: - **根因 J — DockLine 拖拽 `preventDefault()` 吃掉聚焦(与 cost meter 直接相关)**: 面板的 dock 行(stats / cost meter 条目所在的横排)在内容溢出时变为可拖拽,旧实现对该行上 任何非控件 mousedown 一律 `event.preventDefault()` —— **preventDefault mousedown 会取消 浏览器本次点击的默认聚焦**。cost meter 的统计行恰恰最容易把行撑到溢出,于是"点了输入框 下缘/cost meter 行附近 → 焦点消失"。 **修复(v1.4.1)**:拖拽改为"移动 ≥3px 才接合",接合时才清选区;mousedown 不再 preventDefault,普通点击行为完全恢复。 - **根因 K — 编辑器根停在"被盖住的 stock composer"上(Lexical 争抢的残留态)**: 面板输入框只有在它就是该会话 Lexical editor 的 root 时才是 contenteditable。争抢 (见根因 D)停在任何一侧后,若根停在隐藏的 stock composer,面板的输入元素**根本不是可 编辑元素**——点击自然无法聚焦。v1.4.0 的熔断把失败模式定为"不再修复",恰好可能固化这个 坏状态。 **修复(v1.4.1)**:熔断触发时做**最后一次** re-point,让可见的面板宿主持有根 (fail-visible),之后才收手;面板关闭时的"交还"路径不受熔断影响(保证 stock 侧不死)。 另外 `paneHost()` 从"取第一个 `[data-composer-input]`"改为"取第一个**可见的**", 过渡态的零尺寸座位不再误判为"无宿主"。 ### 5.3 "聚焦到输入框之后完全无法 type" - **主因即根因 K/D 的交互态**:焦点已落在元素上、但 Lexical 根刚被 re-point 或停在别处 ——元素要么不可编辑(等同 5.2),要么编辑器 DOM 正在中途重建,键入落不进 input machine; IME 场景(composing 标志滞留)会让 Enter/提交持续失效。v1.4.1 的 fail-visible 熔断 + 交还路径常开 + 可见宿主选择共同收敛这些状态。 - 无法在离线环境复现的部分(真实 Lexical 时序)留待用户挂载试用后回报;插件侧所有已知 能把输入框变成"看得见摸不着"的路径都已加保护。 **v1.4.1 修复清单**:DockLine 拖拽不阻聚焦(J)、熔断 fail-visible + 交还常开(K/D)、 paneHost 可见优先(K)、data-slot 印章对齐(I)。 --- ## 六、追加排查(2026-10-03 第三轮):Agent Team 与 Mode Selector 兼容性 用户要求:desktop 上 chat 运行时菜单栏(会话 header)有 Agent Team 触发器和 mode selector 相关控件,检查是否与 split-screen 冲突,并让面板内也能用。 ### 6.1 事实(读 desktop 实际 bundle 得出) - **Agent Team UI**(`@deepseek-ai/dsh-experimental-client-ui-agent-team`,内嵌于 desktop app.asar 的 profile 层):注册在 `conversation.session.header.actions` (id "agent-team",order -20)。组件 TeamAction 需要 root faces (`useSessions`——读 `projectionsBySession[lead].agentTeam`、`useSessionStatus`) + 会话 face(`useSession` 读 subagent 父会话)+ 注入动作 `openTeammate` + 自带 locale 命名空间。面板用 `createPortal(…, document.body)` + `z-index:100` (> 插件 overlay 的 60,不会被遮)。 - **Mode Selector**(`dsh-client-ui-agent-preset`):新会话的 hero 选择器 (`conversation.hero.agentPreset`,插件已重托管)+ 运行中 header 里的 label/chip (`conversation.session.header.actions` id "agent-preset",order -10)。 - 槽位声明(ui-conversation 源码证实):`conversation.session.header.actions` 与 `.utilities` 均为 **`kind: "list"`**,stock 的 `renderSlot` 对 list 槽位渲染全部入口。 ### 6.2 发现的冲突(根因 L) - **根因 L — 插件的 renderSlot 把 list 槽位当 single 渲染**:面板内的 header actions 行只显示第一个入口(agent-team),**preset label 和 cost-meter 统计条目全部静默丢失**——这就是"split screen 里用不到 mode selector"的直接原因。 - **根因 M — openTeammate 的 mainView 守卫**:stock 动作要求该会话 `retainedBy.mainView > 0` 才导航,面板会话通常由 splitView 持有 → 成员行点击 静默无效。 - **根因 N — 窄面板 SlimHeader 完全不渲染 actions 行**,TUI 模式整个 header 不渲染 (后者为 TUI 设计本意,保留)。 - 面板 z-index/portal 无冲突;root faces 经 shell.overlay 收割后 kit 内齐全, TeamAction 所需 hooks 在面板内可用。 ### 6.3 v1.5.0 修复 1. **renderSlot 感知 `kind: "list"`**(根因 L):按 stock SlotOutlet 语义渲染全部 入口(single/keyed 保持原逻辑)。一处修复同时救活 actions 与 utilities 两类 多入口行——宽面板的 header 从此显示完整菜单栏(team + preset + cost-meter)。 2. **openTeammate 面板感知包装**(根因 M):注入面里的 `openTeammate` 在分屏激活时 改路由到工作区——目标成员已有面板则聚焦之,侧栏可查则分屏打开其会话;否则回落 stock 主视图导航。在面板里点成员 = 该成员对话在旁边打开。 3. **SlimHeader 补 actions 行**(根因 N):窄面板在标题与 view tabs 之间渲染 `conversation.session.header.actions`,带 `container-type: inline-size` 上下文, 使条目自己的 `@container` 折叠规则与 stock 行为一致。 4. TUI 模式仍无 header(设计本意,Alt+T 切回);单面板(≤1 pane)时插件不接管, stock 菜单栏原样可用。 已知边界:成员会话若不出现在会话列表(sidebar 不可查),面板内点它回落到 stock 主视图打开(该会话由主视图 retain 后,再次点击即可开面板)。