dsh-client-ui-branch-map
分支图 / Branch Map — 把当前工作区的对话画成一张图:每条消息是一个节点,每个 Session 是节点上的一个分支引用,可以像 git 分支一样从任意节点切出去。
它是从 dsh-client-ui-canvas 0.1.0 演进出来的第二版。原来看起来像“画布”,但这个视图里没有自由摆放的卡片,也没有缩放平移:它是一张对话分支图。所以 Tab 和包名都改成 Branch Map / 分支图。
0.3.0 是当前版本:归档的对话不进这张图。归档是列表把一条对话收起来的方式,所以归档 Session 的引用、以及只由它引入的节点都不画;工具栏会把隐藏了几条报出来(已隐藏 N 条归档)。唯一的例外是你正在看的那条会话:它被归档时仍然画出来,否则这个 Tab 会在一屏有内容的对话旁边说“还没有消息”。0.2.2 的行为(不带子代理功能、根节点放在树的中线上、0.2.0 那套子代理节点 / merge 线已移除)原样保留。
界面
▣ 工作区 · E:\ai-agent-workspace\dsh\workspace
┌───────────────┴───────────────┐
● 第 1 轮 ● 另开一条
│ │
● 第 2 轮 ● 第 2 轮
⟨主干⟩ ⟨另一条⟩
│
● 第 3 轮
根节点在树中水平居中:根级会话向左右两侧展开,新增会话不会把所有根线堆在左边。
四个基本元素:
| 作用域 | 恰好一个 Workspace |
| 根节点 | 一个,代表 Workspace 本身 |
| 消息节点 | 用户发出的一条消息(一轮对话) |
| 边 | 节点 → 在该会话里紧随其后的下一条消息 |
| Session | 不是节点,而是指向某个节点的引用,和 git 分支指向 commit 一样 |
一个 Session 的引用停在它自己拥有的最后一条消息上;还没有自己消息的 Session 指向它被切出来的那个节点。两个 Session 停在同一个节点,读起来就像两个 git 分支指向同一个 commit。
行按轮号上升排列。轮号小的一定在前,所以这始终是一个合法的拓扑序;兄弟分支落在同一行带,分叉就读成两条并排的 lane。
图里刻意不画什么
子代理运行。 子代理是代理自己派出去的工作,不是用户与它的交互;子代理 Session、以及从子代理再分出来的 Session,都不进入图,也不占节点。过滤按血缘做:isOwnConversation 一路走到根,只要祖先是 origin: "subagent" 就整条排除。
归档的对话。 归档集由宿主单独给出:useWorkspaces 快照的 archivedSessionIds。归档的 Session 仍然是它工作区的成员(workspace.sessionIds 里还在),所以不能靠成员表过滤,必须显式减掉。减掉的是它的引用,以及只由它引入的节点;如果一条未归档分支是从归档分支 fork 出来的,共享的前缀本来就复制在未归档分支的 outline 里,仍然按同一个节点画出来——那是这条活分支自己的历史。
当前打开的那条会话是例外。 它被归档时照样画。你正在里面读对话,Tab 却说“这个工作区还没有消息”,那才是错的。工具栏的 已隐藏 N 条归档 数的就是被减掉的其它分支。
存储细节也不画。 没有事件序号、没有 Session id、没有加载/刷新控件、没有子代理计数。工具栏只说用户拥有的东西:多少条消息、多少条分支;操作就是 git 那几个。
一行里有什么
● T2 [1 条子分支] ······ ⟨trunk⟩ ⟨fork-2⟩ [+分支] [改写]
↑ ↑ ↑
轮号 哪些会话正指向这个节点 操作
一行不显示对话正文。把问题和回复直接印在行里会吃掉整行宽度,把操作挤出右边缘,反而让这张图唯一的目的变得不可达。
- 右侧是哪些会话正指向这个节点。一个会话只指向一个节点,所以这里的 chip 表示“这条线目前停在这里”。没人指向的节点什么也不显示,这本身就是“所有线都已经越过它”的信号。chip 就是控件:点它打开那条分支现在的位置。你正在的分支是惰性标签。
- 悬停显示用户自己的问题,只显示问题。回复不是节点,所以不会出现在图上;点开检查器才同时看到问答两侧。
标签按内容自适应宽度、从不换行。轮号 chip 不设固定宽度,第 10 轮 不会因为多一个字符就折行;每个紧凑按钮都是 white-space: nowrap。浮动卡片宽 min(360px, calc(100% - 32px)),窄列也不会被推出屏幕。行高不写死在样式表里:模型提供行高,SVG gutter 和 DOM 永远不会对不上。
操作
| 控件 | 效果 |
|---|---|
| +分支 on a message | ctx.sessions.fork({ sessionId, atSeq }) — 打开一个新 Session,带上从根到这条消息的整条路径,然后切过去。始终显示,不藏在 hover 里 |
| 改写 on a message | 在消息之前分叉,然后把改写后的文本作为新分支的第一条 prompt 发出去;原分支不受影响。轮次起点在日志第一条事件时没有“之前”可切,这个按钮不出现(从根开新分支即可) |
| a ref chip on a node | 点它打开那条会话现在所在的位置(它的 tip)。这和“从这个节点开新分支”是两个不同的问题,标签也分开写 |
| 从根开新分支 on the root | 一个从工作区根开始的空 Session |
两件容易混淆的事,界面刻意分开:
- 打开分支去它的 tip,因为 ref 记录的是这条线走到了哪里;不会把你送到中间的某个节点。
- 从节点开分支才是“站在这个节点上”:新 Session 的上下文是从根到它的路径,下一句话从这里继续。
atSeq 是包含式的事件切点。一轮自己的 turn/start seq 是包含的,所以下一轮 turn/start 之前的那条事件就是这一轮的末尾——因此切点不会落在轮中间,新 Session 继承节点的所有祖先、不会多带一点。节点已经是分支 tip 时没有下一轮可切,atSeq 省略,宿主用自己的“最近完成边界”。
数据与行为边界
这个插件不读私有数据、不改对话、不联网、不落盘。
| 面 | 实际做了什么 |
|---|---|
| 读 | 只用 conversation.view 的标准客户端 props:useSessions、useWorkspaces(含它的 archivedSessionIds 归档集)、useProjection("turnOutline");以及 ctx.sessions.refreshProjections(id)(宿主语义:只读投影,不启动迁移) |
| 写 | 视图自身不写任何东西。 +分支 / 改写 是用户点击后才调用的 ctx.sessions.fork(...),和 DSH 自带的分支按钮同一语义;不点就什么也不发生 |
| 会话历史 | 从不 retain 其它 Session、从不打开别人的历史;不读 useChat,不读子代理目录 |
| 存储 | 无。没有 localStorage / sessionStorage / IndexedDB / cookie / 插件配置文件 |
| 文件 | 插件自身不读写任何路径。宿主可能按自己的投影缓存策略缓存派生值,但不会改会话日志 |
| 网络 | 无。没有 fetch、WebSocket、遥测或外部服务 |
| 宿主半边 | lib/index.js 的 apply 是空的。不注册服务、投影、路由或工具 |
| 停用 / 卸载 | Tab 消失;样式表通过 effect 清理,locale 注册随 fiber 卸载;没有需要清理的插件数据 |
| 迁移 | refreshProjections 不启动日志迁移;视图也不打开别的会话,所以不会触发格式迁移写盘 |
唯一“碰数据”的地方是用户主动点分支产生的 Session,以及 openSession 的界面导航——这两件事和 DSH 自带的同名操作完全一样。
安装
这个包本身就是一个 DSH bundle:声明了 dsh.bundle.patch,自带的 cordis.patch.yml 按包名插入一行,不写任何绝对路径。
# cordis.patch.yml,随包发布
- insert:
- id: ui-branch-map
name: dsh-client-ui-branch-map
从 GitHub 安装(仓库自带 lib/,安装时不需要构建):
dsh plugin --profile desktop add github:AnichHaveatry/dsh-client-ui-branch-map
从 tarball 安装:
dsh plugin --profile desktop add .\dsh-client-ui-branch-map-0.3.0.tgz
从目录安装(开发时用,link: 是活链接):
dsh plugin --profile desktop add C:\path\to\dsh-client-ui-branch-map
装好之后重启 DSH并刷新页面。两个缓存要到重启才失效:
dsh-client-modules会按 Loader specifier 缓存每个包的 client bundle 路径;- bundle 字节在激活时快照,改
lib/client.js不会热更新。
pnpm remove一个link:依赖会顺着 junction 清空源目录。删之前先把源码拷走,或者用 tarball 安装(它是拷贝,没有这个风险)。
兼容性:peerDependencies 声明 @deepseek-ai/dsh: ^0.2.0-rc.2。不兼容的 DSH 会跳过这个 bundle 并点名,而不是挂上一个坏掉的视图。pnpm 安装时出现 peer 依赖警告是预期的:peer 由 DSH 安装提供,不在 profile 里。
这个包和 dsh-client-ui-canvas 是两个独立包、两个独立 Tab(canvas 与 branch-map),可以并存;想只留一个就卸掉另一个。
限制
- 消息 = 轮次。
turnOutline每轮一条,所以轮次中间的 steering 消息不会单独成节点。 - 分支的消息需要投影加载。 视图会自动、限流、每个 Session 只请求一次
refreshProjections;从没有行显示的空白分支也会被请求,不会因为“还没节点”而永远不加载。 - 没有普通会话之间的 merge。
parentSession只有一个 id,血缘是森林;两条例行分支无法合并。 - 不能原地回退。
SessionStore没有 truncate/rewind,存储是 append-only;“回去重来”表达为开新分支。 - 子代理不在图上。 子代理是代理的私有执行记录;想看它的执行去 DSH 自己的子代理会话。
- 归档的对话不在图上。 想让它回来就取消归档;当前打开的那条会话是唯一的例外。
- 最多画 4000 行。
开发
node test/branch-map.test.mjs
node test/archived.test.mjs
两份测试都把 lib/client.js 的 factory 取出来,用 stub 的 react / react/jsx-runtime 跑,不需要浏览器。
test/branch-map.test.mjs 是 0.2.x 留下的完整模型测试:子代理整条血缘排除、fork 复制的前缀去重成一个节点、行序与 lane、turnOutline 两种取值形状、投影尚未加载时不能显示空状态、渲染冒烟、根节点居中。
test/archived.test.mjs 是 0.3.0 新增的归档规则:
- 归档集解析(
archivedSessionIds:缺省 / 非数组 / 非字符串混入); - 归档会话的 ref 消失,它独占的消息节点也消失;
- 当前打开的会话即使归档也保留(含它自己的消息);
- 未归档分支从归档根继承的共享前缀仍然画出,并落在活分支的 lane 上;
- 整个工作区只剩归档会话时给专属空态,而不是“还没有消息”;
scopeOf/resolveScope与子代理过滤保持一致。
lib/client.js 是手写的 Lazy-CJS bundle,没有构建步骤。它只 require 壳提供的 seed 词:react、react/jsx-runtime。打包:
npm pack
# → dsh-client-ui-branch-map-0.3.0.tgz
维护者笔记
节点身份要看血缘。 DSH fork 时复制源会话的事件前缀(events.slice(0, boundary + 1)),所以一条共享消息在每个继承了它的 Session 里都是同 seq 的一份事件。节点 key 因此要沿父链走:某一轮的 turn/start seq 与父会话同轮号的 seq 相同,就复用父会话的 key。没有这一步,fork 点会变成两个节点,“Session 是引用”就不成立。
turnOutline 的客户端值是 wire view,不是 state。 单子声明了 wire: { view: state => state.turns },所以客户端拿到的是 {turn, seq, prompt, response} 的裸数组,不是 {turns, draft}。读 .turns 会得到 undefined,整张图只剩根。这里两种形状都收,并丢掉不能落节点的条目。
分支加载要用 scope,不是用行。 没有节点显示的分支恰恰是最需要请求投影的那条;如果只遍历 model.rows 里的 owner,它会永远等不到 outline。模型因此额外返回 requestIds(作用域内所有 Session),effect 逐个请求,requested Set 保证每个 id 只请求一次。
归档是减法,不是成员表。 归档的 Session 仍然躺在 workspace.sessionIds 里,归档集只出现在 useWorkspaces 快照的 archivedSessionIds 上,所以过滤必须显式减(archivedOf + resolveScope),不能把成员表当可见集。两个配套细节:当前会话要从归档里补回来,否则打开一条归档对话时这个 Tab 会说“没有消息”;共享前缀节点的 owner 可能已经不在 scope 里,lane: laneOf.get(owner) ?? laneOf.get(id) ?? 0 的第二次回退就是给这种节点的,少了它它们会全叠到 lane 0。
根节点不是 lane 0。 它是整棵树的根,x 取 lane 跨度的中点。把根固定在 lane 0 会让每新增一个根级会话,所有根线都往右堆,而根节点仍留在最左;居中之后根线向左右两侧展开。
Conversation host 只有一个共享滚动容器。 所有视图都渲染在 .scrollBody{overflow-y:auto} 里,这个容器还要放 composer,所以它总是比内容多出 composer 的高度,并且会记住上一个 Tab 的 scrollTop。这个视图自己管滚动,所以在绘制前把宿主滚动容器归零,并用 padding-bottom: var(--dsh-composer-height, 152px) 在自己的滚动区里留出 composer 的空间。
License
MIT
No comments yet. Be the first to write one.