DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

AnichHaveatry /

AnichHaveatry/dsh-client-ui-branch-map

Verified

DSH 会话视图「分支图」:把当前工作区的对话画成一张图——每条消息是一个节点,每个 Session 是节点上的分支引用,可以像 git 分支一样从任意节点切出去;归档的对话不进图。 DeepSeek Harness conversation view: the current workspace as a branch map, with Sessions as branch refs you can fork from.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@a4dc151a

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

—/ 5

No ratings yet

Verified DSH bundle

Commit a4dc151ada11

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout