dsh-edit-turn 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,9 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
61
61
  编辑器里,消息带的图片与文件**各显示成一个芯片**(缩略图或文件名 + 大小 + 删除按钮):不碰就原样保留,点删除就是不要它了;旁边还有一个「**添加附件**」(这条消息本来没有附件时也能用)。缩略图由本插件的只读路由按需取回;取不回来、或那些字节不是图片,芯片退回显示名字。**只有当这个部署没有附件存储时**,编辑器才会改回旧的那句话——「这条消息包含图片或文件附件,改写会丢弃它们,只保留文字」——并且不出现添加入口。
62
62
  **一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器照常关闭、**一条提示都不显示**(0.2.20 起;这条提示此前压在输入框的占位文字上)。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
63
63
 
64
- **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。编辑器上方多一个**块选择器**:一条回复同时带思维链和正文时,可以选「思维链」或「正文」,只改选中的那一块。改完点「保存」——记录只追加一条 `plugin:dsh-edit-turn/patch`,投影在模型下一次折叠时生效;**这条回答仍在原来的位置,后面的轮次原样不动**,工具调用和其他块也不动。转录里正文会就地显示改后的文字;思维链被改时会出现一条注记,展开就能看到模型实际读到的内容。
64
+ **术语**:编辑器里的「**思维链**」「**正文**」「**全部**」三个界面叫**页**;「全部」页里的「思维链」「正文」条目叫**子页**(也叫**块**)。页与子页指的是同一份内容,所以**在任一页里改动、切到别的页再切回来,改过的内容都还在**(只有关掉编辑器才会丢);子页之间也是同一份草稿。
65
+
66
+ **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。编辑器上方多一个**页选择器**:一条回复同时带思维链和正文时,可以选「思维链」「正文」页,或切到「全部」页一次看完整轮。改完点「保存」——记录只追加一条 `plugin:dsh-edit-turn/patch`,投影在模型下一次折叠时生效;**这条回答仍在原来的位置,后面的轮次原样不动**,工具调用和其他块也不动。转录里正文会就地显示改后的文字;思维链被改时会出现一条注记,展开就能看到模型实际读到的内容。
65
67
 
66
68
  在退回重放路径的宿主上(或显式 `replyMode: replay`),行为仍是 0.2.21 的:整条回答被替换,后面的轮次回退再逐事件重放;这种模式下只能改正文,工具调用与思维链无法保留(编辑器会提示)。
67
69
 
@@ -276,7 +278,29 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
276
278
 
277
279
  ### 更新日志
278
280
 
279
- **0.5.2(工作区)** —— 回复编辑不再等这一轮回复结束;编辑器跟着当前皮肤走。
281
+ **0.5.4** —— 设置卡导出给组合包(dsh-as-aistudio);会话 id 校验放宽,空会话按空状态处理。
282
+
283
+ - **会话 id 不再只认 UUID**:命名会话(例如 `bili-whale`,`~/.dsh/storages/<profile>/sessions/bili-whale.json`)是完全合法的会话,但这里的校验只接受 UUID 形式,于是对它的每个请求都被 400 拒掉 —— 面板上就是「状态加载失败,稍后重试。」。现在按 DSH 自己的语法(宿主侧 schema 就是 `z.string().min(1)`)放宽为 `/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/`,仍然挡住 `/`、`\`、`..`、空白与控制字符。
284
+ - **查不到会话按空状态处理**:没有日志的会话(刚建的命名会话)返回空状态而不是 404 —— 面板正常加载、显示"没有可操作的内容",日志一出现下一次刷新就接上。
285
+ - **设置卡导出**:`exports.ConfigCard`(就是 `EditorStyleOption` 那个组件)现在对外导出,于是聚合包 dsh-as-aistudio 能在自己的插件页上渲染**这同一张卡**,而不是照着重画一份。同一份设置、同一份存储键,两个入口不会漂移;单独装本插件时那张卡仍在本插件自己的行上。
286
+
287
+
288
+ **0.5.3** —— 编辑框随内容长高;隐藏兜底不再吞掉第三方插件的按钮。
289
+
290
+ - **换页不再丢草稿**:以前「正文」页写的内容只存在单块草稿里、「全部」页的子页各自另存一份,而且切页时草稿会被重置成记录原文 —— 于是「正文」页写个 1、切到「全部」页,子页里没有这个 1;切回来也没了。现在三页共用**一份按块索引的草稿**(`正文` 页与它在「全部」页里的子页是同一个块、同一个 key),任何一页里打的字,切到别的页、再切回来都还在;保存时发出的也是草稿(真页面复验:正文页写 `1` → 全部页正文子页显示 `1` → 在全部页改思维链子页 → 切回正文页仍是 `1` → 切到思维链页是 `thought draft` → 再切回正文页仍是 `1`)。
291
+
292
+ - **往下滚自动弹回顶部(主要问题)**:测量每个块的 textarea 要把它塌成 `height: auto`,整个文档随之变矮,浏览器就把滚动条的 `scrollTop` 夹到新的上限(≈0)—— 高度随后虽然恢复,**位置已经丢了**。宿主在流式回复时会不停重绘,所以表现为「滚到下面自动回上去」。实测:滚到底(`scrollTop 847 = max 847`)后触发一次布局,`scrollTop` 变 **0**。修法:新增 `withScrollKept(scroller, work)`,测量前后保存/恢复滚动位置;「全部」页的整体重测、逐块撑高、单块模式的测量全部走它。修后同样操作 `scrollTop` 保持 **847**。
293
+ - **最后一行滚不到**:同一次「设成 `auto` 再读 `scrollHeight`」的读取**永远少一行**(换行在写完样式之后才落定),元素被钉在偏小高度且 `overflow:hidden`,外层滚动条按这个值算高度 → 最后一行既看不见也滚不到(实测:一次读 2392、二次读 2415,行高 23px)。修法:抽出 `settleAreaHeight()`,重置后**读两次**再落定。
294
+ - **真页面复验**:正文灌到 1200 / 3000 / 6000 / 12000 字符,`clipped` 全为 0、滚动条都能到底、最后一行完整可见且停在底栏上方 8px;滚到底后连续两次布局,位置不动。
295
+ - **改写后的提示词气泡跟随 Markdown 渲染**:装了 `dsh-markdown-bubble` 时,改写后那条气泡(`.dshet-revision`)不再固定成纯文本,而是走宿主自己的 `MarkdownText`(用 `react-dom/client` 挂载 —— 宿主自己的包也是这么用的,已实测可行)。判据是那个插件自己的存在标记 `globalThis.__DSH_MARKDOWN_BUBBLE__`:**装了才渲染 Markdown,没装就保持纯文本**,因为没装时宿主画的其它用户气泡就是纯文本,只有跟着一致才不会出现「改写过的和没改写过的长得不一样」。理由与上游建议见 [deepseek-harness#10704](https://github.com/deepseek-ai/deepseek-harness/discussions/10704)。
296
+ - **编辑时框跟着内容长高**:以前框高在打开那一刻定死(「一轮最大」还带缓存),打字变多也不长 —— 内容超出后只能靠框内滚动条。现在:**打字变多就长高,删字就变矮,但不会比「打开时」更矮**(这道地板属于这一次编辑,切页也保留,保存或点叉才结束)。方向看空间:下面够就在下方长,下面不够就把底边钉在输入框卡片上沿、往上长(实测底边 680 / 输入框卡片上沿 688,留 8px 边距);整段都装不下就铺满对话区上下边界、由框内滚动。三档「高度」偏好只决定**打开时**多大,编辑中的跟随规则三档一致(设置页的说明文案已同步)。
297
+ - **隐藏兜底会吞掉第三方插件的按钮(issue #1,静默失败)**:折叠一条被改写的消息时,那段兜底是「除我自己的三个类名外,兄弟节点一律隐藏」。`dsh-rewind-pro` 这类插件把图标注入到同一条用户消息里(动作条旁边 / 插槽旁边),于是被一并隐藏 —— DOM 里在、点得到、屏幕上 `display:none`,**没有任何报错**,自动化测试(`querySelectorAll` 数得到元素)也全绿,所以很难发现。现在只隐藏**装着消息正文**的那个子树:兄弟节点里不含正文的一律不动(`messageContentRoot` 找不到宿主形状时仍退回旧行为,免得改写后的消息留在屏幕上)。回归测试里注入两个第三方节点(动作条旁、插槽旁),断言它们保持可见、且不带本插件的折叠标记 —— 改回旧写法该测试转红。
298
+ - **补上 `error.session-empty` 的中英文案**:宿主半部对「还没有内容的会话」会报这个 code,客户端字典里缺它,静态校验(`verify:client`)一直在红。
299
+ - **单块页(思维链 / 正文 / 用户输入)滚不动**:真因不是滚轮,而是**滚动位置活不过下一次布局** —— 给 textarea 赋 `value` 会把它的滚动位置扔回顶部(Chromium 实测:赋**同一个**字符串位置不变,赋**另一个**字符串位置归零,再赋回原值也回不来)。而「一轮最大」的高度测量正是把**同一轮其它块**的文字写进这个输入框来量的,于是每次**缓存未命中**的布局(第一次布局、切页、以及一轮里块还在变的时候——也就是模型流式输出期间)都会把读者扔回顶部。修法:测量前后保存/恢复 `scrollTop`。真页面复验:用户输入页灌 3000 字 → 滚到 400 → 强制重新测量(改宽度 + 输入)→ 仍是 **400**。
300
+ - **思维链页保存后立即渲染**(两层原因):① 宿主对一条思维链会保留**多份拷贝** —— 折叠时那一行留在 DOM 里但 `display:none`,展开面板时再渲染一份**可见的**正文;而补丁逻辑只要「记住的那个元素还连在文档里」就认为已经打过补丁并 `continue`,于是**用户正在看的那一份**始终是记录原文,直到整页刷新(刷新会从日志重新折叠,宿主自己就画对了)。现在这一支会**把所有拷贝一起打上**(记住的元素、所有带标记的元素、所有文本等于原文/上一次文本的元素,含 `findReasoningElements` 这个复数版搜索),实测同一次保存后 `data-dshet-patch` 出现两份 `335:0`。② 一条回复的块列表是**整轮**的(宿主把同一轮里每一步的思维链和正文都挂在回答行上,每块带自己的 `seq`),于是同一份列表里会有多个 `index` 相同的块(第一步的思维链和第四步的思维链都是 `index 0`)。以前两处都只按 `index` 找块:保存后本地那条「乐观」补丁还按**回答行的 seq** 记,渲染时又用 `find(index === …)` 命中**第一个**同 index 的块 —— 两处都拿错了 `original`,于是补丁在 DOM 里匹配不到任何东西,思维链正文要等刷新才变。现在补丁按**块自己的 seq** 记,查块按 **(seq, index)** 查,并顺着块找到**承载它的那条回复条目**(后一步的块住在以回答行 seq 为键的条目里)。
301
+ - **测试**:`npm test` **254/254**(新增六例:补丁只落在目标步的思维链上;保存后思维链正文立即变;后开的思考面板同样显示改写;测量后 textarea 的滚动位置不变;编辑时随内容长高、删字只矮回打开时的高度;下方没空间时向上长而不压输入框 —— 六例在改回旧写法时都会失败)。(新增五例:换页保留草稿;模拟「第一次读少一行」的 textarea 断言最终高度;模拟浏览器夹滚动条断言测量后位置不变;装了 markdown 插件时改写气泡走宿主渲染器、没装时保持纯文本 —— 把修法还原成旧写法时都会失败)。
302
+
303
+ **0.5.2** —— 回复编辑不再等这一轮回复结束;编辑器跟着当前皮肤走。
280
304
 
281
305
  - **回复编辑不再被「正在回复中」挡住**:就地记录(patch)只 append 一条 `ignorable` 事件,不改任何 surface 节点,所以它可以在 agent 还在写的时候落盘 —— 模型下一次折叠就读到。宿主侧只在**会移动 surface** 的路径上保留忙时拒绝:改写提示词(surface replace)和 replay 兜底(遮蔽窗口 + 重放尾部)。客户端同步:回复就地编辑不再禁用保存、不再显示「该会话正在回复中」横幅;提示词编辑与 replay 路径照旧拦下并说明。
282
306
  - **测试**:回复编辑在未闭合的 turn 中保存成功、提示词改写仍被拒、整轮多块在忙时保存成功。
@@ -288,7 +312,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
288
312
  - **改写后的提示词气泡用皮肤自己的气泡色**(`--dsw-specific-bubble`),圆角用 `--dsw-radius-xl`,与真实用户气泡一致。
289
313
  - **测试**:`npm test` **241/241**、`npm run verify:contract` **96** 项、`npm run verify:client` **192** 项。
290
314
 
291
- **0.5.1(工作区)** —— 思维链就地画出来、保存不再“点不动”、同一个块可以反复编辑。
315
+ **0.5.1** —— 思维链就地画出来、保存不再“点不动”、同一个块可以反复编辑。
292
316
 
293
317
  - **删掉「思维链已改写(模型可见)」卡片**:reasoning 正文只要在 DOM 里就就地改写;没有正文时不再插入任何占位卡片。
294
318
  - **无限次编辑**:记录「上一次画上去的文本」,第二、第三次编辑同一个块时仍能找到元素并更新。
package/lib/client.js CHANGED
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  const RERUN_PREFIX = '/dsh-rerun-turn'
46
46
 
47
47
  /** Keep in sync with package.json and lib/index.js. */
48
- const PLUGIN_VERSION = '0.5.2'
48
+ const PLUGIN_VERSION = '0.5.4'
49
49
 
50
50
  // --- copy -----------------------------------------------------------------
51
51
 
@@ -78,9 +78,9 @@ window.__ModuleLoader__.load({
78
78
  'config.summary.height.max': '一轮最大',
79
79
  'config.summary.height.content': '随内容',
80
80
  'config.summary.height.fill': '全部铺满',
81
- 'config.hint.height.max': '取这一轮所有思维链/正文里的最大高度,切换块时框不变。',
82
- 'config.hint.height.content': '框高跟着当前块的内容走,切换块时框会变。',
83
- 'config.hint.height.fill': '普通块按一轮最大;选「全部」时自动铺满对话区到输入框之间的整段高度(刚好触碰上下边界),只有一个滚动条。',
81
+ 'config.hint.height.max': '打开时取这一轮所有思维链/正文里的最大高度;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
82
+ 'config.hint.height.content': '打开时框高跟着当前块走;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
83
+ 'config.hint.height.fill': '普通块打开时按一轮最大,编辑中随打字长高、删字变矮(不会比打开时更矮);选「全部」时铺满对话区到输入框之间的整段高度,只有一个滚动条。',
84
84
  'placement.overlay': '浮层',
85
85
  'placement.inline': '页面内联',
86
86
  'placement.legacy': '整行旧版',
@@ -114,6 +114,7 @@ window.__ModuleLoader__.load({
114
114
  'error.invalid': '请求无效,请刷新后重试。',
115
115
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
116
116
  'error.session-not-found': '找不到该会话的日志。',
117
+ 'error.session-empty': '这个会话还没有内容,暂时没有可编辑的东西。',
117
118
  'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
118
119
  'error.not-editable': '这条消息不支持编辑。',
119
120
  'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
@@ -164,9 +165,9 @@ window.__ModuleLoader__.load({
164
165
  'config.summary.height.max': 'Turn max',
165
166
  'config.summary.height.content': 'Content',
166
167
  'config.summary.height.fill': 'Fill when all',
167
- 'config.hint.height.max': 'Uses the tallest reasoning/text block of the turn, so the box does not resize when switching.',
168
- 'config.hint.height.content': 'The box follows the current block, so it resizes when switching.',
169
- 'config.hint.height.fill': 'Single blocks use the turn max; the all-blocks page fills the whole band between the conversation top and the composer, with one scrollbar.',
168
+ 'config.hint.height.max': 'Opens at the tallest reasoning/text block of the turn; while editing it grows with your text and shrinks back, never below the height it opened at.',
169
+ 'config.hint.height.content': 'Opens sized for the current block; while editing it grows with your text and shrinks back, never below the height it opened at.',
170
+ 'config.hint.height.fill': 'Single blocks open at the turn max and follow your text while editing (never below the opening height); the all-blocks page fills the whole band between the conversation top and the composer, with one scrollbar.',
170
171
  'placement.overlay': 'Overlay',
171
172
  'placement.inline': 'In-page',
172
173
  'placement.legacy': 'Legacy row',
@@ -200,6 +201,7 @@ window.__ModuleLoader__.load({
200
201
  'error.invalid': 'Invalid request; refresh and try again.',
201
202
  'error.session-not-active': 'This session is not open in DSH; open it first.',
202
203
  'error.session-not-found': 'No session log was found for this id.',
204
+ 'error.session-empty': 'This session has nothing in it yet; there is nothing to edit.',
203
205
  'error.busy': 'This session is still replying; wait for it to finish.',
204
206
  'error.not-editable': 'This message cannot be edited.',
205
207
  'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
@@ -236,6 +238,70 @@ window.__ModuleLoader__.load({
236
238
  return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
237
239
  }
238
240
 
241
+ // --- a rewritten prompt, rendered the way the bubble it stands in for is ---
242
+
243
+ /**
244
+ * Whether the markdown bubble plugin is on this page.
245
+ *
246
+ * Its own presence marker is the only honest signal: the host draws a user
247
+ * bubble as plain text (`white-space: pre-wrap`), so rendering markdown in
248
+ * the revision bubble is only correct when something else is already
249
+ * rendering the OTHER user bubbles that way. Without it, a rewritten prompt
250
+ * would look different from every untouched prompt around it.
251
+ */
252
+ function markdownBubbleInstalled() {
253
+ try {
254
+ return typeof globalThis !== 'undefined' && globalThis.__DSH_MARKDOWN_BUBBLE__ !== undefined
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
260
+ /** One mounted markdown occurrence per bubble, so a rebuild can unmount it. */
261
+ const revisionRoots = new WeakMap()
262
+
263
+ /**
264
+ * Draw one rewritten prompt's text into `host`.
265
+ *
266
+ * The host's own markdown renderer (`MarkdownText`) is a React component and
267
+ * this half builds DOM by hand, so it is mounted through `react-dom/client` -
268
+ * the same module the host's own bundles require. Anything missing (no
269
+ * react-dom, no primitives, a hostile host shape) falls back to the plain
270
+ * text node this bubble has always used.
271
+ *
272
+ * @param host - the bubble element that shows the text.
273
+ * @param text - the rewritten text.
274
+ * @returns whether the markdown path was used.
275
+ */
276
+ function renderRevisionText(host, text) {
277
+ if (!markdownBubbleInstalled()) return false
278
+ try {
279
+ const react = require('react')
280
+ const reactDom = require('react-dom/client')
281
+ const MarkdownText = primitives.MarkdownText
282
+ if (
283
+ typeof react?.createElement !== 'function' ||
284
+ typeof reactDom?.createRoot !== 'function' ||
285
+ MarkdownText === undefined ||
286
+ MarkdownText === null
287
+ ) {
288
+ return false
289
+ }
290
+ const previous = revisionRoots.get(host)
291
+ if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
292
+ const node = document.createElement('span')
293
+ node.className = 'dshet-revision-text'
294
+ host.appendChild(node)
295
+ const root = reactDom.createRoot(node)
296
+ root.render(react.createElement(MarkdownText, { text }))
297
+ revisionRoots.set(host, root)
298
+ return true
299
+ } catch (error) {
300
+ console.warn('[dsh-edit-turn] the rewritten prompt could not be rendered as markdown:', error)
301
+ return false
302
+ }
303
+ }
304
+
239
305
  // --- style ----------------------------------------------------------------
240
306
  /**
241
307
  * Take the active skin's own surfaces when they are actually opaque.
@@ -574,6 +640,8 @@ window.__ModuleLoader__.load({
574
640
  this.editorStyle = 'frame'
575
641
  this.editorFocus = false
576
642
  this.editorDraft = ''
643
+ this.editorBlockKey = ''
644
+ this.editorDrafts = new Map()
577
645
  // The block list being edited: what the carrier will be rebuilt from.
578
646
  // Every entry is either `{ keep: <index> }` - a block of this message the
579
647
  // user left alone - or `{ add: { data, mediaType?, name } }` for
@@ -802,8 +870,16 @@ window.__ModuleLoader__.load({
802
870
  this.editorBlockKind = chosen === undefined ? null : chosen.kind
803
871
  this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
804
872
  this.editorAll = false
805
- this.editorAllDrafts = new Map()
873
+ // The height the box opens at: the floor the user cannot shrink below
874
+ // for as long as this edit lasts (save or cancel ends it). Null until
875
+ // the first layout measures it.
876
+ this.editorBaseHeight = null
877
+ // ONE draft store per open, keyed by block: the 正文/思维链 pages and the
878
+ // sub-pages inside 全部 all read and write it, so switching pages never
879
+ // drops what the user typed (only closing the editor does).
880
+ this.editorDrafts = new Map()
806
881
  if (!same) {
882
+ this.editorBlockKey = chosen === undefined ? '' : blockDraftKey(chosen)
807
883
  this.editorDraft = chosen === undefined ? target.text : chosen.text
808
884
  this.editorParts = blocksToParts(normalized, this.sessionId)
809
885
  }
@@ -870,8 +946,9 @@ window.__ModuleLoader__.load({
870
946
  if (target === null || !Array.isArray(target.blocks)) return
871
947
  const raw = String(value)
872
948
  if (raw === 'all') {
949
+ // The all-blocks page reads the SAME drafts: a block edited on its own
950
+ // page shows that edit in its sub-page, and the other way round.
873
951
  this.editorAll = true
874
- this.editorAllDrafts = new Map(target.blocks.map((block) => [`${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`, block.text]))
875
952
  this.editorElement = null
876
953
  this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
877
954
  return
@@ -894,9 +971,12 @@ window.__ModuleLoader__.load({
894
971
  this.editorBlockIndex = block.index
895
972
  this.editorBlockKind = block.kind
896
973
  this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
897
- this.editorDraft = block.text
974
+ this.editorBlockKey = blockDraftKey(block)
975
+ // This block's own draft, which may already carry what the user typed on
976
+ // another page - never the recorded text, which used to wipe it.
977
+ this.editorDraft = this.draftOfBlock(block)
898
978
  this.editorElement = null
899
- this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
979
+ this.publish({ draft: this.editorDraft, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
900
980
  }
901
981
 
902
982
  close() {
@@ -918,12 +998,40 @@ window.__ModuleLoader__.load({
918
998
  this.editorBlockKind = null
919
999
  this.editorTargetSeq = null
920
1000
  this.editorAll = false
921
- this.editorAllDrafts = new Map()
1001
+ this.editorDrafts = new Map()
1002
+ this.editorBlockKey = ''
1003
+ this.editorBaseHeight = null
922
1004
  this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
923
1005
  }
924
1006
 
1007
+ /** The draft of one block: what the user typed, else the recorded text. */
1008
+ draftOfBlock(block) {
1009
+ const drafts = this.editorDrafts
1010
+ if (drafts instanceof Map) {
1011
+ const key = blockDraftKey(block)
1012
+ const typed = drafts.get(key)
1013
+ if (typeof typed === 'string') return typed
1014
+ }
1015
+ return block !== null && block !== undefined && typeof block.text === 'string' ? block.text : ''
1016
+ }
1017
+
1018
+ /** Remember one block's draft, wherever the user typed it. */
1019
+ setDraftFor(block, value) {
1020
+ if (!(this.editorDrafts instanceof Map)) this.editorDrafts = new Map()
1021
+ this.editorDrafts.set(blockDraftKey(block), String(value ?? ''))
1022
+ }
1023
+
925
1024
  setDraft(value) {
926
1025
  this.editorDraft = value
1026
+ // The 正文/思维链 page and the matching sub-page inside 全部 are the same
1027
+ // block: one store, so the edit survives a page switch.
1028
+ const blocks = Array.isArray(this.editorBlocks) ? this.editorBlocks : []
1029
+ const current =
1030
+ blocks.find((block) => blockDraftKey(block) === this.editorBlockKey) ??
1031
+ (typeof this.editorBlockIndex === 'number'
1032
+ ? blocks.find((block) => block.index === this.editorBlockIndex && (this.editorBlockKind === null || block.kind === this.editorBlockKind))
1033
+ : undefined)
1034
+ if (current !== undefined) this.setDraftFor(current, value)
927
1035
  }
928
1036
 
929
1037
  setEditorStyle(style) {
@@ -1001,11 +1109,9 @@ window.__ModuleLoader__.load({
1001
1109
  /** The all-blocks save: one request carrying every edited block. */
1002
1110
  async confirmAll(target) {
1003
1111
  const blocks = Array.isArray(target.blocks) ? target.blocks : []
1004
- const drafts = this.editorAllDrafts instanceof Map ? this.editorAllDrafts : new Map()
1005
1112
  const targets = []
1006
1113
  for (const block of blocks) {
1007
- const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
1008
- const text = drafts.get(key)
1114
+ const text = this.draftOfBlock(block)
1009
1115
  if (typeof text !== 'string' || text.trim() === '') {
1010
1116
  this.publish({ failure: 'empty' })
1011
1117
  return
@@ -1040,7 +1146,7 @@ window.__ModuleLoader__.load({
1040
1146
  this.editor = null
1041
1147
  this.editorDraft = ''
1042
1148
  this.editorAll = false
1043
- this.editorAllDrafts = new Map()
1149
+ this.editorDrafts = new Map()
1044
1150
  this.editorElement = null
1045
1151
  this.editorBox = null
1046
1152
  this.editorRow = null
@@ -1177,8 +1283,17 @@ window.__ModuleLoader__.load({
1177
1283
  let patches = this.view.patches
1178
1284
  let patchBySeq = this.view.patchBySeq
1179
1285
  if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
1286
+ // The BLOCK's own seq, not the reply's. A turn's reasoning can live
1287
+ // in another step's message, and the host reports that seq in the
1288
+ // answer. Keying the optimistic patch by the reply's seq looked up
1289
+ // the wrong block, took the wrong `original`, and the patch then
1290
+ // matched nothing in the DOM - the edit appeared only after a reload,
1291
+ // when /state supplied it under its own seq (reported from the field
1292
+ // for the 思维链 page; the 正文 page looked fine because its block IS
1293
+ // the reply's own).
1294
+ const patchSeq = typeof data.target.seq === 'number' ? data.target.seq : target.seq
1180
1295
  const targetPatch = {
1181
- seq: target.seq,
1296
+ seq: patchSeq,
1182
1297
  blockIndex: data.target.blockIndex,
1183
1298
  kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
1184
1299
  text: String(data.target.text ?? ''),
@@ -1190,10 +1305,21 @@ window.__ModuleLoader__.load({
1190
1305
  const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
1191
1306
  byBlock.set(targetPatch.blockIndex, targetPatch)
1192
1307
  patchBySeq.set(targetPatch.seq, byBlock)
1193
- const entry = replies.get(target.seq)
1308
+ // The entry that carries this block: the reply's own, or the one
1309
+ // whose block list holds another step's reasoning.
1310
+ let entry = replies.get(patchSeq)
1311
+ if (entry === undefined || !Array.isArray(entry.blocks)) {
1312
+ for (const candidate of replies.values()) {
1313
+ if (Array.isArray(candidate?.blocks) && candidate.blocks.some((block) => block.seq === patchSeq)) {
1314
+ entry = candidate
1315
+ break
1316
+ }
1317
+ }
1318
+ }
1194
1319
  if (entry !== undefined && Array.isArray(entry.blocks)) {
1195
1320
  const blocks = entry.blocks.map((block) =>
1196
- block.index === targetPatch.blockIndex
1321
+ block.index === targetPatch.blockIndex &&
1322
+ (block.seq === undefined || block.seq === patchSeq)
1197
1323
  ? { ...block, text: targetPatch.text, patched: true }
1198
1324
  : block)
1199
1325
  const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
@@ -1623,15 +1749,23 @@ window.__ModuleLoader__.load({
1623
1749
  // like the images were deleted.
1624
1750
  const signature = text + '|' + extraBlocks.map((block) =>
1625
1751
  `${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
1626
- if (bubble.dataset.dshetRevisionContent !== signature) {
1752
+ // The signature carries whether the markdown path was used: installing or
1753
+ // removing the markdown bubble plugin must redraw this bubble too.
1754
+ const markdownSignature = signature + '|md:' + (markdownBubbleInstalled() ? '1' : '0')
1755
+ if (bubble.dataset.dshetRevisionContent !== markdownSignature) {
1756
+ const previous = revisionRoots.get(bubble)
1757
+ if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
1758
+ revisionRoots.delete(bubble)
1627
1759
  bubble.textContent = ''
1628
- const textNode = document.createElement('span')
1629
- textNode.className = 'dshet-revision-text'
1630
- textNode.textContent = text
1631
- bubble.appendChild(textNode)
1760
+ if (!renderRevisionText(bubble, text)) {
1761
+ const textNode = document.createElement('span')
1762
+ textNode.className = 'dshet-revision-text'
1763
+ textNode.textContent = text
1764
+ bubble.appendChild(textNode)
1765
+ }
1632
1766
  const files = revisionAttachments(entry, controller)
1633
1767
  if (files !== null) bubble.appendChild(files)
1634
- bubble.dataset.dshetRevisionContent = signature
1768
+ bubble.dataset.dshetRevisionContent = markdownSignature
1635
1769
  }
1636
1770
  // The edit entry goes where every other action of this row lives: the
1637
1771
  // platform's own bar, which the collapse keeps standing, at its right end
@@ -2034,9 +2168,39 @@ window.__ModuleLoader__.load({
2034
2168
  // `_actions` suffix is stable) and hides that strip's ancestors' other
2035
2169
  // children up to the row. Returns false when there is no strip to keep, and
2036
2170
  // false is what tells the caller to hide the whole row instead.
2171
+ /**
2172
+ * The host's own message markup inside a row - the stack / bubble / markdown
2173
+ * the transcript draws the text in. That subtree is the ONLY thing the
2174
+ * collapse walk may hide: a sibling that does not contain it belongs to
2175
+ * somebody else (another plugin's injected button, a portal), and hiding it
2176
+ * is issue #1 - `dsh-rewind-pro`'s button stayed in the DOM, stayed
2177
+ * clickable, and was `display:none` on screen with no console error to
2178
+ * notice. Null when the host shape is not one we recognise; the walk then
2179
+ * keeps its old "everything but ours" behaviour instead of leaving a
2180
+ * rewritten prompt on screen.
2181
+ */
2182
+ function messageContentRoot(row) {
2183
+ const selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]', '[class*="_stack"]', '[class*="messageBody"]']
2184
+ for (const selector of selectors) {
2185
+ let found = null
2186
+ try {
2187
+ found = row.querySelector(selector)
2188
+ } catch {
2189
+ found = null
2190
+ }
2191
+ if (found === null || found === undefined) continue
2192
+ if (typeof found.closest === 'function' && found.closest('.dshet-editor') !== null) continue
2193
+ return found
2194
+ }
2195
+ return null
2196
+ }
2197
+
2037
2198
  function collapseRowContent(row, hide) {
2038
2199
  const strip = row.querySelector('[class*="_actions"]')
2039
2200
  if (strip === null) return false
2201
+ // Only consulted while hiding: the restore pass goes by the marker this
2202
+ // plugin wrote, so a node an older build had hidden is still restored.
2203
+ const contentRoot = hide ? messageContentRoot(row) : null
2040
2204
  let node = strip
2041
2205
  while (node !== null && node !== row) {
2042
2206
  const parent = node.parentElement
@@ -2056,6 +2220,16 @@ window.__ModuleLoader__.load({
2056
2220
  ) {
2057
2221
  continue
2058
2222
  }
2223
+ // I5 / issue #1: another plugin's node beside the message is not ours
2224
+ // to hide. Only a sibling that holds the message content is.
2225
+ if (
2226
+ hide &&
2227
+ contentRoot !== null &&
2228
+ typeof child.contains === 'function' &&
2229
+ child.contains(contentRoot) !== true
2230
+ ) {
2231
+ continue
2232
+ }
2059
2233
  if (hide) {
2060
2234
  // Written only when the value changes: this runs on every DOM pass,
2061
2235
  // and an attribute write the observer can see would never settle.
@@ -2544,7 +2718,7 @@ window.__ModuleLoader__.load({
2544
2718
  return bounds
2545
2719
  }
2546
2720
 
2547
- function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null) {
2721
+ function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null, controller = null) {
2548
2722
  const layer = ensureEditorLayer()
2549
2723
  if (box.parentElement !== layer) layer.appendChild(box)
2550
2724
  const placement = readStoredEditorPlacement() ?? 'overlay'
@@ -2718,11 +2892,23 @@ window.__ModuleLoader__.load({
2718
2892
  try {
2719
2893
  const previousValue = area.value
2720
2894
  const previousHeight = area.style.height
2895
+ // Assigning `value` drops a textarea's scroll position to the top -
2896
+ // even when the assignment is only a measuring detour. Verified in
2897
+ // Chromium: the SAME string keeps the scroll, a DIFFERENT string
2898
+ // resets it to 0, and putting the original back does not bring it
2899
+ // back. The "turn max" pass below writes every other block's text into
2900
+ // this field, so every cache-miss layout (the first one, a page
2901
+ // switch, and every pass while the turn's blocks change - i.e. while
2902
+ // the model streams) threw the reader back to the top. That is what
2903
+ // "the 思维链/正文 page cannot be scrolled down" was: the wheel worked,
2904
+ // the position did not survive the next layout.
2905
+ const previousScroll = typeof area.scrollTop === 'number' ? area.scrollTop : 0
2721
2906
  const measure = (text) => {
2722
2907
  area.value = text
2723
- area.style.height = 'auto'
2724
- const measured = area.scrollHeight
2725
- return typeof measured === 'number' && measured > 0 ? Math.round(measured) : 0
2908
+ // The settled height: one pass reads a line short (see
2909
+ // settleAreaHeight), and this number decides the whole box. The
2910
+ // scroller is the box itself, so its position is kept too.
2911
+ return Math.round(withScrollKept(box, () => settleAreaHeight(area)))
2726
2912
  }
2727
2913
  const own = measure(previousValue)
2728
2914
  if (own > 0) contentHeight = own
@@ -2748,11 +2934,26 @@ window.__ModuleLoader__.load({
2748
2934
  }
2749
2935
  area.value = previousValue
2750
2936
  area.style.height = previousHeight
2937
+ if (typeof area.scrollTop === 'number' && area.scrollTop !== previousScroll) {
2938
+ area.scrollTop = previousScroll
2939
+ }
2751
2940
  } catch {}
2752
2941
  }
2753
2942
  // Every mode hugs the measured content; `coveredHeight` is only a
2754
2943
  // fallback for a host whose textarea cannot be measured.
2755
- let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND)
2944
+ // While the editor is open its height FOLLOWS the draft: more lines grow
2945
+ // the box, deleting lines shrinks it - but never below the height it had
2946
+ // when it opened, and never past the composer below or the conversation
2947
+ // top above. The floor belongs to the whole edit (a page switch keeps it)
2948
+ // and only a save or a cancel ends it.
2949
+ const baseHeight =
2950
+ controller !== null &&
2951
+ controller !== undefined &&
2952
+ typeof controller.editorBaseHeight === 'number' &&
2953
+ controller.editorBaseHeight > 0
2954
+ ? controller.editorBaseHeight
2955
+ : null
2956
+ let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND, baseHeight === null ? 0 : baseHeight)
2756
2957
  let clampedTop = null
2757
2958
  if (clamped) {
2758
2959
  const bounds = conversationBounds(row)
@@ -2763,14 +2964,32 @@ window.__ModuleLoader__.load({
2763
2964
  // fills the WHOLE band (not just the message's own position), so the
2764
2965
  // top touches the conversation edge and the bottom the composer.
2765
2966
  const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
2766
- clampedTop = fillAll ? topLimit : Math.max(base.top, topLimit)
2767
- height = fillAll
2768
- ? Math.max(minimumHeight, bottomLimit - clampedTop)
2769
- : Math.min(height, Math.max(minimumHeight, bottomLimit - clampedTop))
2967
+ const band = bottomLimit - topLimit
2968
+ if (fillAll) {
2969
+ clampedTop = topLimit
2970
+ height = Math.max(minimumHeight, band)
2971
+ } else if (height <= band) {
2972
+ // It fits: grow DOWN from the row while there is room for it, and
2973
+ // when there is not, keep the bottom at the composer edge and grow
2974
+ // UP instead - the direction follows the space, the box never covers
2975
+ // the composer and never leaves the conversation's top.
2976
+ const room = bottomLimit - Math.max(base.top, topLimit)
2977
+ clampedTop = height <= room ? Math.max(base.top, topLimit) : Math.max(topLimit, bottomLimit - height)
2978
+ height = Math.min(height, band)
2979
+ } else {
2980
+ // Taller than the whole band: fill it and let the field scroll.
2981
+ clampedTop = topLimit
2982
+ height = Math.max(minimumHeight, band)
2983
+ }
2770
2984
  }
2771
2985
  }
2772
2986
  if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
2773
2987
  height = Math.max(minimumHeight, height)
2988
+ if (baseHeight === null && controller !== null && controller !== undefined) {
2989
+ // The first layout of this open fixes the floor: from here on the box
2990
+ // may grow and shrink with the draft, but not below this.
2991
+ controller.editorBaseHeight = height
2992
+ }
2774
2993
  box.style.height = `${height}px`
2775
2994
  box.style.maxHeight = ''
2776
2995
  let top = clampedTop === null ? base.top : clampedTop
@@ -2794,11 +3013,9 @@ window.__ModuleLoader__.load({
2794
3013
  // is exactly as tall as its text and the ONE scrollbar appears when it
2795
3014
  // overflows the band.
2796
3015
  if (typeof allBox.querySelectorAll === 'function') {
2797
- for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) {
2798
- blockArea.style.height = 'auto'
2799
- const measured = blockArea.scrollHeight
2800
- if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
2801
- }
3016
+ withScrollKept(allBox, () => {
3017
+ for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) settleAreaHeight(blockArea)
3018
+ })
2802
3019
  }
2803
3020
  } else if (area !== null && area !== undefined) {
2804
3021
  const offsetLeft = Math.max(0, bubble.rect.left - base.left)
@@ -2836,6 +3053,73 @@ window.__ModuleLoader__.load({
2836
3053
  }
2837
3054
  }
2838
3055
 
3056
+ /**
3057
+ * Run `work` without losing the reader's place.
3058
+ *
3059
+ * Measuring a textarea means collapsing it (`height: auto`), which shrinks
3060
+ * the document; the browser then clamps the scroller's `scrollTop` to the
3061
+ * new maximum, and the position is gone even though the heights come back.
3062
+ * The host re-renders constantly (a streaming turn, a state refresh), so
3063
+ * this is what "scroll down and it jumps back up" was. The position is
3064
+ * captured before the work and put back after.
3065
+ *
3066
+ * @param scroller - the scrolling element that holds the textareas.
3067
+ * @param work - the measuring pass.
3068
+ * @returns whatever `work` returned.
3069
+ */
3070
+ function withScrollKept(scroller, work) {
3071
+ const keep =
3072
+ scroller !== null && scroller !== undefined && typeof scroller.scrollTop === 'number'
3073
+ ? scroller.scrollTop
3074
+ : null
3075
+ const result = work()
3076
+ if (keep !== null && scroller.scrollTop !== keep) scroller.scrollTop = keep
3077
+ return result
3078
+ }
3079
+
3080
+ /**
3081
+ * The content height of one textarea, SETTLED and applied.
3082
+ *
3083
+ * One measurement is not enough. With the height reset to `auto`, the first
3084
+ * read comes back exactly one line short - the wrap is resolved after the
3085
+ * style write - and the element is then pinned to that height with
3086
+ * `overflow:hidden`. In the all-blocks page that made the last line of a
3087
+ * long body unreachable: the surrounding scroller's height is computed from
3088
+ * the pinned value, so it cannot scroll to a line the textarea is hiding
3089
+ * (reported from the field: a long 正文 could not be scrolled past).
3090
+ *
3091
+ * Measured live: first read 2392, second read 2415, line height 23px.
3092
+ *
3093
+ * @param area - the textarea to measure and size.
3094
+ * @returns the applied height in CSS pixels, or 0 when unmeasurable.
3095
+ */
3096
+ function settleAreaHeight(area) {
3097
+ if (area === null || area === undefined || typeof area.scrollHeight !== 'number') return 0
3098
+ area.style.height = 'auto'
3099
+ const first = area.scrollHeight
3100
+ if (!(typeof first === 'number' && first > 0)) return 0
3101
+ area.style.height = `${first}px`
3102
+ const settled = area.scrollHeight
3103
+ const height = typeof settled === 'number' && settled > first ? settled : first
3104
+ area.style.height = `${height}px`
3105
+ return height
3106
+ }
3107
+
3108
+ /**
3109
+ * One block's identity inside the editor's draft store.
3110
+ *
3111
+ * A turn's blocks are addressed by (seq, index): the same block reached from
3112
+ * the 正文 page and from its sub-page inside 全部 must resolve to the SAME
3113
+ * key, or an unsaved edit disappears when the user switches pages.
3114
+ *
3115
+ * @param block - an editor block (seq, index).
3116
+ * @returns the stable key.
3117
+ */
3118
+ function blockDraftKey(block) {
3119
+ if (block === null || block === undefined) return ''
3120
+ return `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
3121
+ }
3122
+
2839
3123
  // Grow the textarea to its content, the way a normal input box behaves.
2840
3124
  // Without this it sits at a fixed `rows` height and a two-line edit floats
2841
3125
  // in half an empty box. The stylesheet owns the limits (`min-height`,
@@ -2845,9 +3129,12 @@ window.__ModuleLoader__.load({
2845
3129
 
2846
3130
  function fitEditorHeight(area) {
2847
3131
  if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
2848
- area.style.height = 'auto'
2849
- const height = area.scrollHeight
2850
- if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
3132
+ const scroller =
3133
+ typeof area.closest === 'function'
3134
+ ? area.closest('.dshet-all') ?? area.closest('.dshet-editor')
3135
+ : null
3136
+ const height = withScrollKept(scroller, () => settleAreaHeight(area))
3137
+ if (height > 0) area.style.height = `${height + 2}px`
2851
3138
  }
2852
3139
 
2853
3140
  // Wire an action so a host re-render cannot eat the click.
@@ -3134,7 +3421,7 @@ window.__ModuleLoader__.load({
3134
3421
  ? controller.editorBox
3135
3422
  : null
3136
3423
  if (existing && existing.dataset.dshetFor === marker) {
3137
- placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3424
+ placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3138
3425
  return
3139
3426
  }
3140
3427
 
@@ -3235,13 +3522,13 @@ window.__ModuleLoader__.load({
3235
3522
  fitEditorHeight(area)
3236
3523
  const row = controller.editorRow
3237
3524
  const box = controller.editorBox
3238
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3525
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3239
3526
  })
3240
3527
  area.addEventListener('compositionend', () => {
3241
3528
  fitEditorHeight(area)
3242
3529
  const row = controller.editorRow
3243
3530
  const box = controller.editorBox
3244
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3531
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3245
3532
  })
3246
3533
  area.addEventListener('focus', () => {
3247
3534
  controller.editorFocus = true
@@ -3314,15 +3601,15 @@ window.__ModuleLoader__.load({
3314
3601
  blockArea.style.position = 'static'
3315
3602
  blockArea.style.left = 'auto'
3316
3603
  blockArea.style.top = 'auto'
3317
- blockArea.value = controller.editorAllDrafts instanceof Map ? (controller.editorAllDrafts.get(key) ?? block.text) : block.text
3604
+ blockArea.value = controller.draftOfBlock(block)
3318
3605
  blockArea.disabled = view.pending || view.confirming
3319
- const grow = () => {
3320
- blockArea.style.height = 'auto'
3321
- const measured = blockArea.scrollHeight
3322
- if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
3323
- }
3606
+ const grow = () =>
3607
+ withScrollKept(
3608
+ typeof blockArea.closest === 'function' ? blockArea.closest('.dshet-all') : null,
3609
+ () => settleAreaHeight(blockArea),
3610
+ )
3324
3611
  blockArea.addEventListener('input', () => {
3325
- if (controller.editorAllDrafts instanceof Map) controller.editorAllDrafts.set(key, blockArea.value)
3612
+ controller.setDraftFor(block, blockArea.value)
3326
3613
  grow()
3327
3614
  })
3328
3615
  section.appendChild(head)
@@ -3523,7 +3810,7 @@ window.__ModuleLoader__.load({
3523
3810
  controller.editorBox = box
3524
3811
  controller.editorRow = row
3525
3812
  controller.editorElement = area
3526
- placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3813
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3527
3814
  fitEditorHeight(area)
3528
3815
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
3529
3816
  try {
@@ -3735,6 +4022,49 @@ window.__ModuleLoader__.load({
3735
4022
  * the deepest element whose text is the recorded original (or what we last
3736
4023
  * painted there).
3737
4024
  */
4025
+ /**
4026
+ * EVERY element that shows this reasoning text.
4027
+ *
4028
+ * Plural on purpose: the host keeps more than one copy of a turn's reasoning.
4029
+ * The collapsed process row stays in the DOM with `display: none`, and opening
4030
+ * the panel renders a second, visible body. Patching only the first one left
4031
+ * the copy the user was looking at showing the recorded text.
4032
+ *
4033
+ * @param root - the conversation root to search.
4034
+ * @param wanted - the patch (`original` text, new `text`).
4035
+ * @param previousText - what the patch showed before this pass, if anything.
4036
+ * @returns the deepest matching elements, best match first.
4037
+ */
4038
+ function findReasoningElements(root, wanted, previousText) {
4039
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return []
4040
+ const original = matchText(wanted.original)
4041
+ const previous = matchText(previousText)
4042
+ if (original === '' && previous === '') return []
4043
+ const found = []
4044
+ for (const el of root.querySelectorAll('*')) {
4045
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4046
+ try {
4047
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4048
+ } catch {
4049
+ /* a detached node is still a candidate */
4050
+ }
4051
+ const text = matchText(el.textContent)
4052
+ if (text === '') continue
4053
+ if (text !== original && !(previous !== '' && text === previous)) continue
4054
+ let deeper = false
4055
+ for (const child of el.children ?? []) {
4056
+ if (matchText(child.textContent) === text) {
4057
+ deeper = true
4058
+ break
4059
+ }
4060
+ }
4061
+ if (deeper) continue
4062
+ found.push({ el, score: Math.abs(text.length - original.length) })
4063
+ }
4064
+ found.sort((a, b) => a.score - b.score)
4065
+ return found.map((entry) => entry.el)
4066
+ }
4067
+
3738
4068
  function findReasoningElement(root, wanted, previousText) {
3739
4069
  if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
3740
4070
  const original = matchText(wanted.original)
@@ -3804,9 +4134,38 @@ window.__ModuleLoader__.load({
3804
4134
  const wantedText = new Map()
3805
4135
  const wantedReasoning = new Map()
3806
4136
  for (const patch of Array.isArray(view.patches) ? view.patches : []) {
3807
- const reply = replies.get(patch.seq)
3808
- if (reply === undefined || !Array.isArray(reply.blocks)) continue
3809
- const block = reply.blocks.find((entry) => entry.index === patch.blockIndex)
4137
+ // Find the block by (seq, index) AND the entry that carries it.
4138
+ //
4139
+ // One reply entry carries its whole TURN's blocks, so several blocks
4140
+ // share an index - the first step's reasoning and the fourth step's
4141
+ // reasoning are both "index 0" - and a later step's block lives in the
4142
+ // entry keyed by the ANSWER's seq, not by its own. Keying the lookup by
4143
+ // `patch.seq` alone found no entry, and `find(index === ...)` alone found
4144
+ // the wrong block: either way the patch matched nothing in the DOM and
4145
+ // the reasoning body kept the recorded text until a reload (reported from
4146
+ // the field for the 思维链 page; a save on another step's reasoning wrote
4147
+ // the record and /state reported it, yet the transcript did not move).
4148
+ let reply = replies.get(patch.seq)
4149
+ let block = Array.isArray(reply?.blocks)
4150
+ ? reply.blocks.find(
4151
+ (entry) =>
4152
+ entry.index === patch.blockIndex &&
4153
+ (entry.seq === undefined || patch.seq === undefined || entry.seq === patch.seq),
4154
+ )
4155
+ : undefined
4156
+ if (block === undefined) {
4157
+ for (const candidate of replies.values()) {
4158
+ if (!Array.isArray(candidate?.blocks)) continue
4159
+ const found = candidate.blocks.find(
4160
+ (entry) => entry.index === patch.blockIndex && entry.seq === patch.seq,
4161
+ )
4162
+ if (found !== undefined) {
4163
+ reply = candidate
4164
+ block = found
4165
+ break
4166
+ }
4167
+ }
4168
+ }
3810
4169
  if (block === undefined) continue
3811
4170
  if (patch.kind === 'text') {
3812
4171
  wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
@@ -3928,37 +4287,34 @@ window.__ModuleLoader__.load({
3928
4287
  // in the DOM (or is drawn as a short summary without the full text).
3929
4288
  for (const [key, wanted] of wantedReasoning) {
3930
4289
  const owner = rowBySeq.get(wanted.seq)
4290
+ const shown = displayText(wanted.text)
4291
+ const previousText = patchedTexts.get(key) ?? ''
4292
+ const original = matchText(wanted.original)
4293
+ const previous = matchText(previousText)
4294
+ // EVERY copy of this reasoning, not just the one this pass remembers.
4295
+ //
4296
+ // The host keeps the collapsed process row in the DOM with `display:
4297
+ // none` and renders a SECOND body when the panel is opened. Trusting the
4298
+ // remembered element because it is still `isConnected` meant the copy the
4299
+ // user was looking at kept the recorded text until a reload - which is
4300
+ // exactly the reported "思维链页保存后不能马上渲染".
4301
+ const targets = new Set()
3931
4302
  const remembered = patchedElements.get(key)
3932
4303
  if (remembered !== undefined) {
3933
- // The reasoning part is its own row (the flow key carries a
3934
- // `,reasoning` group part), so it is NOT the row `rowBySeq` picked for
3935
- // the seq - that one draws the answer. Only connectivity matters here.
3936
- const stillThere = remembered.isConnected === true
3937
- if (stillThere) {
3938
- const shown = displayText(wanted.text)
3939
- if (normalizedText(remembered.textContent) !== normalizedText(shown)) remembered.textContent = shown
3940
- if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
3941
- remembered.style.whiteSpace = 'pre-wrap'
3942
- patchedTexts.set(key, wanted.text)
3943
- continue
3944
- }
3945
- patchedElements.delete(key)
4304
+ if (remembered.isConnected === true) targets.add(remembered)
4305
+ else patchedElements.delete(key)
3946
4306
  }
3947
- const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
3948
- if (done !== undefined) {
3949
- patchedElements.set(key, done)
3950
- continue
4307
+ for (const el of root.querySelectorAll(`[${PATCH_ATTR}]`)) {
4308
+ if (el.getAttribute(PATCH_ATTR) === key) targets.add(el)
3951
4309
  }
3952
- const original = matchText(wanted.original)
3953
- const previous = matchText(patchedTexts.get(key) ?? '')
3954
- const previousText = patchedTexts.get(key) ?? ''
3955
- let best = null
3956
4310
  if (original !== '' || previous !== '') {
3957
4311
  // The owning row first (a host that draws the reasoning inside the
3958
4312
  // answer row), then the whole conversation: on this host the reasoning
3959
4313
  // is its own row, so the answer row holds no reasoning text at all.
4314
+ // EVERY scope is merged, never just the first one that matched: the
4315
+ // point of this pass is to catch all copies at once.
3960
4316
  for (const scope of owner === undefined ? [null] : [owner, null]) {
3961
- let bestScore = Infinity
4317
+ let foundInScope = false
3962
4318
  for (const el of containers) {
3963
4319
  if (el.getAttribute(PATCH_ATTR) !== null) continue
3964
4320
  if (scope !== null) {
@@ -3971,29 +4327,29 @@ window.__ModuleLoader__.load({
3971
4327
  const text = matchText(el.textContent)
3972
4328
  if (text === '') continue
3973
4329
  if (text !== original && !(previous !== '' && text === previous)) continue
3974
- const score = Math.abs(text.length - original.length)
3975
- if (score < bestScore) {
3976
- best = el
3977
- bestScore = score
3978
- }
4330
+ targets.add(el)
4331
+ foundInScope = true
3979
4332
  }
3980
- if (best !== null) break
4333
+ if (foundInScope) break
3981
4334
  }
3982
- if (best === null) best = findPatchTextFallback(owner ?? null, wanted, previousText)
3983
- if (best === null) best = findReasoningElement(root, wanted, previousText)
4335
+ const fallback = findPatchTextFallback(owner ?? null, wanted, previousText)
4336
+ if (fallback !== null) targets.add(fallback)
4337
+ for (const el of findReasoningElements(root, wanted, previousText)) targets.add(el)
3984
4338
  }
3985
- if (best === null) {
4339
+ if (targets.size === 0) {
3986
4340
  // No body in the DOM (the panel is closed): the header snippet is the
3987
4341
  // only place the edit can show.
3988
4342
  if (owner !== undefined) patchReasoningSummary(owner, wanted)
3989
4343
  patchReasoningSummary(root, wanted)
3990
4344
  continue
3991
4345
  }
3992
- patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
3993
- best.textContent = displayText(wanted.text)
3994
- best.setAttribute(PATCH_ATTR, key)
3995
- best.style.whiteSpace = 'pre-wrap'
3996
- patchedElements.set(key, best)
4346
+ for (const el of targets) {
4347
+ if (!patchOriginals.has(el)) patchOriginals.set(el, { html: el.innerHTML, text: el.textContent })
4348
+ if (normalizedText(el.textContent) !== normalizedText(shown)) el.textContent = shown
4349
+ if (el.getAttribute(PATCH_ATTR) !== key) el.setAttribute(PATCH_ATTR, key)
4350
+ el.style.whiteSpace = 'pre-wrap'
4351
+ patchedElements.set(key, el)
4352
+ }
3997
4353
  patchedTexts.set(key, wanted.text)
3998
4354
  if (owner !== undefined) patchReasoningSummary(owner, wanted)
3999
4355
  patchReasoningSummary(root, wanted)
@@ -4217,7 +4573,7 @@ window.__ModuleLoader__.load({
4217
4573
  const key = `${Math.round(rect.top)}:${Math.round(rect.height)}:${Math.round(typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0)}:${viewportWidth}x${viewportHeight}:${controller.editorBlockKind}:${controller.editorAll === true ? 'all' : ''}`
4218
4574
  if (key === lastPlacementKey) return
4219
4575
  lastPlacementKey = key
4220
- placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
4576
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
4221
4577
  }
4222
4578
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
4223
4579
  else window.setTimeout(run, 16)
@@ -4684,6 +5040,10 @@ window.__ModuleLoader__.load({
4684
5040
  }
4685
5041
 
4686
5042
  exports.apply = apply
5043
+ // The settings card, exported so a composing bundle (dsh-as-aistudio) can
5044
+ // render THE SAME component on its own page instead of re-implementing it:
5045
+ // one source of truth for 编辑与重跑 settings.
5046
+ exports.ConfigCard = EditorStyleOption
4687
5047
  exports.inject = ['slots', 'locale']
4688
5048
  exports.PLUGIN_VERSION = PLUGIN_VERSION
4689
5049
  return module.exports
package/lib/index.js CHANGED
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
80
  export const name = PLUGIN_ID
81
81
 
82
82
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.5.2'
83
+ export const PLUGIN_VERSION = '0.5.4'
84
84
 
85
85
  // A ring of the last few requests, answering "did the browser half even ask, and
86
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -100,7 +100,15 @@ export function recentRequests() {
100
100
  }
101
101
 
102
102
  const ROUTE_PREFIX = '/dsh-edit-turn'
103
- const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
103
+ // A session id is an OPAQUE string to DSH: the host's own schema is
104
+ // `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
105
+ // stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
106
+ // real as a UUID one. A UUID-only test rejected those with a 400, so the panel
107
+ // answered "状态加载失败" for a session that plainly exists. The character class
108
+ // still refuses everything that could leave a path or a URL: `/`, `\`, `..`
109
+ // (the first character must be alphanumeric, so a leading dot is already out),
110
+ // whitespace and control characters.
111
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
104
112
  const MAX_TEXT = 200_000
105
113
  const FLUSH_DEADLINE_MS = 5_000
106
114
 
@@ -1683,8 +1691,34 @@ export async function attachmentBytes(ctx, sessionId, seq, index) {
1683
1691
  async function stateOf(ctx, sessionId, config) {
1684
1692
  const events = await readEvents(ctx, sessionId)
1685
1693
  if (!events) {
1686
- noteRequest({ kind: 'state', sessionId, ok: false, code: 'session-not-found' })
1687
- throw new HttpError(404, 'session-not-found', 'no session log for this id')
1694
+ // A named session with nothing in it yet is an EMPTY session, not a failed
1695
+ // one: the editor opens with nothing to edit, and the next refresh picks the
1696
+ // log up once it exists. Answering 404 here is what the panel printed as
1697
+ // "状态加载失败,稍后重试。". The capability probe still runs, because "what
1698
+ // can this deployment do" does not depend on the log.
1699
+ noteRequest({ kind: 'state', sessionId, ok: true, code: 'session-empty' })
1700
+ const store = resolveAttachmentStore(ctx)
1701
+ return {
1702
+ hidden: [],
1703
+ edits: 0,
1704
+ revisions: [],
1705
+ surface: [],
1706
+ turns: [],
1707
+ replies: [],
1708
+ patches: [],
1709
+ mode: config.patch === true ? 'patch' : 'replay',
1710
+ live: false,
1711
+ busy: false,
1712
+ lastSeq: -1,
1713
+ capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1714
+ config: {
1715
+ confirm: config.confirm,
1716
+ replyMode: config.replyMode,
1717
+ patch: config.patch === true,
1718
+ editorStyle: config.editorStyle,
1719
+ },
1720
+ version: PLUGIN_VERSION,
1721
+ }
1688
1722
  }
1689
1723
  const folded = foldSurface(events)
1690
1724
  const ledger = rollbackLedger(events)
@@ -2477,12 +2511,71 @@ export function isProjectionUnavailable(message) {
2477
2511
  }
2478
2512
 
2479
2513
  function failure(res, error) {
2514
+ const message = String((error && error.message) || error)
2515
+ // The host refuses to derive a session whose projection left the registry
2516
+ // (`SurfaceManager._assertProjections`). It is a known, self-explaining state
2517
+ // - the plugin was reloaded while that session was open - not an internal
2518
+ // fault, so it answers 409 with the host's own words and does not dump a stack
2519
+ // into the log on every request.
2520
+ if (isProjectionUnavailable(message)) {
2521
+ sendJson(res, 409, { ok: false, code: 'projection-missing', error: message })
2522
+ return
2523
+ }
2480
2524
  const status = error instanceof HttpError ? error.status : 500
2481
2525
  const code = error instanceof HttpError ? error.code : 'internal'
2482
2526
  // An unexpected throw is the only case the client cannot explain; leave the
2483
2527
  // stack in the host log so a report can name the cause.
2484
2528
  if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
2485
- sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2529
+ sendJson(res, status, { ok: false, code, error: message })
2530
+ }
2531
+
2532
+ /**
2533
+ * The one projection object this PROCESS uses, kept across plugin reloads.
2534
+ *
2535
+ * The host registry compares projection IDENTITY, not type:
2536
+ * `SurfaceManager._assertProjections()` (dsh-session/lib/index.js) throws
2537
+ * `session message projection "<type>" was removed or replaced` when a
2538
+ * projection a LIVE session already folded with is no longer in the registry
2539
+ * array, and `registerMessageProjection`'s own docstring states the rule:
2540
+ * "Disposing the contribution makes sessions that used it refuse further
2541
+ * derivation."
2542
+ *
2543
+ * A reload disposes the previous fiber, which splices the old object out of the
2544
+ * registry. Building a fresh object then leaves every open session that carries
2545
+ * a patch event unable to derive anything: the surface read throws, so this
2546
+ * plugin's own save/state paths answer 500, and so does any sibling that reads
2547
+ * `session.surface.nodes` (verified live - dsh-delete-turn's /state answered 500
2548
+ * for such a session after a reload). Reusing one object from a process-global
2549
+ * hands the registry the SAME definition back, so a reloaded plugin keeps the
2550
+ * open sessions foldable.
2551
+ *
2552
+ * The object is a pure, stateless interpreter, which is what makes the reuse
2553
+ * safe. One consequence is deliberate: after a code change the projection keeps
2554
+ * running the PREVIOUS definition for sessions that are already open, until the
2555
+ * session is reopened or DSH restarts. That is the better half of the trade -
2556
+ * the alternative is a session that cannot be read at all.
2557
+ *
2558
+ * @returns the process-wide projection definition.
2559
+ */
2560
+ function sharedPatchProjection() {
2561
+ const KEY = '__DSH_EDIT_TURN_PATCH_PROJECTION__'
2562
+ try {
2563
+ const stashed = globalThis[KEY]
2564
+ if (
2565
+ stashed !== undefined &&
2566
+ stashed !== null &&
2567
+ typeof stashed.project === 'function' &&
2568
+ stashed.type === PATCH_TYPE
2569
+ ) {
2570
+ return stashed
2571
+ }
2572
+ const fresh = patchProjection()
2573
+ globalThis[KEY] = fresh
2574
+ return fresh
2575
+ } catch {
2576
+ // A frozen or absent globalThis is not worth failing the load over.
2577
+ return patchProjection()
2578
+ }
2486
2579
  }
2487
2580
 
2488
2581
  // --- plugin ------------------------------------------------------------------
@@ -2523,7 +2616,7 @@ export function apply(ctx, config) {
2523
2616
  const existing = Array.isArray(registry)
2524
2617
  ? registry.find((entry) => entry && entry.type === PATCH_TYPE)
2525
2618
  : undefined
2526
- if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
2619
+ if (existing === undefined) sessionsService.registerMessageProjection(sharedPatchProjection())
2527
2620
  patch = true
2528
2621
  } catch (error) {
2529
2622
  if (resolved.replyMode === 'patch') throw error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",