Skip to content
dsh.fish
Bundle

dsh-fold-it-up

Force every finished turn's work process to fold in the DSH Web chat: one summary line per turn, expandable, historical turns included.

Source
gurio-wine
License
MIT
Updated
Updated 20 hours ago

Readme

# dsh-fold-it-up

**强制的整轮折叠**:一轮工作结束(或已经结束过的旧轮次)之后,它的工作过程收成一行,答案照常显示;点这一行即可展开、再点收起。回答结束的那一刻,**视图会自动落到这一轮的提问上**——长回答不再需要自己往上翻。

装进 DSH 的 Web 界面后,**打开任意会话——包括你在这个插件存在之前就聊完的历史会话,以及点「加载更早」取回来的那些页——每一轮都已经折好了**。

```
▸ 10 次工具调用 · 3 条消息
  已上传工坊,发布版本 1.0.8。
  ...
```

(那一行就是 DSH 自己的折叠行,文案、字体、主题色、间距全部来自产品本身。)

---

## 为什么需要它

DSH 自带「轮次过程折叠」,但它只在**一整套前置条件同时成立**时才折叠。用户能撞上的那一条是长会话:

```
processWindowReady = 会话是紧凑视图
                   && 该轮已有定稿答案
                   && 该轮已关闭
                   && !historyIncomplete      ← 这一条
```

`historyIncomplete` 来自会话窗口是否还有更早的历史(界面上的「加载更早」)。DSH 首屏只取 **50 条消息**(`PAGE_MESSAGES`),所以只要一次会话超过两三个来回,这个条件就永久为真:**该折叠的一轮也不折,历史轮次也不折**,界面上看就是「折叠功能时灵时不灵」。

本插件接管了那一格的渲染(`conversation.chat.node` 的 `turn-process`),自己判断折叠:

```
折 ⟺ 该轮已关闭 且 这一轮有过程行
```

没有历史完整性开关,也没有「模型是否给出了总结」这一层判断。

## 判定读的是渲染结果,不是 store

这是本插件最要紧的一条设计,也是它修掉的第二个 bug。

**Chat store 的快照和屏幕上真实渲染的行会不一致。** 点「加载更早」时,React 把新的一页分几批提交进 DOM,于是有一小段时间:**DOM 里已经有这些行,而当前 store 快照还没有描述它们**。如果折叠范围是按 store 算、再往 DOM 上贴,那么「store 不认识的行」就会被算成不该隐藏,结果是——

> 实测:某一轮的过程行在一次 pass 之后从 94 行涨到 116 行,那次 pass 只折了 18 行,**剩下 102 行留在屏幕上**。

所以现在:

- **行集合与顺序来自座位元素本身**(`ChatNodeSeat` 给每个渲染节点写的 `data-chat-turn` / `data-chat-flow-kind` / `data-chat-anchor-key`);
- **哪些行属于过程范围,用座位自己写的 `data-turn-process-member`**——那是官方投影的产物,不需要重新推导,也不可能和屏幕上的东西矛盾;
- 官方标记缺席时(座位因自己的原因拒绝了折叠,例如该轮还在跑),退回读折叠行上发布的 `processStartSeq` / `answerAnchorSeq` 序列区间;
- **store 只提供每个 key 的内容**(文本块、step),行集合和顺序一概不听它的。

判定和写入在**同一次 pass** 里完成,每次都是重新读一遍当前的列。

## 上下文注入行:它们不属于任何一轮

第三个 bug 是用户报的「上下文注入类还留在外面不被折叠」。

**注入的上下文(`上下文注入 @deepseek-ai/dsh-system-prompt`、`上下文注入 skill-catalog`、graph-memory 的回忆块……)在自己的座位上是没有轮次号的。** 它们的 `user/message` 事件没有 `turn` 字段,于是 Location 是 `unresolved`,座位渲染出 `data-chat-turn="null"`——它们**不属于任何分组**。所以它们不是被「排除」在折叠之外,而是**从来没有进入过任何一次判定**。

两个事实叠加才让它一直看得见:

1. 官方那份「与过程无关的 kind」清单里**没有** `context`(`system-prompt` / `user` / `steering` / `turn-process` / `turn-error` / `turn-max-tokens` / `turn-tail` 才有)——也就是说官方几何**本来就会**在范围里隐藏它们;
2. 但官方给行打过程标记的那一刻,需要 `processWindowReady`,而那个开关要求 `!historyIncomplete`。长会话里它恒为假,于是这些行连标记都拿不到。

于是本插件的做法是:**按座位在 DOM 里的位置,把无轮次的 `context` 行归给紧跟在它下面的那一轮**(也就是它被注入进去的那一轮);尾部没有后续轮次时归给上面那一轮。然后

- 有官方标记时:这些行按成员对待,和同一轮的其它过程行一起折;
- 官方标记缺席时:退回序列区间判定,`context` 行不以序列号参与,而是以「它被归给了这一轮」参与。

归给哪一轮是可读的:pass 会给每一行写 `data-folditup-turn`,上下文行的归属就写在它自己的 DOM 上。这也让「这一行到底属于谁」可以被外部断言,而不必重新推导一遍规则。

顺带一个**边界**:一行只在它位于该轮答案之前时才会被隐藏,所以「上一轮答案之后、下一轮用户消息之前」的注入行跟着**上一轮**折(它属于上一轮),而不会被算进下一轮。

## 正在思考的那一轮会把自己折起来

第四个 bug 是用户报的「处于思考时,过程就会被折叠,直到进入到下一步」。

**根因是「已定稿答案」这个判据在轮次运行中并不成立。** 官方 `latestAnswer`(`turn-process.ts`)只接受**最后一步**的 assistant 节点、且该节点已带 `finalNode`;但运行中「最后一步」正是**正在流式输出的那一步**,它还没定稿,于是官方投影仍然把**上一步**的定稿回答当作边界发布出去——`answerAnchorSeq` 指向的是一条**在模型正在写的那一行之上**的行。于是:

1. 过程标记非空、`answerFor` 能解析、区间比较全部成立 → 折叠条件成立;
2. 折叠行出现,把过程折掉,而**仍在增长的那一行**被当成答案留在外面;
3. 那一步定稿、下一步开始时投影重算,折叠又消失 → 用户看到的「折一下再弹开」。

第二个放大器:`textLen` 原本把 **reasoning 也算进回答正文**。纯思考的一步因此能通过 `isAnswerRow`,折叠行甚至会显示成「运行中 · 思考」。官方的 `hasAssistantReplyContent` 恰好把 reasoning 排除在外。

修法是补上官方那道**关闭门**,并且让 `textLen` 只数正文:

- `textLen` 只累计 `text` 块,reasoning 是过程材料;
- **一轮的「已关闭」由它自己的行说明**:`turn-tail` 在 `turn/end` 时发布(不论什么原因),`turn-error` / `turn-max-tokens` 与它同时发布。分组里出现这三种 kind 之一,就是 DOM 自己对 `turnClosed` 的陈述;
- `foldTurn` 第一件事就是这道门:**没有关闭 → 一行都不折**,`foldable` 为 false,折叠行不渲染。

关闭门必须排在其它判据之前,因为它是唯一无法从别处补救的事实:运行中的一轮发布的边界**已经移动过了**,后面所有区间比较都建立在过期前提上。

## 回答结束时回到提问的开头

第五个功能是用户提的「每次回答完后自动滚动到这条的顶部」。触发点直接复用上面那道**关闭门**:某一轮从「未关闭」变成「关闭」时滚一次,不另起一套判据。

落点是把**提问行贴到滚动口顶边**(`scrollTop + (提问行.top − 滚动口.top)`)。实测那一轮结束时的状态:

| 测量 | 结果 |
|---|---|
| 滚动容器 | `div.QFBU2W_scrollBody`,带 `data-conversation-scroll`——正是 `ChatView` 自己解析的那个元素 |
| 一轮结束后 | `scrollTop = 1993 = floor`(停在底部),提问行在滚动口上方 **−1937px** |
| 落点 | `scrollTop = 56`,提问行在顶边 **0px**,1.2 秒后仍在 56 |

只有真的会滚的时候才滚:**没有溢出**(短回答)或**提问已经在顶边**时不写。这两条让「短回答无感」和「重复触发安全」都成了自然结果,而不是额外规则。

另外三条护栏,每条都对应一个真实的坏结果:

- **你刚动过滚动就不滚**:`wheel` / `touchstart` / `pointerdown` / `keydown` 一秒半内,这一轮的滚动作废(这正是「你正在往回翻时被拽走」的来源);
- **只认「刚刚关闭」这一个瞬间**,所以打开历史会话、切会话、点「加载更早」都不会自己滚;
- **写入是瞬时的,并在下一帧核对一次**:这是踩出来的。动画版本(`scroll-behavior: smooth`)在这里**赢不了**——一轮结束时产品自己也在往底部跟,两条滚动动画同时活着,实测出现过「插件记录了 `from 1993 to 56`、而视图留在底部」,且连续两次运行一次成功一次失败。改成赋值就没有动画可以输;下一帧再量一次,只有真的没到位才补写,而**这段时间里你一旦动手,补写就取消**。

### 会话身份用「签名」,不用那个列元素

这里还有一个更隐蔽的坑,值得单独记:折叠控制器每帧都从锚点重新解析「当前的列」,而**那个元素会因为一次普通重渲染就被换掉**。早先的版本把「列元素变了」当成「换了一份历史」,于是把 baseline 重置了——结果是**关闭事件本身被吃掉**:实测六次里有两三次,插件连「这一轮关闭了」都没记录到(`__FOLDITUP__` 里只有 pass,没有 noticed)。

现在用**文字签名**判断身份:`最小轮次号 | 第一个渲染行的 key`。会话切换一定会动这两个值之一;重渲染不会。窗口增长(新轮次、加载更早)也只是让签名变了下界而已——那时重置 baseline 恰好是想要的(那不是「刚刚关闭」)。

关掉它:`localStorage['dsh-fold-it-up.autoScroll'] = 'off'`(默认开)。

## 行为细节

| 场景 | 行为 |
|---|---|
| 一轮正常结束 | 折叠,末条回答保持可见,**视图自动落到这一轮的提问顶部** |
| **正在思考 / 正在跑工具的那一轮** | **完全不折**(没有 `turn-tail`),过程照常实时显示;也不会滚 |
| 回答很短、页面本来就没得滚 | 不滚(写入是空操作,插件干脆不写) |
| 回答结束后你正在自己往回翻 | 不滚(一秒半内的滚动手势会否掉这一次) |
| 点「加载更早」取回旧页 | **那批旧轮次立刻折好**,视图不动 |
| 打开历史会话 | 已结束的轮次全都折好,视图停在原来的位置 |
| 被中断、没有回答的轮次 | 不折(没有正文回答可留) |
| **上下文注入行** | **与该轮的过程一起折叠**;展开时原样回来 |
| 轮次报错 / 触顶 | 错误行与提示行永不隐藏 |
| 首条人类消息 | 永不隐藏 |
| 刷新页面 / 切换会话 | 重新折起(展开状态不持久化,这是刻意的),且不会自动滚动 |
| Ctrl+F 搜索 | 折叠的行仍能被浏览器找到并自动展开(`hidden="until-found"`) |

## 安装

把仓库放到本地任意目录(本插件**没有任何依赖,也没有安装步骤**:`client.js` / `index.js` / `cordis.patch.yml` 都是入库的生成物,不需要构建工具链):

```powershell
git clone https://github.com/gurio-wine/dsh-fold-it-up.git
```

然后把它装进 Web profile(profile 名按你的实际配置来,默认是 `web`):

```powershell
dsh plugin --profile web add <刚 clone 下来的目录>
```

最后**按 Ctrl+R 刷新页面** —— 客户端插件随页面加载,不需要重启 App。

卸载:

```powershell
dsh plugin --profile web remove dsh-fold-it-up
```

再刷新页面即可;会话记录不受影响。

## 实现

| 文件 | 作用 |
|---|---|
| `src/logic.js` | 全部决策 + 整列 pass:分组、范围、答案、隐藏 |
| `src/browser.js` | 浏览器半边(作者态 ESM):影子渲染器、控制器、DOM 读写原语 |
| `client.js` | **生成物**:按模块系统的 bundle 协议包装好的浏览器半边 |
| `index.js` | **生成物**:宿主半边(刻意什么也不做) |
| `cordis.patch.yml` | **生成物**:把包挂进 profile 的那一行 |

几个刻意的选择:

1. **影子注册**。`priority: -1` 拿下这个 keyed 单元(官方是 `0`,**同一格优先级最低者渲染**)。包级 boot-graph 插件不过 runner 的 guard,所以这里的 `-1` 是真实语义,不是自动分配的。
2. **复用产品自己的折叠行**。真实那一行是**运行时**从页面已经下载的 source map(`sourcesContent`)里取出 `TurnProcessNodeView.tsx` 源码、只改两处 import 后编译出来的。文案走 `chat` 命名空间既有词条(中英文跟随界面),样式、图标、间距全部一致,不需要跟着产品改版同步维护。取不到源码时退回内置等价实现。
3. **隐藏用官方同一套机制**。给同一批 wrapper 元素挂 `hidden="until-found"`——这正是 `ChatNodeSeat` 自己用的属性,因此「隐藏的行不占列间距」的节奏和 find-on-page 都保持不变。
4. **展开状态从行元素上读回**。折叠行自己会写 `data-open`;pass 读它来决定这一轮要不要展开。这一点踩过坑:`data-open` 写在**行**上,而索引里拿到的是外面的**座位**包装元素,读错对象会让每一次点击都变成空操作。
5. **一个稳定的锚点元素**。组件在「还不知道该不该折」和「已经折好」两种状态下都渲染同一个容器元素,控制器从它解析当前的 transcript 列。它必须一直存在:折好之后那一行本身就不渲染了。
6. **监听 DOM,而不只是监听 store**。列上挂 `MutationObserver`——一页被提交进来时,负责渲染那一轮行的那次 React 提交**不会**通知这个插件,但会改动 DOM。
7. **无构建步骤的构建**。`tools/build.mjs` 是一个字面量的 ESM→bundle 转换器(本包自己写的四种语法形式),把 `src/*.js` 内联进 `client.js`,平台模块(`react`、`@deepseek-ai/dsh-client-store`)留给 boot 模块表。生成物入库,装插件不需要任何工具链。

诊断:页面里 `globalThis.__FOLDITUP__` 保存最近 200 条生命周期记录(注册是否拿到格子、每次 pass 读到的列规模与分组数)。每一行上还有两个由 pass 写下的属性:`data-folditup-seq`(该行在 store 里的排序位置,取不到就没有)与 `data-folditup-turn`(管这一行的轮次——注入上下文行的归属只写在这里)。

## 验证

```powershell
node --test tools/*.test.mjs                               # 纯逻辑 + 生成物契约
node tools/build.mjs --check                               # 生成物是否为最新
node tools/verify-live.mjs --url <带 token 的启动 URL> --session <id> --toggle
node tools/verify-load-older.mjs --url <带 token 的启动 URL> --session <id> --rounds 3
node tools/probe-stream.mjs --url <带 token 的启动 URL> --text "<提示词>"   # 边跑边采样
node tools/verify-autoscroll.mjs --url <URL> [--session <id>] [--mode instant|smooth]   # 量滚动的算术
node tools/verify-autoscroll-live.mjs --url <URL> --reload   # 端到端:真的发一轮
```

`tools/verify-live.mjs` 用独立无头 Edge(独立 profile、独立调试端口,不碰你在用的窗口)打开真实会话,直接读**真实 DOM**:

- 每个已关闭轮次:恰好 1 行可见的 assistant(答案)、控制器未被隐藏、答案行已标记、没有漏出来的过程行;
- **该轮的注入上下文行如果还看得见,就是失败**(这一条以前漏掉了,因为断言把 `context` 当成了「本来就该可见」的 kind);
- 每一行隐藏都必须是 `hidden="until-found"`;
- `--toggle`:点开 → 隐藏数归零、过程行与注入行重新可见;再点 → 恢复原样;
- `--expect builtin` 跑同一套断言的反面(基线:过程行全部可见);
- 任何 `console.error` / 未捕获异常都会打印出来。

`tools/verify-load-older.mjs` 专测上一个回归:每一轮都重新加载页面、点一次「加载更早」、等新页提交,然后断言

- 没有任何一轮「有折叠控件却还露着过程行」,也没有任何一轮「渲染了过程行却完全没有折叠控件」;
- 每个折好的轮次恰好 1 行可见 assistant、且注入上下文行为 0——**否则一个空转录也会「零失败」通过**。

`tools/probe-stream.mjs` 专测「运行中不许折」:用浏览器自己的输入管线真的发一轮,从发出前到关闭后每 60ms 采样一次 DOM,把采样压成状态段后断言

- **关闭之前不存在任何折叠控件、也不存在任何被隐藏的行**(`premature` 必须为空);
- 关闭之后折叠行才出现,且恰好 1 行 assistant 可见、且它是答案行。

静态检查只看已结束的轮次,所以「折一下再弹开」这种运行中的抖动在它们眼里是隐形的——这个探针就是补这一块。

`tools/verify-autoscroll.mjs` 量的是**滚动的算术**:把提问行贴到顶边需要写什么、写下去会不会被界面抢回去、已经对齐时再写是不是空操作;`--mode smooth` 可以复现「动画版本为什么不行」。

`tools/verify-autoscroll-live.mjs` 量的是**这个功能本身**:用浏览器自己的输入管线真的发一轮长回答,然后**只观察不干预**——先断言它确实溢出了、关轮时确实停在底部、提问确实在屏幕外(否则这一轮什么都证明不了),再断言关轮后视图落在顶边、且插件只记录了一次写入、没有被追着补第二次。`--reload` 追加反向的一半:刷新后同一份转录重新折好,而插件记录的滚动次数必须是 **0**(新页面里没有任何一轮「刚刚关闭」)。它同时用页面的出生时间戳证明**刷新真的发生了**——否则「页面恰好很安静」会冒充这条断言通过。

实测(web profile,长回答):

```
关轮瞬间          top=1993 floor=1993 questionTop=-1937   ← 停在底部,提问在屏幕外 1937px
关轮后            top=56   floor=1993 questionTop=0        ← 提问落在顶边,连续 6 次采样不变
插件记录          {"kind":"scroll","from":1993,"to":56,"landed":56}  ← 恰好一次,无补写
刷新后重新打开    rows=8 controllers=1 hidden=3 scrollEvents=0        ← 折好了,且一次都没滚
短回答(无溢出)  写入落地为 0,等于空操作
```

实测(web profile):

```
真实 DOM 上刚刚跑完的一轮      7 行:system-prompt / user / 折叠行 / 2 × context / 答案 / 尾部
                               2 行 context 均为 hidden="until-found"
                               展开 → 2 行 context 回来;收起 → 又都藏好
大历史会话(有「加载更早」)    4 turn(s) inspected; 0 failure(s)
                               turn 22/23/24 各带 4/6/5 行 context,全部折好,0 泄漏
                               展开 → hidden 0 / 11 行 assistant 可见;收起 → hidden 20 / 1 行
点「加载更早」2 轮              页面从 90 行长到 201 行,5 个轮次全部折好(隐形轮 99 行),0 failure
同一会话 4 轮(关闭门修复后)   4 turn(s) inspected; 0 failure(s)
                               点开 → hidden 0 / 11 行 assistant 可见;收起 → hidden 20 / 1 行
边跑边采样(关闭门修复前)      premature 1 段:t+11.8s–13.0s 折叠控件已出现、3 行已隐藏,
                               而该轮尚无尾部;被当作「答案」留在外面的是「运行中 思考 I」
边跑边采样(关闭门修复后)      premature 0 段;关闭前 controllers=0 / hidden=0,关闭后才折
```

同一会话在**未装插件**的实例上过程行全部可见——也就是用户报的那个「就是不折叠」。

其余工具:

- `tools/scan-context.mjs <session.v3.jsonl.zstd>`:从会话日志里把注入的上下文事件与每一轮的过程范围 `[turn/start, 答案)` 对出来,回答「这些行到底在不在范围里」;
- `tools/pick-session.mjs`:按「无轮次上下文行数 / 轮次数」挑选值得实测的会话;
- `tools/analyze-turns.mjs <session.v3.jsonl.zstd>`:从会话日志重建每一轮,按官方判定打印哪些轮「本该折但没折」及原因;
- `tools/probe-rows.mjs --url <URL> [--session <id>]`:把一页的每一行按轮次打印出来(kind / 轮次 / 归属 / 成员标记 / 是否隐藏 / 文案),排查时先看它;
- `tools/probe-ask.mjs --url <URL> --text "..."`:用浏览器自己的输入管线在实例里真的发一轮,然后打印折叠结果——没有可用的历史会话时用这个造一个;
- `tools/probe-stream.mjs --url <URL> --text "..."`:同上,但**在整轮进行中每 60ms 采样一次 DOM**,输出压成状态段并列出「关闭前就折叠」的段——运行中抖动只能用它看见;
- `tools/probe-scroll.mjs --url <URL> --text "..."`:打印滚动容器是谁、一轮结束前后滚动位置怎么变、以及一次写入能不能挪动它——自动滚动那几个约束都是它先量出来的;
- `tools/probe-routes.mjs --url <URL>`:Web 端**没有会话级 URL**,这个探针把这件事查清楚(页面地址、localStorage 键、侧栏会话行长什么样),顺带说明「打开指定会话」只能靠侧栏或用 `dsh.sessions.current` 预置;
- `tools/session-log.mjs`:多帧 zstd 会话日志的解码(DSH 的 `.zstd` 是上千个拼接帧,Node 的 `zstdDecompressSync` 只解第一帧)。

## 已知边界

- 只对 **Web 界面**生效;TUI / headless 没有这个话题。
- 展开状态不跨刷新保留(刻意:装上、刷新、切会话都应该看到折叠)。
- **自动滚动只在轮次关闭的那一刻发生**。所以中途插话(steering)、打断、报错这些不会把视图拽走;你在它滚动之前一秒半内自己动过滚动,它也不动。
- 折叠行是 DSH 自己的行,所以它的文案是「工具调用 / 消息 / 已思考」;**工作时长**显示在轮次尾部的用量面板里,本插件不去改那一行。
- 「加载更早」还没点的时候,更早的轮次根本不在 DOM 里,无从折叠;把那些行取回来之后它们会立刻折好。
- **注入上下文的归属是「它下面的那一轮」**。这符合几何(一行只在它位于该轮答案之前时才会被隐藏),但如果将来 DSH 改成把注入行渲染在别的位置,这条规则需要跟着改——`data-folditup-turn` 就是为了一眼看出归属而存在的。
- **滚动的落点是「提问行的顶边对齐滚动口顶边」**,所以顶部那条空白(滚动口自身的上内边距,实测 56px)会保留;没有做成「贴到 0」是因为那个数字属于产品的样式,抄进来就会随它改版而错位。
- 与另一个同类第三方插件(抢同一格)不能共存;同一格里优先级最低者胜出,本插件是 `-1`。

## 许可证

[MIT](LICENSE) © 2026 Gurio

Install

dsh plugin --profile web add github:gurio-wine/dsh-fold-it-up

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source