# v0.2 完成报告:看板 UI + 插件取代技能 > 目标:按 plugin-design.md 第四节做出看板(纵向依赖图),按第六节完成"插件取代技能"。 > 状态:**看板已在 web profile 实测可用;技能已归档。** --- ## 一、交付内容 | 组件 | 文件 | 说明 | |---|---|---| | 图计算 | [`src/host/graph.js`](../src/host/graph.js) | 分层、关键路径、同泳道排队边、等待带、阻塞半径 —— 纯函数,可单测 | | 看板页面 | [`src/host/board-page.js`](../src/host/board-page.js) | 自包含 HTML + SVG,无构建步骤 | | 纪律注入 | [`src/host/policy.js`](../src/host/policy.js) | 编排纪律写进系统提示(1847 字,order 610) | | 静态快照 | [`docs/board-snapshot.html`](board-snapshot.html) | 内联演示数据,双击即可看 | | 技能归档 | `~/.dsh/skills/_retired/dsh-parallel-dev/` | 含迁移状态表与恢复方法 | 路由:`/pdev/board`(页面)、`/pdev/board/data`(JSON,支持 `?demo=1`)。 新增工具 `pdev_board`:给出看板地址 + 关键结论。 --- ## 二、看板做到了设计要求的四件事 设计文档说,DAG 的"效果"是四件事,看板必须画出来。用「16 任务案例」的形状实测: ``` 层数 5 | 最宽 6 | 关键路径 T0→T1→T7→T8→T9(剩 3 层)| 被阻塞 3 层宽 [1, 6, 1, 1, 1] | 真依赖边 12 | 同泳道排队边 2 | 等待带 [6,3,3,2] 泳道 8 条 | T1 的阻塞半径 = 3 个下游 ``` | 设计要求 | 落地方式 | |---|---| | **并行宽度** | 层 = 行,带首标「层 N · M 并行」。一行并排读作"这批一起跑" | | **关键路径** | 贯穿的红色脊椎线 + 节点描边。顶栏显示「关键路径还剩 N 层」而不是完成百分比 —— 总工期由关键路径决定,不由任务计数决定 | | **阻塞半径** | 悬停节点 → 高亮全部下游、其余淡出,角落浮层显示「卡住会拖累 N 个下游任务」 | | **收敛结构** | 依赖边从多个上游汇到下游,等待带上标注「K 条边过带 · K 个任务在等」—— 耦合度指标 | **外加一条普通 DAG 不画、但真实存在的约束**:同泳道排队(紫色虚线)。 实测 `impl-store` 上有 T1→T7→T8 三个任务,虚线标出它们必须串行 —— 逻辑上 T7 只依赖 T1/T2, 看起来能和 T1 并行,但派给同一个 agent 就跑不了。不画出来会误判并行度。 --- ## 三、踩到的两个坑 ### 坑 1:路由注册静默失败(表现为 404) 第一版写成 `const webServer = ctx.get("webServer"); if (webServer?.register) {...}` —— 结果 `/pdev/board` 返回 404,而且**日志里连一行错误都没有**。 根因:`apply` 运行时 cordis 服务还没就绪,`ctx.get("webServer")` 返回 `undefined`,走了 else 分支静默跳过。 修法:`ctx.inject(["webServer"], (sctx) => {...})` —— 服务可用时才执行回调。这同时解决了另一个问题:没挂 `host-webserver` 的 profile(headless)里,插件不会被卡成 PENDING。 **教训**:cordis 里访问服务,要么静态 `inject`,要么运行时 `ctx.inject(deps, cb)`。**不要在 apply 里 `ctx.get` 之后 if 判空就完事** —— 那会把"服务还没来"变成"功能悄悄没有"。 ### 坑 2:纪律注入需要挑对位置 `systemPrompt.section({name, order, text})` 的 `order` 是从固定表 `SECTION_ORDERS` 里取的, `getSectionOrder('不存在的名字')` 返回 `undefined`。查表后选了 `TEAM_POLICY(600)` 之后的 `610` —— 紧挨团队政策,语义连贯。 --- ## 四、"插件取代技能"完成 ### 纪律注入实测 在真实会话里问模型"系统提示里有没有这段内容",它回答: > **有**。 > 关于『同泳道串行』的原句引用: > > **同泳道串行**是隐性约束:两个任务逻辑上无依赖,但如果派给同一个 teammate,就不能同时跑。 > > 写范围(`write_scopes`)有交集的必须串行,或者先定契约把交集消掉。看板上的虚线就是这种排队边。 措辞逐字一致 —— 说明注入生效,而且**不需要用户贴任何提示词**。 ### 技能已归档 `~/.dsh/skills/_retired/dsh-parallel-dev/`,附 README 记录:为什么退役、每份资产的去向与迁移状态、如何恢复。 **迁移状态(诚实记录)**: | 资产 | 去向 | 状态 | |---|---|---| | SKILL.md 流程与纪律 | `policy.js` 系统提示注入 | ✅ | | DAG编排.md | `graph.js` 看板计算 | ✅ | | 降级方案.md | `pdev_preflight` 拒绝话术 | ✅ | | 实施/验收 Agent 提示词 | 纪律文本里的约定 | ⚠️ 部分 | | 环境隔离.md | 待做 worktree 管理模块 | ⬜ | | assets/templates/* | 待做工具输出模板 | ⬜ | | scripts/pdev.py | 由平台 `team_task_*` 取代 | — 已淘汰 | 未迁移的是体验层补强,不影响核心机制。 --- ## 五、验证证据 | 验证项 | 方式 | 结果 | |---|---|---| | 图计算正确性 | 单测(16 任务案例形状) | ✅ 分层/关键路径/泳道边/等待带/阻塞半径全对 | | 端点可达 | curl web 实例 | ✅ `/pdev/board` 200 (12648B)、`/pdev/board/data` 200 | | 内联脚本语法 | `node --check` | ✅ | | 渲染逻辑 | DOM 桩驱动 `render()` | ✅ 10 节点、32 路径、5 关键节点、5 层带、4 等待带 | | 纪律注入 | 真实会话让模型引用 | ✅ 逐字一致 | | 静态快照 | 内联数据后 DOM 桩复跑 | ✅ 同上 | **未验证**:浏览器里的**实际视觉效果**。browser-skill 报 `no browser is currently connected`, 所以只做到"渲染逻辑产出正确结构",没做到"肉眼看过排版"。 → 已生成静态快照 `docs/board-snapshot.html` 并打开,请你看一眼排版是否可读。 --- ## 六、下一步 1. **侧栏缩略图**(设计第四节的第二级承载)—— 需要客户端 slot API,是 React 侧的工作 2. **视图 B:并发时间轴** —— 数据源已铺好:插件从 v0.2 起就订阅 `team/task` 事件记时间戳(`taskTimeline`),因为平台的 `TeamTaskView` 只有 revision 和 status、没有状态变更时刻,而时间点错过就补不回来 3. **可停靠面板**(`ui-dockkit`)承载放大版依赖图 --- ## 七、追加:侧栏缩略图(改用响应式,不写客户端插件) ### 为什么不写原生侧栏面板 设计原本要求"侧栏缩略图"作为独立承载。调研后发现原生侧栏 tab 需要客户端半边: `window.__ModuleLoader__.load({id, factory})` 形式的**浏览器 bundle**,注册到 `sidebar.right.pane.tab` 座位,还要配套 `ctx.sidebarRightTabs` 的 tab 类型定义。 参考实现(`ui-sidebar-documentpreview`)是 TypeScript + TSX 编译产物。 **放弃这条路的理由是验证条件不具备**:browser-skill 报 `no browser is currently connected`, 我无法看到任何客户端渲染结果。把**未经验证的 React 组件塞进用户每天用的侧栏**, 风险(可能弄坏侧栏)与收益(省一次点击)不成比例。 ### 改用的办法:一个响应式页面,两处承载 better-sidebar 已经能把任意 URL 开进侧栏(沙箱 iframe)。所以只要看板**自适应容器宽度**, 同一份 `/pdev/board` 就能同时充当"侧栏缩略图"和"完整依赖图": | 容器宽度 | 渲染 | 内容 | |---|---|---| | ≥ 560px | **完整依赖图** | 层带 + 芯片节点 + SVG 边(实线依赖/虚线同泳道/关键路径脊椎)+ 等待带标注 + 悬停阻塞半径 | | < 560px | **紧凑迷你图** | 每层一行、状态色小芯片、层间标注「↓ N 条边过带 · M 个在等」、完成/关键路径/阻塞 三项 KPI、「完整看板」按钮 | 宽度变化时自动切换(监听 resize)。 **好处**:零客户端代码、零构建步骤、零侧栏损坏风险,而且今天就能用。 ### 验证 | 宽度 | 结果 | |---|---| | 1200px | 完整模式:10 节点、32 条 SVG 路径、5 个关键节点 | | 400px | 紧凑模式:10 个芯片、5 层行、4 个层间连接、无 SVG | (DOM 桩驱动 `render()`,两种宽度各跑一遍。) ### 怎么在侧栏里打开 在 DSH Web 会话里让模型把看板开进侧栏(better-sidebar 的 `sidebar_open` 支持 URL), 或直接把 `/pdev/board` 贴进侧栏的浏览器标签。**前置**:better-sidebar 设置里 "模型可在侧栏打开文件/文件夹/网页"要打开。 ### 仍未做(下一轮) - **原生侧栏面板**:等 browser 连上、能可视化验证之后再考虑 - **视图 B:并发时间轴**:数据已在记录(插件订阅 `team/task` 事件写 `taskTimeline`), 只差渲染 - **可停靠面板**(`ui-dockkit`)承载放大版依赖图 --- ## 八、追加:时间线落盘(补齐目标里"先落盘状态变更历史") ### 之前的缺口 目标里写着"若时间轴需要时间戳,先落盘状态变更历史",但我最初只把时间线放在**内存 Map** 里 —— **插件一重启,历史就没了**。 这不是小事:平台的 `TeamTaskView` 只有 `revision` 和 `status`,**没有状态变更时刻**; "什么时候开始、什么时候结束、整改了多久"这些时间点一旦错过就再也补不回来。 放在内存里等于没存。 ### 做法:append-only JSONL `<项目根>/.pdev/timeline.jsonl`,每条一行: ```json {"task_id":"task-1","at":"2026-10-16T13:00:19.367Z","status":"in_progress","revision":1,"subject":"存储层","owner":"impl-store","op":null} ``` 选 append-only 是因为**崩溃安全**:写到一半最多丢最后一行,不会毁掉整段历史。 读回时按 `task_id` 分组、按时间排序;**坏行跳过而不中断**(实测塞一行 `not json at all` 进去,其余记录照常读出)。 看板端点把磁盘(持久历史)与内存(本进程最新几条)合并去重,`meta.timelineSource` 会标明来源是 `demo` 还是 `disk+memory`。 ### 验证 | 项 | 结果 | |---|---| | 事件写入后文件生成、格式正确 | ✅ | | 同一 revision 的重复事件不重复记 | ✅ 4 条事件 → 3 行 | | **模拟重启**:新进程只读磁盘 | ✅ 读回 task-1(in_progress → completed)、task-2(needs_fix) | | 损坏行容错 | ✅ 坏行跳过后仍读到全部有效记录 | | 端点三种模式 | ✅ 页面 200 / 演示数据 200(10 条)/ 真实数据 200(`disk+memory`) | ### 顺手记一个教训 这次补 import 时,我的替换字符串与实际代码不匹配(实际是 `readFile, writeFile, mkdir, readdir`,我按记忆写成了含 `rm` 的版本), **替换静默失败**,结果插件报 `appendFile is not defined`,而 `appendTimeline` 的 catch 把错误吞了 —— 表现就是"文件没生成,但毫无线索"。 两处改进:给替换加断言(已在后续操作中执行);**`appendTimeline` 的失败改为大声报错** —— 静默的持久化失败正是最该吵的那类问题,吞掉它等于给自己埋雷。 --- ## 九、首次真正的视觉验证(browser 连上之后) 之前几轮我只能验证结构、看不到画面。这次 browser-skill 连上后做了第一次**肉眼验证**,立刻发现三个只有看图才知道的问题: ### 问题 1:节点内容被裁掉 每张卡片底部那行「已完成 impl-contract r3」都缺了一半。三行内容(id ≈16px + 标题 ≈22px + meta ≈15px + 上下内边距 12px)需要约 65px,而我设的是 56px。 **修法**:`NODE_H` 56 → 68。结构测试看不出来——DOM 里那行元素是在的,只是被 `overflow:hidden` 裁了。 ### 问题 2:关键路径在层间来回横跳 层内按状态排序,导致已完成的任务被排到最右。关键路径 T0 → T1 → T7 → T8 的红线要先从左绕到最右再绕回来,看着像连错了。 **修法**分三步,每步都补了单测: 1. **重心法排序**(分层图经典做法):按父节点在上一层的平均位置排层内顺序。**单独用不管用**——本例里层 1 所有节点的父节点都是同一个 T0,重心相同,排序退化成原序。 2. 加上**反向扫描**(看子节点位置),上下各两轮。 3. **关键路径竖直对齐**:把脊椎上的节点挪到其关键父节点正下方。这一步才是真正解决问题的。 最后还漏了首层:层 0 是单节点、默认居中,而层 1 的多节点从左排,于是 T0 在 597、T1 在 1087,差 490px。补上首层对齐后: ``` 脊椎各节点中心 x: T0:1087 T1:1087 T7:1087 T8:1087 T9:1087 横向跨度: 0 px → 完全竖直 ✅ ``` ### 问题 3:并发柱顶到边框 并发区 44px 高、柱高按 100% 缩放,峰值那根正好顶满。改成 56px 高 + 按 85% 缩放。 ### 这次视觉验证的结论 **三个问题里有两个是纯视觉的、结构测试永远发现不了**(内容裁切、路径横跳)。这印证了之前的判断:没有可视化验证手段时,UI 工作只能做到"结构正确",做不到"能看"。 ### 顺带搞清楚的 browser-skill 连接方式 排查时发现连接链是:**Chrome 扩展(BrowserSkill)→ `bsk` 守护进程 → DSH 插件**。 诊断命令: ```bash bsk status # 守护进程与浏览器连接状态 bsk browsers # 列出已连接的浏览器实例 bsk doctor # 诊断 + 修复建议 ``` 两个实测结论: - `bskPath` 在 desktop profile 里**已经配好了**(`~/.local/bin/bsk`)——DSH 从 Finder 启动时 PATH 很干净(只有 `/usr/bin:/bin:/usr/sbin:/sbin`),所以必须显式配。 - **`file://` URL 被 bsk 拦**(`Navigating to local URL is not allowed`)。所以看板要用 `http://` 看,静态快照那种双击打开的文件没法用浏览器工具验证——必须走 web profile 起的 HTTP 服务。 --- ## 十、原生侧栏面板(v0.3) 之前因为"看不到画面",侧栏缩略图改用了响应式页面 + better-sidebar 的 URL tab。browser 连上、有了视觉验证能力之后,回头把原生面板做掉了。 ### 客户端半边:手写,不用构建 DSH 的客户端插件本质是个浏览器模块: ```js window.__ModuleLoader__.load({ id: "dsh-parallel-dev", factory: (require) => { const React = require("react"); // ... 用 React.createElement 代替 JSX exports.apply = apply; exports.inject = ["slots", "sidebarRightTabs"]; return module.exports; }, }); ``` 用 `createElement` 而不是 JSX,就**完全不需要打包步骤** —— 少一层工具链,少一处能出错的地方。 ### 注册契约(读源码确认的) 看板是 **page type**(不是"把文件/URL 打开成 tab"那种 viewer),所以不声明 `patterns`/`canOpen`,按 `kind` 打开,靠 `guide` 在侧栏入口列表里出现一个胶囊。两段式注册: ```js // 第一段:类型是什么 ctx.effect(() => tabs.register({ id: TAB_ID, kind: TAB_KIND, priority: "extension", title: () => "并行开发", guide: [{ id: "parallel-dev-board", order: 40, title: () => "并行开发看板", description: ... }], }), "..."); // 第二段:正文与芯片标题,key 用定义的 id ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register( { name: "sidebar.right.pane.tab", key: TAB_ID, inject: (sessionId) => ({ sessionId }) }, BoardPanel)); ctx.slots.inject("sidebar.right.pane.tab.title", () => ctx.slots.register( { name: "sidebar.right.pane.tab.title", key: TAB_ID }, BoardTitle)); ``` ### 视觉验证结果 侧栏入口列表里出现「并行开发看板」,点开后: ``` [并行开发 ×] + [分栏][全屏][收起] 示例数据(形状取自真实案例) [回到真实数据] 完成 2/10 关键路径剩 3 层 阻塞 3 [刷新] 层0·1 ┃T0 接口与数据契约┃ ↓ 6 条边过带 · 9 个在等 层1·6 ┃T1 存储层┃┃T2 领域层┃┃T3 引擎适配层┃┃T4 前端基座┃┃T5 两个模拟应用┃┃T6 部署件与 ops┃ ↓ 3 条边过带 · 3 个在等 层2·1 ┃T7 API 层┃ ↓ 3 条边过带 · 2 个在等 层3·1 ┃T8 执行器与看门狗┃ ↓ 2 条边过带 · 1 个在等 层4·1 ┃T9 跨模块集成测试┃ ────────────────────────────── 跨度 5 层 · 最宽 6 并行 悬停芯片看阻塞半径 · 完整依赖图见 /pdev/board ``` 关键路径芯片带红框,状态色分明(绿=已完成、蓝=进行中、灰=待处理),层间标注过带边数。**这就是设计文档第四节那张侧栏缩略图的实机效果。** ### 两个工程发现 **1. 客户端改动可以热加载,不用重启服务。** 客户端资源的 URL 带 `rev=`,硬刷新就从磁盘重新读取。实测:改文件 → 硬刷新 → 控制台打出新的 `rev=62e36a5c8c62` 并重新注册成功。 **2. 装错 profile 了。** 我一直在装 `web` profile,但用户日常用的 GUI(19387)跑的是 **`desktop`** profile——所以面板在我验证的实例里能用,在用户主界面里根本没有。已补装 desktop(带备份)。 ### 顺带记一个坑 验证过程中我的 agent 标签页跑到了 `http://127.0.0.1:8100/`(用户的 self-evolve-web 应用),没动它就导航回来了。原因未查明,可能与 DSH 恢复上一次的浏览器标签有关。**验证别人环境里的 UI 时,落地前先确认当前 URL。** --- ## 十一、合并成一个看板:侧栏改 iframe 承载 web 页(v0.3.1) ### 起因 之前是两个看板两套渲染代码: | 渲染代码 | 在哪 | 行数 | |---|---|---| | 完整依赖图(SVG + 贝塞尔边 + 脊椎) | 只在 web 页 | 127 | | 时间轴(并发曲线 + 泳道甘特) | 只在 web 页 | 107 | | 紧凑模式(层行 + 芯片 + 层间计数) | **两处都有** | 211 / 105 | 重复的只是紧凑模式,但**侧栏拿不到完整依赖图和时间轴**——那是看板真正的价值所在。 ### 方案:不再"复刻",直接复用 侧栏面板改成 iframe,承载 `/pdev/board?embed=1`。 前提是三条已核实的事实: 1. **应用没有设 CSP**(只有预览 HTML 时用沙箱 iframe,见 `basic-document.ts`),插件面板里可以嵌 iframe 2. 侧栏有**分栏 / 全屏**两档 3. web 页本来就是**响应式**的 于是侧栏宽度天然决定渲染档位,不需要额外逻辑。 ### `?embed=1` 做了什么 被侧栏承载时,宿主已经显示了 tab 芯片「并行开发」,页面再画一遍大标题是重复;外边距也要收掉,否则嵌进去像套了两层壳。 ``` body.embed header h1 → display:none (去掉重复标题) body.embed main → padding:8px 10px 14px (收掉外边距) body.embed .legend → 更紧凑 ``` **KPI、视图切换、刷新按钮全部保留**——那些是有用的,去掉反而丢功能。 ### 效果 | 侧栏状态 | 宽度 | 渲染 | |---|---|---| | 分栏 | ~420px | 紧凑迷你图(层行 + 芯片 + 层间计数) | | 全屏 | ~1200px+ | **完整依赖图 + 时间轴**(原来侧栏看不到的) | ### 代码量 客户端半边 **253 → 103 行**(删掉 105 行独立渲染 + 状态色表 + 悬停逻辑,换成约 30 行的 iframe 外壳)。 渲染代码从此只有一份,改一次两处都变。 ### 验证 | 项 | 方式 | 结果 | |---|---|---| | 嵌入识别与样式 | 取页面 + 字符串核对 | ✅ `EMBED=true`、`body.embed` 已加、三条样式规则都在 | | 宽度决定渲染 | DOM 桩驱动 `render()` | ✅ 1200px → 10 节点;420px → 10 芯片 | | 客户端语法 | `new Function` 解析 | ✅ | | **iframe 在真实侧栏里的样子** | 浏览器实测 | ❌ **未验证**(见下) | ### 为什么没做视觉验证 用户的浏览器正在被他自己使用。我的 agent 标签页两次被外部导航带走(先是 `127.0.0.1:8100` 的上线审批页,再是 `127.0.0.1:8899` 的 Office 预览原型),说明**多个会话共用同一个浏览器**。 继续抢标签页会打断用户正在做的事,所以我停手了,改为请用户自查。 **待确认风险**:侧栏给组件的容器如果没有确定高度,iframe 可能塌成 0 高。代码里用绝对定位铺满 + `minHeight:320px` 兜底,但只有真机能确认。 --- ## 十二、看板主题:浅色为默认 + 支持明暗切换 ### 问题 看板页原本是写死的暗色配色。嵌进侧栏后,DSH 应用是浅色的,**深色看板嵌在浅色应用里像贴了块黑补丁**。 ### 做法 调色板改成三块,全部走 CSS 变量: | 选择器 | 含义 | |---|---| | `:root` | **浅色(默认)** | | `:root[data-theme="dark"]` | 显式暗色 | | `@media (prefers-color-scheme: dark)` + `:root:not([data-theme])` | auto 模式跟随系统 | 优先级:`?theme=`(侧栏把宿主主题传进来)> localStorage 记忆 > 跟随系统。 **不加 `data-theme` 属性 = 跟随系统**(媒体查询接管),加了就是显式选择。 侧栏这一侧读宿主主题:DSH 在 `` 上挂 `data-ds-dark-theme` 表示暗色(见 ui-theme 的 `boot-theme.ts`), 客户端把它拼成 `&theme=dark|light` 传给 iframe,并用 `MutationObserver` 监听——**你在应用里切明暗,看板跟着换**。 嵌入模式下**隐藏页面内的切换按钮**:那时候主题归宿主管,页面里再放一个开关只会打架。 独立打开 `/pdev/board` 时才有那个按钮(☾/☀),选择记在 localStorage。 ### 顺带清理 把散落在 CSS 和 JS 里的 20 处硬编码颜色全部收进变量。 其中 `STATUS_COLOR` 早就是 `var(--)` 了,这次补上 `LANE_COLORS`(改成一组成员 `--lane-0..9`, 浅色和暗色各一组,避免在浅底上看不清)和边线颜色。 **过程中自己引入并修掉一个 bug**:全局替换把调色板**定义内部**的 `rgba(0,0,0,.35)` 也换成了 `var(--hatch)`,变成自引用 `--hatch:var(--hatch)` —— 一条无效定义。加断言复查变量定义时发现并修正。 教训:**替换颜色值这类"到处都出现"的字符串时,要避开它自己的定义处**。 --- ## 十三、给浏览器自动化开独立 profile ### 问题 `bsk` 驱动的是用户日常那个 Chrome。实测中我的 agent 标签页被外部导航走了两次, UI 验证做不下去;而且 agent 能碰到用户所有网站的登录态。 ### 交付 `scripts/bsk-auto-browser.sh`(+ `scripts/README.md`,含 Windows 段): - 从已安装的 Chrome 扩展目录**复制**一份扩展到 `~/.dsh/bsk-extension` (不直接依赖原目录——Chrome 会自动更新/清理版本目录) - 用独立 `--user-data-dir=~/.dsh/bsk-chrome` 启动第二个 Chrome - 自动探测该 profile 里扩展装没装,装了就正常启动,没装就打开扩展页并打印逐步指引 - `--status` 查看 bsk 认到的所有浏览器实例 ### 一个走不通的路(记录以免重走) 原计划用 `--load-extension` 自动加载扩展。**Chrome 155 已经彻底禁用这条路**: - `--load-extension` 不再生效 - 历史上用于绕过的 `--disable-features=DisableLoadExtensionCommandLineSwitch` **实测也失效了** - 失败时 **Chrome 不报任何错**——表现为"浏览器起来了,但 bsk 看不见第二个实例", 得去翻 profile 的 `Preferences` 才发现 `extensions.settings` 是空的 所以首次安装扩展必须人工(开发者模式 → 加载已解压的扩展程序 → 选 `~/.dsh/bsk-extension`)。 **只需一次**,之后脚本自动跳过引导。 ### 验证状态 | 项 | 结果 | |---|---| | 脚本语法、扩展复制、profile 创建 | ✅ | | 扩展安装探测逻辑 | ✅(用伪造的 Preferences 验过"已装/未装"两种判定) | | 独立 Chrome 能启动 | ✅(实测起得来,日志正常) | | **扩展装好后能否作为第二实例连上 bsk** | ❌ **待验证** —— 需要人工装扩展这一步先完成 | --- ## 十四、侧栏面板首次完整视觉验证(在独立浏览器里做的) 有了隔离的自动化浏览器之后,终于能完整验一遍,不用再抢用户的标签页。 ### 验证清单 | 项 | 方式 | 结果 | |---|---|---| | iframe 在真实侧栏里渲染 | 可访问性树 | ✅ 树里能看到 iframe 内部结构与文本 | | **侧栏高度塌陷风险** | 截图 | ✅ **没有塌**(这是上次唯一没验的怀疑点) | | 主题与应用一致 | 像素采样 | ✅ 面板 `rgb(240,241,243)` = 浅色 `--panel`,正文纯白,与应用主区一致 | | 全屏档 | 点击 + 观察 | ✅ 面板铺满宽度,页面切到宽屏渲染 | | 分栏档宽度 | 量算 | ✅ **657 CSS px,已超过 560px 断点** —— 默认就能看完整依赖图 | | 暗色主题(嵌入模式) | 像素采样 | ✅ 页头 `rgb(21,25,34)`=`#151a22`、层带 `rgb(19,23,32)`=`--band-bg` | | 视图切换(依赖图/时间轴) | 点击 iframe 内按钮 | ✅ 内容随之切换,交互完整可用 | 额外发现:**iframe 内部的 DOM 对自动化是可访问的**,所以以后能直接点侧栏面板里的按钮做端到端测试。 ### 这轮修掉的两个真 bug **1. 嵌入模式背景色(浏览器实测发现)** ```css body.embed { background: transparent; } /* 旧 */ body.embed { background: var(--bg); } /* 新 */ ``` 当初设成 `transparent` 是想让侧栏底色透上来。浅色下看不出问题(白 ≈ 透明), **暗色下就露馅**:层带和卡片按暗色调色板变暗,页面却还是白的,看着像半成品。 **2. 服务端页面是旧版(排查过程中发现)** 改完主题后看板页没变,一度以为主题代码写错了。实际是 **`BOARD_HTML` 在插件加载时就烘进内存**, 不重启服务就不会更新。这个"改了没生效"的表现和"改错了"一模一样,很容易误判。 **记下来:改 board-page.js 之后必须重启 profile。** ### 顺带纠正一个我自己的误判 暗色主题那轮,我看缩略图觉得"页头还是白的",差点又去改 CSS。 用**像素采样**一测是 `rgb(21,25,34)`,完全是暗的——**是我这边看图不可靠,不是代码有问题**。 教训:**配色这类判断,别靠看缩略图,直接采样像素。** --- ## 十五、侧栏面板自动打开(v0.4) ### 效果 起一个并行开发任务 → **侧栏自己弹出看板**,任务一建好就能看到分层。 不用再去侧栏入口列表点「并行开发看板」。 端到端实测(真实数据,非演示): ``` 0/3 完成 2 层 关键路径还剩 2 层 / 最宽 2 结构 2 被阻塞 层0·1 并行 ┃task-1 接口契约┃ (关键·待处理·未分配·r1) ↓ 2 条边过带 · 2 个任务在等 层1·2 并行 ┃task-2 存储层┃ (关键) ┃task-3 领域层┃ ``` ### 触发时机是这里最关键的设计 **不用"会话一开始就开"**:绝大多数会话不搞并行开发,每次都弹一个空看板就是骚扰。 改成**第一个团队任务出现时才开**——有任务才有内容可看,这时候开叫"正好需要"。 实现:客户端每 15 秒轻量轮询 `/pdev/board/data`,看 `summary.total > 0`; 开过一次就停轮询,所以不会反复弹。 ### 怎么退订:关掉就是不要 **不需要设置界面。** 自动打开后 60 秒内被关掉 → 记一条持久标记,以后不再自动开。 超过 60 秒才关,多半只是"看完了",不算拒绝。 (想恢复:清掉 localStorage 的 `pdev-auto-open-off`。) ### API ```js ctx.sidebarRight.openTab("parallel-dev-board") // 导航面 ctx.sidebarRight.openTabs // ObservableSnapshot:getSnapshot() + subscribe() ``` 客户端 `inject` 从 `["slots", "sidebarRightTabs"]` 扩到加上 `"sidebarRight"`。 ### 真机测试抓到一个单测漏掉的 bug 控制台打出**两条**「检测到团队任务,已自动打开看板」——`fire()` 里忘了停掉轮询, 于是每 15 秒又开一次。 单测没抓到,因为我只等了 50ms(一个 tick 都没跨过)。补了一个**跨 5 个检查点**的长跑测试才确认修好。 **教训:定时器类的逻辑,单测必须跨多个周期跑,只测一次调用等于没测。** ### 另一处设计修正(也是真机发现的) 第一版这样写: ```js if (boardTabOpen(ctx)) { stop(); return; } // 自以为"已经开着就不用管" ``` 结果**一次都没开成**。因为 `openTabs` 的语义是 **"跨已保存和已采纳会话的 tab 元数据"** —— 它包含别的会话、甚至已保存布局里的旧记录, 于是被误判成"已经开着"就停了。 修法:去掉这个提前停止。**真正要防的是重复打开,那由"本次会话只开一次"负责。** ### 验证 | 场景 | 期望 | 结果 | |---|---|---| | 无任务 | 不开 | ✅ | | 有任务 | 开一次 | ✅ | | 用户已退订 | 不开 | ✅ | | 面板已在别处开着 | 开(openTab 幂等) | ✅ | | 本次会话已开过 | 不再开 | ✅ | | 端点挂了 | 不崩 | ✅ | | **跨 5 个轮询周期** | **只开一次** | ✅ | | 关掉即退订 | 记持久标记 | ✅ | | **真机端到端** | 建任务后看板自动弹出并渲染真数据 | ✅ | --- ## 十六、自动打开的触发条件优化(v0.4.1) ### 改动 | | 条件 | 行为 | |---|---|---| | 旧 | `total > 0` | 只要有任务就开 | | **新** | `unfinished > 0` | **整个 DAG 完成就不开**;还有没做完的才开 | 理由:全部完成时看板上没有"正在发生的事",弹出来只是噪音。 真正需要看板的时刻是**还有任务在跑、要看卡在哪**。 ### `unfinished` 的口径 服务端算好给客户端(`graph.js` 的 `computeSummary`): ```js unfinished: alive.length - counts.completed ``` `alive` = 非 `deleted` 的任务。所以它涵盖 `pending / in_progress / review / needs_fix` —— 这些都算"没做完",都会触发打开。 实测各口径: | 任务状态 | total | completed | unfinished | 自动打开 | |---|---|---|---|---| | 全 pending | 2 | 0 | 2 | 是 | | 全 completed | 2 | 2 | 0 | **否** | | 半完成 | 2 | 1 | 1 | 是 | | 待验收(review) | 2 | 1 | 1 | 是 | | 需整改(needs_fix) | 2 | 1 | 1 | 是 | | 完成的 + 一个已删除 | 1 | 1 | 0 | **否** | | 空板 | 0 | 0 | 0 | 否 | ### 加了退化计算(防静默失效) 客户端**能热更新**,但服务端要重启才生效 —— 这两半版本不一致是常态(这个坑本项目已经踩过一次: `BOARD_HTML` 烘在内存里,改了页面不重启就是旧版)。 所以客户端不硬依赖新字段: ```js const unfinished = s.unfinished ?? ((s.total || 0) - (s.completed || 0)); ``` 拿不到就自己算,功能不会因为服务端没重启而悄悄失效。 ### 验证 | 项 | 结果 | |---|---| | `unfinished` 口径(7 种状态组合) | ✅ 全对 | | 客户端判断(7 个场景,含退化路径) | ✅ 全对 | | **活体端点**:`?demo=1` 返回 `unfinished: 8` | ✅ | | 真实端点(空板)返回 `unfinished: 0` | ✅ | --- ## 十七、按会话隔离看板(v0.5) ### 问题 多个会话同时并行开发时,**各自的侧栏显示的是同一块看板**,而且不确定是哪一个。 三处叠加导致: 1. 侧栏面板**拿到了 `sessionId` 却没用**(`BoardPanel()` 直接丢掉了参数) 2. iframe 地址写死 `/pdev/board?embed=1&theme=...`,**不带会话标识** 3. 服务端 `currentTeamTasks` 返回**全局第一个**任务板非空的 agent —— 顺序不保证稳定 后果:A、B 两个会话的侧栏都在展示被先遍历到的那个团队,**刷新之间还可能跳**。 ### 修法 | 位置 | 改动 | |---|---| | 客户端 | `BoardPanel({ sessionId })` → iframe 加 `&session=`(URL 编码) | | 客户端 | 自动打开改为盯 **`ctx.sidebarRight.mounted`**(屏幕上那个会话),不知道是谁就不弹 | | 服务端 | `currentTeamTasks(ctx, { sessionId })` → 按会话精确定位 | | 服务端 | 端点读 `?session=`;`pdev_board` 用 `exec` 定位调用方会话并在地址里带上 | | 兼容 | 不传 `session` → 保持旧行为(独立看板页、旧客户端不受影响) | ### 一个差点埋雷的细节 `Agent.id` 本身就是 `SessionId`(见 `core/agent/src/types.ts`: ```ts export interface Agent { /** Session-backed Agent identity. */ readonly id: SessionId } ``` 而运行时还有 `agent.session.id`。**只比对其中一个,一旦它们不是同一个字段,表现就是"侧栏永远是空的"** —— 静默失效,极难排查。 所以查找时两个都比对: ```js const agent = agents.find((a) => a?.id === opts.sessionId || a?.session?.id === opts.sessionId); ``` 单测覆盖了三种身份变体(只有 `id` 对得上 / 只有 `session.id` 对得上 / 两者都是)。 ### 验证 **单元测试** | 项 | 结果 | |---|---| | 两个假团队,各自 `task-1`(真实情况就是会重名) | ✅ 各看各的,cwd 也跟着走 | | 未知会话 | ✅ 返回空,**不 fallback 到别人的团队** | | 三种身份字段变体 | ✅ 都能匹配 | | 面板 URL 带 session / 保留 embed+theme / 特殊字符编码 | ✅ | | 无 sessionId 时不硬塞空参数 | ✅ | | 自动打开:无屏幕会话 / 有未完成 / 全完成 / 无任务 | ✅ 四个场景全对 | **真实数据端到端**(在临时 web 实例里唤醒一个真会话,它有 3 个任务的团队板) | 查询 | 期望 | 结果 | |---|---|---| | `?session=<持有者>` | 3 个任务 | ✅ `task-1/2/3` | | `?session=<另一个会话>` | **0**(不泄漏) | ✅ 0 | | 不传 session | 3 个任务(旧行为) | ✅ | | `?session=<不存在>` | 0 | ✅ | ### 未验证的一项 **侧栏 iframe 的 src 里是否真的带上了 `&session=`** —— 需要真机看 DOM。 尝试时自动化浏览器的标签页被外部导航走了两次(先是「上线审批」,再是「智能问数」), 没能在中断前取到。 风险评估:**低**。客户端构造 URL 的那段有单测覆盖,而且 `embed` / `theme` 走的是同一条代码路径 (那两个参数真机验过)。但严格来说这一环是"测过代码、没测过现场"。 **重启后建议顺手确认**:打开侧栏面板,看设备上任务对不对得上当前会话。 ### 状态 - 两个 profile 都已同步(desktop host 04:11:34 / client 03:36:11) - 运行中的应用仍是 00:12 加载的旧版 —— **host 半边要重启才生效** - 客户端半边会随页面刷新热更新,但**新客户端 + 旧 host** 时 `session` 参数被忽略, 退回旧行为(不会更糟,也不会变好)