dsh-edit-turn 0.5.0 → 0.5.2

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
@@ -252,8 +252,56 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
252
252
 
253
253
  **「点了保存没反应」先看这里**:记录里有没有 `apply`。没有 → 点击丢在浏览器半边(此前的成因是宿主在 mousedown/mouseup 之间重建了行,按钮已改为 pointerdown 激活);有 `apply` 但 `ok:false` → 宿主拒绝了,`code` 就是原因(`busy`/`stale`/...)。
254
254
 
255
+ ### 为什么没有 `replyMode: replace`(把回复也走 surface 替换)
256
+
257
+ 试过:像 `dsh-chat-thinking-editor` 那样 append 一条新的 `assistant/message`、用 replace op 遮蔽旧的那条,让宿主原生渲染。**当前宿主(0.2.1-rc.2)的 surface 校验直接拒绝**,两条独立规则:
258
+
259
+ 1. `session event "assistant/message" carries an invalid replace surfaceOp` —— replace op 的键必须是 `{op, startSeq, endSeq}`(对方的代码用 `{start, end}`)
260
+ 2. `assistant/message embeds its source stream and cannot carry sourceEventSeqs`,而同一校验又要求 `surface replace: sourceEventSeqs must include every shadowed surface node; missing N` —— 两条互相矛盾 ⇒ **assistant 消息不能被另一条 assistant 消息替换**
261
+
262
+ ### 为什么没有走 `conversation.chat.reasoning.body` 槽位渲染
263
+
264
+ 也试过:注册官方 `conversation.chat.reasoning.body`(assistant-step 的子槽,`kind: single`、`scope: session`,宿主默认实现 `priority: 100`,并转交工厂槽 `conversation.chat.reasoning.content` 画 markdown),用它原生渲染改写后的思维链。
265
+
266
+ 实测(真页面,2026-10-22):**注册成功但拿不到槽位**——
267
+
268
+ - 注入回调确实执行(在 `document.documentElement.dataset` 上留了标记,读到 `1`)
269
+ - 组件从不渲染:`[data-dshet-reason-body]` 计数恒为 0
270
+ - `priority: 200` / `priority: 50` / 不传(由 runner 分配负值)三种都试过,宿主默认实现始终是渲染者
271
+ - 没有出现任何「registration refused」告警,说明注册被接受、但 `single` 槽位仍由宿主持有
272
+
273
+ 结论:**当前版本下第三方插件无法接管这个 single 子槽位**。所以「原生渲染」的两条路都封着:assistant 替换被 session 校验拒绝;思维链正文槽位不可顶替。界面侧只能继续用本插件的 DOM 补丁(已验证可用:`[data-dshet-patch]` 落在 `_text_*` 思维链节点上,内容为投影后的文本)。
274
+
275
+ 要真正摆脱 DOM 补丁,需要上游给投影一个原生渲染口,或者允许 assistant 替换、或允许插件 shadow 一个 single 子槽位。
276
+
255
277
  ### 更新日志
256
278
 
279
+ **0.5.2(工作区)** —— 回复编辑不再等这一轮回复结束;编辑器跟着当前皮肤走。
280
+
281
+ - **回复编辑不再被「正在回复中」挡住**:就地记录(patch)只 append 一条 `ignorable` 事件,不改任何 surface 节点,所以它可以在 agent 还在写的时候落盘 —— 模型下一次折叠就读到。宿主侧只在**会移动 surface** 的路径上保留忙时拒绝:改写提示词(surface replace)和 replay 兜底(遮蔽窗口 + 重放尾部)。客户端同步:回复就地编辑不再禁用保存、不再显示「该会话正在回复中」横幅;提示词编辑与 replay 路径照旧拦下并说明。
282
+ - **测试**:回复编辑在未闭合的 turn 中保存成功、提示词改写仍被拒、整轮多块在忙时保存成功。
283
+
284
+ - **颜色/圆角/阴影改用主题令牌**:文字、次级文字、三级文字、描边、主色、圆角(`--dsw-radius-panel/xl/lg/md/sm`)、阴影(`--dsw-shadow-lv3`)都读当前主题的令牌,取不到时退回本插件原来的值;悬停底色用 `color-mix` 从主题墨色算出来。
285
+ - **面板底色取皮肤的“色相”,不取它的透明**:皮肤把界面画在插画上时会把表面色做成半透明(靠自己的文字描边保证可读),插件元素没有描边。现在运行时探测 `--dsw-alias-bg-layer-1/2`:把它的**颜色**原样拿来、透明度强制为 1,于是「中秋」这类**深色插画皮肤**得到的是同色系的深色面板,而不是插件自带的浅色面板(这类皮肤不设 `data-ds-dark-theme`,旧代码只能退回浅色)。
286
+ - **调色板挂在 `body` 上**:主题令牌声明在 `body`,而自定义属性是在**定义处**作用域内做替换的 —— 原来挂在 `:root` 上,所有 `var(--dsw-*)` 都静默取了 fallback(实测:改了等于没改)。
287
+ - **就地编辑器不再刷白底**:`pageBackground()` 原来读 `body` 背景,插画皮肤下 `body` 是透明的 → 退回 `#ffffff`,于是深色皮肤上出现一个白框。现在优先用已解析的 `--dshet-panel`(按上面规则一定不透明)。
288
+ - **改写后的提示词气泡用皮肤自己的气泡色**(`--dsw-specific-bubble`),圆角用 `--dsw-radius-xl`,与真实用户气泡一致。
289
+ - **测试**:`npm test` **241/241**、`npm run verify:contract` **96** 项、`npm run verify:client` **192** 项。
290
+
291
+ **0.5.1(工作区)** —— 思维链就地画出来、保存不再“点不动”、同一个块可以反复编辑。
292
+
293
+ - **删掉「思维链已改写(模型可见)」卡片**:reasoning 正文只要在 DOM 里就就地改写;没有正文时不再插入任何占位卡片。
294
+ - **无限次编辑**:记录「上一次画上去的文本」,第二、第三次编辑同一个块时仍能找到元素并更新。
295
+ - **projection 缺失不再 500**:宿主用 `... was removed or replaced` 拒绝时,单块回复保存自动降级走 replay 路径;「全部」多块保存返回 `projection-missing`,提示重启 DSH。
296
+ - **忙时不再假死**:客户端从 `/state` 读 `busy`,会话还在回复时直接禁用保存并显示整条提示「该会话正在回复中…」,每 3 秒复查一次,回复结束自动解除。
297
+ - **失败提示变醒目**:in-place 的 `.dshet-error` 从底栏左侧小字改成整条横幅(左右 8px、底 52px、带边框/阴影)。
298
+ - **确认步不再死循环**:保存键处理函数改读实时 controller 状态;确认步给按钮加可见强调圈;`/apply` 增加 20 秒超时,宿主挂住时恢复按钮并报错。
299
+ - **从「全部」切回单块不再残留**:选择器切回某一块时没有把 `allBlocks` 置回 false,重绘又画成「全部」视图、选择器却写着「正文」(两个输入框 + 单块选择)。现在切回单块会明确退出全部视图,`allBlocks` 也进了编辑器重建标记;选到不存在的块时同样退出全部视图并保留原先选中的块。
300
+ - **思考行找对了**:官方把 reasoning 画在**它自己那一行**(flow key 带 `,reasoning` 分组),而按 seq 找到的行是**正文行** —— 旧代码只在那一行里找,所以思维链改写永远落不到 DOM(正文改写正常,因为正文就在那一行)。现在思维链的搜索改为**在整个会话里找“文本等于记录原文”的最深节点**,并和其它块一样登记 `data-dshet-patch`(可回退)。
301
+ - **折叠的思考也能看到改写**:思考面板收起时正文根本不在 DOM 里,而头部那行 `思考 · <截断片段>` 是宿主按**记录的原文**画的 —— 于是改了模型上下文、界面却像没生效。现在保存后连**头部片段**一起按改写后的文本重画(保留宿主的省略号与截断长度、不碰它自己的标签)。
302
+ - **长内容不再被底栏压住**:输入框高度原来只减了顶栏(`height - offsetTop - 8`),底部 40px 落在控制带下面 —— 框内能滚动,但最后几行永远被 X/保存那条带子盖住。现在按底栏实际高度(`offsetHeight`,取不到时用 46px 常量)预留,输入框正好停在控制带上方;内容更高时在框内滚动。
303
+ - **测试**:`npm test` **235/235**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
304
+
257
305
  **0.5.0** —— 从「一个气泡的编辑器」长成「整轮可编辑 + 可导航的文档」。
258
306
 
259
307
  - **整轮所有块**:一条回答里每个 step 的 reasoning 与每个 text 都进 picker,同类自动编号(思维链 1 / 正文 1 …),每块带自己的 seq,保存时 patch 到它所在的消息。
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.0'
48
+ const PLUGIN_VERSION = '0.5.2'
49
49
 
50
50
  // --- copy -----------------------------------------------------------------
51
51
 
@@ -103,7 +103,6 @@ window.__ModuleLoader__.load({
103
103
  'editor.block.reasoning': '思维链',
104
104
  'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
105
105
  'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
106
- 'editor.patch-note': '思维链已改写(模型可见)',
107
106
  'editor.review': '确认执行',
108
107
  'editor.cancel': '取消',
109
108
  'editor.pending': '正在保存…',
@@ -132,6 +131,7 @@ window.__ModuleLoader__.load({
132
131
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
133
132
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
134
133
  'error.internal': '服务器内部错误,请稍后重试。',
134
+ 'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
135
135
  'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
136
136
  'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
137
137
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
@@ -189,7 +189,6 @@ window.__ModuleLoader__.load({
189
189
  'editor.block.reasoning': 'Reasoning',
190
190
  'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
191
191
  'confirm.body-block': 'Confirming replaces only this block; tool calls, the rest of the reply and later turns stay. The original session log is never rewritten.',
192
- 'editor.patch-note': 'Reasoning patched (model-visible)',
193
192
  'editor.review': 'Confirm',
194
193
  'editor.cancel': 'Cancel',
195
194
  'editor.pending': 'Working...',
@@ -218,6 +217,7 @@ window.__ModuleLoader__.load({
218
217
  'error.already-retired': 'This turn is no longer part of the current context.',
219
218
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
220
219
  'error.internal': 'The host hit an internal error; try again shortly.',
220
+ 'error.projection-missing': 'The edit projection is not registered; restart DSH and retry.',
221
221
  'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
222
222
  'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
223
223
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
@@ -237,6 +237,92 @@ window.__ModuleLoader__.load({
237
237
  }
238
238
 
239
239
  // --- style ----------------------------------------------------------------
240
+ /**
241
+ * Take the active skin's own surfaces when they are actually opaque.
242
+ *
243
+ * A skin may paint its surfaces translucent so artwork shows through, and it
244
+ * compensates with its own text halo - which a plugin element does not have.
245
+ * So the theme's surfaces are adopted only after resolving them to a real
246
+ * colour and finding them opaque; a translucent skin leaves this editor on
247
+ * its own opaque palette. Colours, radii and shadows need no such guard:
248
+ * those are hue and geometry, and they survive any skin.
249
+ *
250
+ * The result is written as inline variables on `body`, which beats both the
251
+ * `:root` defaults and the dark-theme rule below them.
252
+ *
253
+ * @returns nothing; the stylesheet defaults stand when no probe is possible.
254
+ */
255
+ function resolveSkinPalette() {
256
+ if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
257
+ if (typeof getComputedStyle !== 'function') return
258
+ const probe = document.createElement('span')
259
+ probe.setAttribute('aria-hidden', 'true')
260
+ probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
261
+ document.body.appendChild(probe)
262
+ const read = (name) => {
263
+ try {
264
+ probe.style.color = `var(${name})`
265
+ const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
266
+ if (match === null) return null
267
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
268
+ if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
269
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+ const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
275
+ const style = document.body.style
276
+ const setVar = (name, value) => {
277
+ if (style === undefined || style === null) return
278
+ if (typeof style.setProperty === 'function') style.setProperty(name, value)
279
+ else style[name] = value
280
+ }
281
+ const clearVar = (name) => {
282
+ if (style === undefined || style === null) return
283
+ if (typeof style.removeProperty === 'function') style.removeProperty(name)
284
+ else delete style[name]
285
+ }
286
+ try {
287
+ const panel = read('--dsw-alias-bg-layer-1')
288
+ const layer2 = read('--dsw-alias-bg-layer-2')
289
+ const accent = read('--dsw-alias-button-primary-fill')
290
+ // The skin's surface HUE is taken; its transparency is not. A skin paints
291
+ // its own elements translucent over artwork and gives them a text halo -
292
+ // a plugin element has none, so the same colour is adopted at full
293
+ // opacity. That keeps a dark artwork skin dark (instead of falling back
294
+ // to this plugin's light palette, which is what a skin that never sets
295
+ // `data-ds-dark-theme` used to get) without ever painting text on art.
296
+ const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
297
+ const panelSolid = solid(panel)
298
+ if (panelSolid === null) {
299
+ clearVar('--dshet-panel')
300
+ clearVar('--dshet-chip')
301
+ clearVar('--dshet-field')
302
+ } else {
303
+ const fieldSolid = solid(layer2) ?? panelSolid
304
+ setVar('--dshet-panel', css(panelSolid))
305
+ setVar('--dshet-chip', css(panelSolid))
306
+ setVar('--dshet-field', css(fieldSolid))
307
+ }
308
+ if (accent === null) {
309
+ clearVar('--dshet-on-accent')
310
+ } else {
311
+ // The label that sits ON the accent fill: the platform exposes no
312
+ // inverse token here, so it is picked from the fill's own luminance.
313
+ const channel = (value) => {
314
+ const v = value / 255
315
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
316
+ }
317
+ const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
318
+ setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
319
+ }
320
+ } finally {
321
+ if (typeof probe.remove === 'function') probe.remove()
322
+ }
323
+ }
324
+
325
+
240
326
 
241
327
  // A skin is free to make the theme's surface colours translucent - that is
242
328
  // the point of one: the artwork shows through, and the skin compensates by
@@ -251,8 +337,21 @@ window.__ModuleLoader__.load({
251
337
  // UI packages use. Theme variables are still used for *colours* (hue,
252
338
  // semantic tones) - those survive a skin; it is alpha that does not.
253
339
  const CSS = [
254
- ':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
255
- 'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
340
+ // Colours, radii and shadows come from the active theme's own tokens, so a
341
+ // skin restyles this editor the way it restyles the rest of the app; every
342
+ // one of them falls back to the value this plugin shipped with. The palette
343
+ // hangs off `body`, not `:root`: a custom property substitutes against the
344
+ // values in scope where it is DEFINED, and the theme declares its tokens on
345
+ // body - on `:root` every reference would silently fall back. Only the
346
+ // SURFACES stay namespaced: a skin is free to make its own surfaces
347
+ // translucent (that is the point of one), and a plugin's element has no
348
+ // artwork-aware halo to stay readable behind. The surfaces are taken from
349
+ // the theme too, but only when a runtime probe finds them opaque (see
350
+ // resolveSkinPalette).
351
+ 'body{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:var(--dsw-alias-border-l2,rgba(16,24,40,.16));--dshet-ink:var(--dsw-alias-label-primary,#0f1524);--dshet-ink-dim:var(--dsw-alias-label-secondary,#4b5872);--dshet-ink-soft:var(--dsw-alias-label-tertiary,#81858c);--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(9,18,40,.22));--dshet-accent:var(--dsw-alias-button-primary-fill,#4d6bfe);--dshet-on-accent:#fff;--dshet-bubble:var(--dsw-specific-bubble,var(--dshet-field));--dshet-radius-panel:var(--dsw-radius-panel,16px);--dshet-radius-xl:var(--dsw-radius-xl,16px);--dshet-radius-lg:var(--dsw-radius-lg,16px);--dshet-radius-md:var(--dsw-radius-md,12px);--dshet-radius-sm:var(--dsw-radius-sm,8px)}',
352
+ 'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.5))}',
353
+ // A hover wash that follows the skin's ink instead of a fixed grey.
354
+ '@supports (color:color-mix(in srgb,red,blue)){body{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent)}body[data-ds-dark-theme]{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent)}}',
256
355
  // The pencil sits among the platform's own action icons, so it takes the
257
356
  // platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
258
357
  // read as a different, "active" icon next to the neutral grey ones. The
@@ -278,7 +377,7 @@ window.__ModuleLoader__.load({
278
377
  // inherit picks up the row's 16px, fixed ignores the reader's content
279
378
  // font setting, and either way the rewritten text changed size against
280
379
  // the bubble it replaces.
281
- '.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:16px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
380
+ '.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:var(--dshet-radius-xl);background:var(--dshet-bubble);color:var(--dshet-ink);font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
282
381
  '.dshet-revision:empty{display:none}',
283
382
  '.dshet-revision{position:relative}',
284
383
  // Fallback only: a row with no action bar to keep is displayed away
@@ -289,7 +388,7 @@ window.__ModuleLoader__.load({
289
388
  '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
290
389
  '.dshet-revision-text{display:block}',
291
390
  '.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
292
- '.dshet-revision-file{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 6px 2px 2px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-chip);font-size:12px;line-height:18px}',
391
+ '.dshet-revision-file{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 6px 2px 2px;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-sm);background:var(--dshet-chip);font-size:12px;line-height:18px}',
293
392
  '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
294
393
  '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
295
394
  // A sibling plugin can append its own button (dsh-delete-turn's bin) into
@@ -331,15 +430,16 @@ window.__ModuleLoader__.load({
331
430
  '.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
332
431
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
333
432
  '.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
334
- '.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain;margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
433
+ '.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain;margin:0;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-panel);background:var(--dshet-panel);backdrop-filter:blur(var(--dsh-input-card-blur,18px)) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
335
434
  '.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
336
435
  '.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
337
- '.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:12px;background:var(--dshet-panel);backdrop-filter:none;box-shadow:none}',
436
+ '.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:var(--dshet-radius-md);background:var(--dshet-panel);backdrop-filter:none;box-shadow:none}',
338
437
  '.dshet-editor[data-dshet-inplace="1"] textarea{position:absolute;min-height:0;max-height:none;padding:6px 10px;border:0;border-radius:8px;background:transparent;overflow-y:auto}',
339
- '.dshet-editor[data-dshet-inplace="1"] .dshet-footer{position:absolute;top:auto;left:0;right:0;bottom:0;height:46px;align-items:center;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 12px 12px;gap:6px}',
438
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-footer{position:absolute;top:auto;left:0;right:0;bottom:0;height:46px;align-items:center;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 var(--dshet-radius-md) var(--dshet-radius-md);gap:6px}',
340
439
  '.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
341
440
  '.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
342
- '.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn,.dshet-editor[data-dshet-inplace="1"] .dshet-error{position:absolute;left:8px;bottom:6px;max-width:60%;margin:0;background:var(--dshet-panel);border-radius:8px;padding:2px 6px}',
441
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn{position:absolute;left:8px;bottom:6px;max-width:60%;margin:0;background:var(--dshet-panel);border-radius:8px;padding:2px 6px}',
442
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-error{position:absolute;left:8px;right:8px;bottom:52px;max-width:none;margin:0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);box-shadow:var(--dshet-shadow);font-weight:500}',
343
443
 
344
444
  '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:min(46vh,420px);overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
345
445
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
@@ -368,7 +468,7 @@ window.__ModuleLoader__.load({
368
468
  '.dshet-note{color:var(--dshet-ink-dim)}',
369
469
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
370
470
  '.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
371
- '.dshet-footer{position:absolute;left:0;right:0;bottom:0;top:auto;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:6px;height:46px;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 16px 16px}',
471
+ '.dshet-footer{position:absolute;left:0;right:0;bottom:0;top:auto;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:6px;height:46px;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 var(--dshet-radius-panel) var(--dshet-radius-panel)}',
372
472
  '.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
373
473
  '.dshet-all-nav{position:sticky;top:0;z-index:2;display:flex;flex-wrap:nowrap;align-items:center;gap:6px;min-height:28px;padding:4px 0 6px;background:var(--dshet-panel);overflow-x:auto;scrollbar-width:none}',
374
474
  '.dshet-all-nav button{flex:none;border:0;border-radius:999px;background:var(--dshet-hover);color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;padding:3px 10px;cursor:pointer;white-space:nowrap}',
@@ -392,10 +492,11 @@ window.__ModuleLoader__.load({
392
492
  '.dshet-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}',
393
493
  '.dshet-btn-primary{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent));background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));color:var(--dsw-alias-label-primary-foreground,var(--dshet-on-accent))}',
394
494
  '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
395
- '.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
495
+ '.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dshet-ink-soft);filter:none}',
396
496
  '.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
497
+ '.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
397
498
  '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
398
- '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:#81858c;filter:none}',
499
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
399
500
  '.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
400
501
  '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
401
502
  '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
@@ -403,13 +504,10 @@ window.__ModuleLoader__.load({
403
504
  '.dshet-config{display:flex;flex-direction:column;gap:10px}',
404
505
  '.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
405
506
  '.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
406
- '.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
507
+ '.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-sm);background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
407
508
  '.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
408
509
  '.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
409
510
  '[data-dshet-patch]{white-space:pre-wrap}',
410
- '.dshet-patch-note{margin:6px 0;padding:6px 10px;border:1px dashed var(--dshet-line);border-radius:10px;background:var(--dshet-panel);color:var(--dshet-ink-dim);font-size:12px;line-height:18px}',
411
- '.dshet-patch-note summary{cursor:pointer}',
412
- '.dshet-patch-note-body{margin:6px 0 0;white-space:pre-wrap;color:var(--dshet-ink);font-size:12px;line-height:18px}',
413
511
  '.dshet-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
414
512
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
415
513
  ].join('')
@@ -634,6 +732,7 @@ window.__ModuleLoader__.load({
634
732
  ? data.config.editorStyle
635
733
  : 'frame',
636
734
  surfaceReady: true,
735
+ busy: data.busy === true,
637
736
  loaded: true,
638
737
  loadError: false,
639
738
  confirmStep: !(data.config && data.config.confirm === false),
@@ -648,6 +747,14 @@ window.__ModuleLoader__.load({
648
747
  })
649
748
  .finally(() => {
650
749
  this.inflight = null
750
+ // While the session is replying, the editor stays open but cannot
751
+ // save; re-check so the banner clears itself when the turn ends.
752
+ if (this.view.busy === true && this.editor !== null) {
753
+ const token = (this.busyToken = (this.busyToken ?? 0) + 1)
754
+ window.setTimeout(() => {
755
+ if (this.busyToken === token && this.editor !== null) this.load()
756
+ }, 3000)
757
+ }
651
758
  })
652
759
  this.inflight = pending
653
760
  return pending
@@ -769,19 +876,27 @@ window.__ModuleLoader__.load({
769
876
  this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
770
877
  return
771
878
  }
772
- this.editorAll = false
773
879
  const split = raw.includes(':') ? raw.split(':') : ['', raw]
774
880
  const seq = split[0] === '' ? null : Number(split[0])
775
881
  const index = Number(split[1])
776
882
  const block = target.blocks.find((entry) =>
777
883
  entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
778
- if (block === undefined || block.kind === undefined) return
884
+ // Leaving the all-blocks view has to say so: the picker change alone left
885
+ // `allBlocks` true, so the box was redrawn as the all view again with a
886
+ // picker that said "正文" - two fields over a single-block selection.
887
+ this.editorAll = false
888
+ if (block === undefined || block.kind === undefined) {
889
+ // Nothing matched: still leave the all view, keeping the block that was
890
+ // selected before it, so the picker describes the box again.
891
+ this.publish({ allBlocks: false, failure: null })
892
+ return
893
+ }
779
894
  this.editorBlockIndex = block.index
780
895
  this.editorBlockKind = block.kind
781
896
  this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
782
897
  this.editorDraft = block.text
783
898
  this.editorElement = null
784
- this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, failure: null })
899
+ this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
785
900
  }
786
901
 
787
902
  close() {
@@ -905,16 +1020,21 @@ window.__ModuleLoader__.load({
905
1020
  this.publish({ editing: null })
906
1021
  return
907
1022
  }
908
- this.publish({ pending: true, failure: null })
1023
+ this.publish({ pending: true, failure: null, failureDetail: null })
909
1024
  try {
910
1025
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
911
1026
  method: 'POST',
912
1027
  headers: { 'content-type': 'application/json' },
1028
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1029
+ ? { signal: AbortSignal.timeout(20000) }
1030
+ : {}),
913
1031
  body: JSON.stringify({ sessionId: this.sessionId, targets }),
914
1032
  })
915
1033
  const data = await res.json().catch(() => ({}))
916
1034
  if (!res.ok || !data.ok) {
917
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1035
+ const code = data && data.code ? String(data.code) : 'generic'
1036
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1037
+ this.publish({ pending: false, failure: code, failureDetail: detail })
918
1038
  return
919
1039
  }
920
1040
  this.editor = null
@@ -983,11 +1103,16 @@ window.__ModuleLoader__.load({
983
1103
  const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
984
1104
  ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
985
1105
  : undefined
986
- this.publish({ pending: true, failure: null })
1106
+ this.publish({ pending: true, failure: null, failureDetail: null })
987
1107
  try {
988
1108
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
989
1109
  method: 'POST',
990
1110
  headers: { 'content-type': 'application/json' },
1111
+ // A hung host must not leave the editor pending forever with a
1112
+ // button that looks dead.
1113
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1114
+ ? { signal: AbortSignal.timeout(20000) }
1115
+ : {}),
991
1116
  body: JSON.stringify({
992
1117
  sessionId: this.sessionId,
993
1118
  seq: target.seq,
@@ -1007,7 +1132,9 @@ window.__ModuleLoader__.load({
1007
1132
  })
1008
1133
  const data = await res.json().catch(() => ({}))
1009
1134
  if (!res.ok || !data.ok) {
1010
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1135
+ const code = data && data.code ? String(data.code) : 'generic'
1136
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1137
+ this.publish({ pending: false, failure: code, failureDetail: detail })
1011
1138
  return
1012
1139
  }
1013
1140
  const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
@@ -2229,9 +2356,28 @@ window.__ModuleLoader__.load({
2229
2356
  element.style[property] = value
2230
2357
  }
2231
2358
 
2359
+ /**
2360
+ * The surface an in-place editor paints itself with, so the recorded text
2361
+ * underneath cannot show through the draft.
2362
+ *
2363
+ * The skin's own panel colour comes first. A skin that paints the app on
2364
+ * artwork usually leaves `body` transparent (the artwork is a layer behind
2365
+ * it), and the white fallback then put a white box on a night sky. The
2366
+ * palette keeps the skin's hue and drops its transparency, so what comes back
2367
+ * here is opaque by construction.
2368
+ *
2369
+ * @returns a CSS colour for the editor's own background.
2370
+ */
2232
2371
  function pageBackground() {
2233
2372
  try {
2234
- if (typeof getComputedStyle === 'function' && typeof document !== 'undefined') {
2373
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
2374
+ const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
2375
+ const match = /rgba?\(([^)]+)\)/.exec(panel)
2376
+ if (match !== null) {
2377
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
2378
+ const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
2379
+ if (opaque) return panel
2380
+ }
2235
2381
  const value = getComputedStyle(document.body).backgroundColor
2236
2382
  if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
2237
2383
  }
@@ -2659,7 +2805,15 @@ window.__ModuleLoader__.load({
2659
2805
  // Reserve a header band for the controls; when the prompt carries image
2660
2806
  // chips they sit above the text, so the text starts below them too.
2661
2807
  const offsetTop = headerOffset
2662
- const available = Math.max(40, height - offsetTop - 8)
2808
+ // The control band is painted over the box's bottom edge, so the field
2809
+ // has to stop above it: the old `height - offsetTop - 8` let the field
2810
+ // run 40px under the footer, and the last lines of a long block were
2811
+ // covered for good (the field scrolled, the footer never moved).
2812
+ const footerHeight =
2813
+ footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
2814
+ ? footerEl.offsetHeight
2815
+ : FOOTER_BAND
2816
+ const available = Math.max(40, height - offsetTop - footerHeight)
2663
2817
  area.style.position = 'absolute'
2664
2818
  area.style.boxSizing = 'border-box'
2665
2819
  area.style.left = `${Math.round(offsetLeft)}px`
@@ -2840,6 +2994,9 @@ window.__ModuleLoader__.load({
2840
2994
  // saving would write, so both have to redraw the strip - and the chip
2841
2995
  // keys are enough to tell one list from another.
2842
2996
  view.parts.map((part) => part.key).join('.'),
2997
+ // The mode itself: leaving the all-blocks view must rebuild even when the
2998
+ // single block that comes back is the one that was selected before it.
2999
+ view.allBlocks === true ? 'all' : 'one',
2843
3000
  // The selected reply block: switching blocks has to rebuild the box.
2844
3001
  view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
2845
3002
  view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
@@ -3099,13 +3256,15 @@ window.__ModuleLoader__.load({
3099
3256
  if (event.isComposing === true || event.keyCode === 229) return
3100
3257
  if (event.key === 'Enter' && event.shiftKey !== true) {
3101
3258
  event.preventDefault()
3102
- if (view.confirming) controller.confirm()
3259
+ const live = liveView()
3260
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
3103
3261
  else controller.review()
3104
3262
  return
3105
3263
  }
3106
3264
  if (event.key === 'Escape') {
3107
3265
  event.preventDefault()
3108
- if (view.confirming) controller.back()
3266
+ const live = liveView()
3267
+ if (live !== null && live !== undefined && live.confirming === true) controller.back()
3109
3268
  else controller.close()
3110
3269
  }
3111
3270
  })
@@ -3234,14 +3393,34 @@ window.__ModuleLoader__.load({
3234
3393
  warn.textContent = texts.warn
3235
3394
  box.appendChild(warn)
3236
3395
  }
3237
- if (typeof view.failure === 'string' && view.failure !== '') {
3396
+ // A running turn only blocks the paths that move the surface (a prompt
3397
+ // rewrite, or a reply edit on a host without the in-place record). The
3398
+ // in-place record appends one event and changes nothing that is already
3399
+ // on the surface, so waiting for the agent to finish is not required -
3400
+ // the model picks the edit up on its next fold.
3401
+ const inPlaceEdit = target.mode === 'reply' && target.patch === true
3402
+ const busyBlocked = view.busy === true && !inPlaceEdit
3403
+ const failureCode = typeof view.failure === 'string' && view.failure !== ''
3404
+ ? view.failure
3405
+ : busyBlocked
3406
+ ? 'busy'
3407
+ : null
3408
+ if (failureCode !== null) {
3238
3409
  const error = document.createElement('p')
3239
3410
  error.className = 'dshet-error'
3240
3411
  error.setAttribute('role', 'status')
3241
- error.textContent = errorText(t, view.failure)
3412
+ const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
3413
+ error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
3414
+ ? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3415
+ : errorText(t, failureCode)
3242
3416
  box.appendChild(error)
3243
3417
  }
3244
3418
 
3419
+ // Handlers must read the CURRENT controller state: the host may not have
3420
+ // rebuilt the button after the first press, and a stale `view.confirming`
3421
+ // made the confirmation step a dead end (press again -> review again).
3422
+ const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
3423
+
3245
3424
  const footer = document.createElement('div')
3246
3425
  footer.className = 'dshet-footer'
3247
3426
  const footerActions = document.createElement('div')
@@ -3282,11 +3461,17 @@ window.__ModuleLoader__.load({
3282
3461
  submit.appendChild(submitText)
3283
3462
  submit.title = `${submitLabel} (Enter)`
3284
3463
  submit.setAttribute('aria-label', submitLabel)
3285
- submit.disabled = view.pending
3464
+ submit.disabled = view.pending || busyBlocked
3286
3465
  pressable(submit, () => {
3287
- if (view.confirming) controller.confirm()
3466
+ const live = liveView()
3467
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
3288
3468
  else controller.review()
3289
3469
  })
3470
+ if (view.confirming === true) {
3471
+ // The save label is visually hidden, so the confirmation step needs a
3472
+ // visible state of its own or the first press looks like nothing.
3473
+ submit.classList.add('dshet-btn-confirm')
3474
+ }
3290
3475
 
3291
3476
  footerActions.appendChild(cancel)
3292
3477
  // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
@@ -3377,8 +3562,11 @@ window.__ModuleLoader__.load({
3377
3562
  // collapsed panel that may not be in the DOM at all.
3378
3563
 
3379
3564
  const PATCH_ATTR = 'data-dshet-patch'
3380
- const NOTE_ATTR = 'data-dshet-patch-note'
3381
3565
  const patchOriginals = new WeakMap()
3566
+ // The text this plugin last painted for a key, so a second edit of the same
3567
+ // block can still find the element after the first text replaced the
3568
+ // recorded original in the DOM.
3569
+ const patchedTexts = new Map()
3382
3570
  // React may re-render the assistant container and reset its attributes and
3383
3571
  // style while leaving the text node we replaced. Remember the element by
3384
3572
  // seq:block so the next pass can restore the marker and, on a later edit,
@@ -3456,10 +3644,11 @@ window.__ModuleLoader__.load({
3456
3644
  // element in the owning row whose whole text is exactly the recorded text;
3457
3645
  // never a button, a timestamp, our own chrome, or an ancestor that merely
3458
3646
  // contains the text.
3459
- function findPatchTextFallback(owner, wanted) {
3647
+ function findPatchTextFallback(owner, wanted, previousText = '') {
3460
3648
  if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
3461
3649
  const original = matchText(wanted.original)
3462
- if (original === '') return null
3650
+ const previous = matchText(previousText)
3651
+ if (original === '' && previous === '') return null
3463
3652
  let scope = owner
3464
3653
  try {
3465
3654
  const node = owner.querySelector('[data-slot="conversation.chat.node"]')
@@ -3471,12 +3660,12 @@ window.__ModuleLoader__.load({
3471
3660
  if (el === scope) continue
3472
3661
  if (el.getAttribute(PATCH_ATTR) !== null) continue
3473
3662
  try {
3474
- if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
3663
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3475
3664
  } catch {
3476
3665
  continue
3477
3666
  }
3478
3667
  const text = matchText(el.textContent)
3479
- if (text !== original) continue
3668
+ if (text !== original && !(previous !== '' && text === previous)) continue
3480
3669
  let deeper = false
3481
3670
  for (const child of el.children ?? []) {
3482
3671
  if (matchText(child.textContent) === text) {
@@ -3489,6 +3678,97 @@ window.__ModuleLoader__.load({
3489
3678
  return null
3490
3679
  }
3491
3680
 
3681
+ /**
3682
+ * The collapsed reasoning header draws its own truncation of the RECORDED
3683
+ * text ("思考 · ..."), and when the panel is closed the body is not in the
3684
+ * DOM at all - so a patch of the reasoning body alone leaves the edit
3685
+ * invisible. Rewrite that snippet too, keeping whatever the host appended
3686
+ * (its ellipsis) and its own truncation length.
3687
+ */
3688
+ function patchReasoningSummary(owner, wanted) {
3689
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
3690
+ const original = String(wanted.original ?? '')
3691
+ const patched = String(wanted.text ?? '')
3692
+ if (original === '' || patched === '') return false
3693
+ const head = original.slice(0, 12)
3694
+ let changed = false
3695
+ const apply = (get, set) => {
3696
+ const data = String(get() ?? '')
3697
+ if (data === '' || !data.includes(head)) return
3698
+ // A node holding the whole text gets the whole replacement; a truncated
3699
+ // snippet keeps the host's length and gains an ellipsis when the new
3700
+ // text is longer.
3701
+ const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
3702
+ if (next === data) return
3703
+ set(next)
3704
+ changed = true
3705
+ }
3706
+ const walk = (node) => {
3707
+ for (const child of node.childNodes ?? []) {
3708
+ const type = child.nodeType
3709
+ if (type === 3) {
3710
+ apply(() => child.data, (value) => { child.data = value })
3711
+ continue
3712
+ }
3713
+ if (type !== 1) continue
3714
+ try {
3715
+ if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3716
+ } catch {
3717
+ /* a node without closest is walked anyway */
3718
+ }
3719
+ const kids = child.childNodes ?? []
3720
+ if (kids.length === 0) {
3721
+ apply(() => child.textContent, (value) => { child.textContent = value })
3722
+ continue
3723
+ }
3724
+ walk(child)
3725
+ }
3726
+ }
3727
+ walk(owner)
3728
+ return changed
3729
+ }
3730
+
3731
+ /**
3732
+ * The reasoning body on this host is its own row (the flow key carries a
3733
+ * `,reasoning` group part), so neither the answer row nor the first chat node
3734
+ * the shared fallback picks contains it. Search the whole conversation for
3735
+ * the deepest element whose text is the recorded original (or what we last
3736
+ * painted there).
3737
+ */
3738
+ function findReasoningElement(root, wanted, previousText) {
3739
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
3740
+ const original = matchText(wanted.original)
3741
+ const previous = matchText(previousText)
3742
+ if (original === '' && previous === '') return null
3743
+ let best = null
3744
+ let bestScore = Infinity
3745
+ for (const el of root.querySelectorAll('*')) {
3746
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
3747
+ try {
3748
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3749
+ } catch {
3750
+ /* a detached node is still a candidate */
3751
+ }
3752
+ const text = matchText(el.textContent)
3753
+ if (text === '') continue
3754
+ if (text !== original && !(previous !== '' && text === previous)) continue
3755
+ let deeper = false
3756
+ for (const child of el.children ?? []) {
3757
+ if (matchText(child.textContent) === text) {
3758
+ deeper = true
3759
+ break
3760
+ }
3761
+ }
3762
+ if (deeper) continue
3763
+ const score = Math.abs(text.length - original.length)
3764
+ if (score < bestScore) {
3765
+ best = el
3766
+ bestScore = score
3767
+ }
3768
+ }
3769
+ return best
3770
+ }
3771
+
3492
3772
  function applyPatchDisplay(snapshot, view, t) {
3493
3773
  // Map a reply seq to the host row that draws it, so a short answer like
3494
3774
  // "好的" cannot be matched in a different row. Fall back to a global
@@ -3522,7 +3802,7 @@ window.__ModuleLoader__.load({
3522
3802
  if (root === null) return
3523
3803
  const replies = view.replies instanceof Map ? view.replies : new Map()
3524
3804
  const wantedText = new Map()
3525
- const wantedNotes = new Map()
3805
+ const wantedReasoning = new Map()
3526
3806
  for (const patch of Array.isArray(view.patches) ? view.patches : []) {
3527
3807
  const reply = replies.get(patch.seq)
3528
3808
  if (reply === undefined || !Array.isArray(reply.blocks)) continue
@@ -3536,8 +3816,10 @@ window.__ModuleLoader__.load({
3536
3816
  text: String(patch.text ?? ''),
3537
3817
  })
3538
3818
  } else if (patch.kind === 'reasoning') {
3539
- wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
3819
+ wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
3540
3820
  key: `${patch.seq}:${patch.blockIndex}`,
3821
+ seq: patch.seq,
3822
+ original: String(block.original ?? ''),
3541
3823
  text: String(patch.text ?? ''),
3542
3824
  reply,
3543
3825
  block,
@@ -3548,7 +3830,7 @@ window.__ModuleLoader__.load({
3548
3830
  // A patch that no longer exists must give the host's recorded text back,
3549
3831
  // even when a re-render already stripped our marker from the element.
3550
3832
  for (const [key, el] of [...patchedElements]) {
3551
- if (wantedText.has(key)) continue
3833
+ if (wantedText.has(key) || wantedReasoning.has(key)) continue
3552
3834
  patchedElements.delete(key)
3553
3835
  if (el.isConnected === true) restorePatchedBlock(el)
3554
3836
  }
@@ -3566,18 +3848,14 @@ window.__ModuleLoader__.load({
3566
3848
  el.textContent = shown
3567
3849
  }
3568
3850
  }
3569
- for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
3570
- const key = note.getAttribute(NOTE_ATTR)
3571
- if (!wantedNotes.has(key)) note.remove()
3572
- }
3573
- if (wantedText.size === 0 && wantedNotes.size === 0) return
3851
+ if (wantedText.size === 0 && wantedReasoning.size === 0) return
3574
3852
 
3575
3853
  const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
3576
3854
  const containers = []
3577
3855
  for (const node of nodes) {
3578
3856
  for (const el of node.querySelectorAll('*')) {
3579
3857
  try {
3580
- if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
3858
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
3581
3859
  } catch {
3582
3860
  /* a detached node is skipped */
3583
3861
  }
@@ -3608,7 +3886,8 @@ window.__ModuleLoader__.load({
3608
3886
  continue
3609
3887
  }
3610
3888
  const original = matchText(wanted.original)
3611
- if (original === '') continue
3889
+ const previous = matchText(patchedTexts.get(key) ?? '')
3890
+ if (original === '' && previous === '') continue
3612
3891
  let best = null
3613
3892
  let bestScore = Infinity
3614
3893
  for (const el of containers) {
@@ -3622,7 +3901,7 @@ window.__ModuleLoader__.load({
3622
3901
  }
3623
3902
  const text = matchText(el.textContent)
3624
3903
  if (text === '') continue
3625
- const exact = text === original
3904
+ const exact = text === original || (previous !== '' && text === previous)
3626
3905
  const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
3627
3906
  if (!exact && !prefix) continue
3628
3907
  // Prefer the exact match, then the container closest in length: a
@@ -3634,47 +3913,90 @@ window.__ModuleLoader__.load({
3634
3913
  bestScore = score
3635
3914
  }
3636
3915
  }
3637
- if (best === null) best = findPatchTextFallback(owner, wanted)
3916
+ if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
3638
3917
  if (best === null) continue
3639
3918
  patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
3640
3919
  best.textContent = displayText(wanted.text)
3641
3920
  best.setAttribute(PATCH_ATTR, key)
3642
3921
  best.style.whiteSpace = 'pre-wrap'
3643
3922
  patchedElements.set(key, best)
3923
+ patchedTexts.set(key, wanted.text)
3644
3924
  }
3645
3925
 
3646
- // Reasoning notes: put one under the reply row, found by the reply's own
3647
- // answer text (the panel itself may be collapsed and absent).
3648
- for (const [key, wanted] of wantedNotes) {
3649
- let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
3650
- let row = rowBySeq.get(wanted.reply.seq) ?? null
3651
- const replyText = matchText(wanted.reply.text)
3652
- for (const node of row === null ? nodes : []) {
3653
- const text = matchText(node.textContent)
3654
- if (text === '') continue
3655
- if (replyText !== '' && text.includes(replyText)) {
3656
- row = node
3657
- break
3926
+ // A reasoning patch is drawn in place when the host renders that
3927
+ // reasoning body; the note is only the fallback for a panel that is not
3928
+ // in the DOM (or is drawn as a short summary without the full text).
3929
+ for (const [key, wanted] of wantedReasoning) {
3930
+ const owner = rowBySeq.get(wanted.seq)
3931
+ const remembered = patchedElements.get(key)
3932
+ 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
3658
3944
  }
3659
- if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
3660
- row = node
3661
- break
3945
+ patchedElements.delete(key)
3946
+ }
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
3951
+ }
3952
+ const original = matchText(wanted.original)
3953
+ const previous = matchText(patchedTexts.get(key) ?? '')
3954
+ const previousText = patchedTexts.get(key) ?? ''
3955
+ let best = null
3956
+ if (original !== '' || previous !== '') {
3957
+ // The owning row first (a host that draws the reasoning inside the
3958
+ // answer row), then the whole conversation: on this host the reasoning
3959
+ // is its own row, so the answer row holds no reasoning text at all.
3960
+ for (const scope of owner === undefined ? [null] : [owner, null]) {
3961
+ let bestScore = Infinity
3962
+ for (const el of containers) {
3963
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
3964
+ if (scope !== null) {
3965
+ try {
3966
+ if (el.closest('[data-chat-flow-key]') !== scope) continue
3967
+ } catch {
3968
+ /* keep the global match */
3969
+ }
3970
+ }
3971
+ const text = matchText(el.textContent)
3972
+ if (text === '') continue
3973
+ 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
+ }
3979
+ }
3980
+ if (best !== null) break
3662
3981
  }
3982
+ if (best === null) best = findPatchTextFallback(owner ?? null, wanted, previousText)
3983
+ if (best === null) best = findReasoningElement(root, wanted, previousText)
3663
3984
  }
3664
- if (note === undefined || note === null) {
3665
- note = document.createElement('details')
3666
- note.className = 'dshet-patch-note'
3667
- note.setAttribute(NOTE_ATTR, key)
3668
- const summary = document.createElement('summary')
3669
- summary.textContent = t('editor.patch-note')
3670
- const body = document.createElement('pre')
3671
- body.className = 'dshet-patch-note-body'
3672
- note.appendChild(summary)
3673
- note.appendChild(body)
3985
+ if (best === null) {
3986
+ // No body in the DOM (the panel is closed): the header snippet is the
3987
+ // only place the edit can show.
3988
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
3989
+ patchReasoningSummary(root, wanted)
3990
+ continue
3674
3991
  }
3675
- const body = note.querySelector('.dshet-patch-note-body')
3676
- if (body !== null && body.textContent !== wanted.text) body.textContent = wanted.text
3677
- if (note.isConnected !== true && row !== null) row.after(note)
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)
3997
+ patchedTexts.set(key, wanted.text)
3998
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
3999
+ patchReasoningSummary(root, wanted)
3678
4000
  }
3679
4001
  }
3680
4002
 
@@ -4292,6 +4614,31 @@ window.__ModuleLoader__.load({
4292
4614
  // A reply's edit entry lives in the official assistant-actions strip, one
4293
4615
  // entry per assistant message. order 5 puts it ahead of the feedback
4294
4616
  // entries (order 10), so the pencil sits with the leading icons.
4617
+ // The active skin repaints its surfaces at any time (switching one
4618
+ // rewrites body's attributes and inline variables), so the palette is
4619
+ // resolved once and then again on every theme or skin change.
4620
+ ctx.effect(() => {
4621
+ const run = () => {
4622
+ try {
4623
+ resolveSkinPalette()
4624
+ } catch {
4625
+ /* the stylesheet defaults stand */
4626
+ }
4627
+ }
4628
+ run()
4629
+ const observer =
4630
+ typeof MutationObserver === 'function' && document && document.body
4631
+ ? new MutationObserver(run)
4632
+ : null
4633
+ if (observer !== null) {
4634
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
4635
+ if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
4636
+ }
4637
+ return () => {
4638
+ if (observer !== null) observer.disconnect()
4639
+ }
4640
+ }, 'dsh-edit-turn:skin-palette')
4641
+
4295
4642
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
4296
4643
  ctx.slots.register(
4297
4644
  {
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.0'
83
+ export const PLUGIN_VERSION = '0.5.2'
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
@@ -162,6 +162,10 @@ export const Config =
162
162
  * replay splice otherwise; `patch` refuses to run without the
163
163
  * projection; `replay` always uses the old path. Prompt edits always use
164
164
  * the official surface replacement.
165
+ *
166
+ * There is deliberately no `replace` value: appending a fresh
167
+ * `assistant/message` with a replace op is refused by the session's own
168
+ * surface validation (see the note in the README).
165
169
  */
166
170
  replyMode: z.string().default('auto'),
167
171
  /**
@@ -1868,7 +1872,9 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
1868
1872
  }
1869
1873
  const events = await readEvents(ctx, sessionId)
1870
1874
  if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1871
- if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1875
+ // No busy gate here: this path only appends the in-place patch record, which
1876
+ // leaves every surface node where it is. A running turn keeps writing its own
1877
+ // events; the record lands between them and the fold ignores it.
1872
1878
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1873
1879
  const patches = patchesOf(events)
1874
1880
  const plans = []
@@ -1900,7 +1906,11 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
1900
1906
  buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
1901
1907
  )
1902
1908
  } catch (error) {
1903
- throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
1909
+ const message = String((error && error.message) || error)
1910
+ if (isProjectionUnavailable(message)) {
1911
+ throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
1912
+ }
1913
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
1904
1914
  }
1905
1915
  const flush = await flushSession(ctx, session)
1906
1916
  return {
@@ -1927,22 +1937,23 @@ async function applyEdit(ctx, sessionId, body, config) {
1927
1937
  }
1928
1938
  const events = await readEvents(ctx, sessionId)
1929
1939
  if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1930
- if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1940
+ const busy = isBusy(events)
1931
1941
 
1932
1942
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1933
1943
  const patches = patchesOf(events)
1934
1944
  const patchMode = config.patch === true
1945
+ const request = {
1946
+ // A block from another step of the same turn names its own seq; that is
1947
+ // the message the projection has to patch.
1948
+ seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1949
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1950
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
1951
+ blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1952
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
1953
+ }
1935
1954
  let plan
1936
1955
  try {
1937
- plan = planRollback(events, surfaceNodes, {
1938
- // A block from another step of the same turn names its own seq; that is
1939
- // the message the projection has to patch.
1940
- seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1941
- messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1942
- turn: typeof body.turn === 'number' ? body.turn : undefined,
1943
- blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1944
- kind: typeof body.kind === 'string' ? body.kind : undefined,
1945
- }, { patches, patch: patchMode })
1956
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
1946
1957
  } catch (error) {
1947
1958
  if (error instanceof EditPlanError) {
1948
1959
  const status = error.code === 'not-editable' ? 400 : 409
@@ -1951,6 +1962,13 @@ async function applyEdit(ctx, sessionId, body, config) {
1951
1962
  throw error
1952
1963
  }
1953
1964
 
1965
+ // A running turn is a hard stop for every path that moves the surface: the
1966
+ // prompt rewrite shadows a message the turn may still be reading, and the
1967
+ // replay splice shadows a window and regenerates the tail. The in-place record
1968
+ // below moves nothing, so it is allowed to land while the agent is still
1969
+ // writing - the model reads it on its next fold.
1970
+ if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
1971
+
1954
1972
  // The in-place reply path: one small plugin record, interpreted by this
1955
1973
  // plugin's projection. Nothing leaves the surface, so there is no window to
1956
1974
  // check, no turn to open and no tail to replay. The record is append-only and
@@ -1980,8 +1998,19 @@ async function applyEdit(ctx, sessionId, body, config) {
1980
1998
  buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
1981
1999
  )
1982
2000
  } catch (error) {
1983
- throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
2001
+ const message = String((error && error.message) || error)
2002
+ // The projection is not registered for this session (a live reload that
2003
+ // dropped it). The same edit can land through the replay path, which
2004
+ // needs no projection - so fall through instead of failing the save.
2005
+ if (isProjectionUnavailable(message)) {
2006
+ // The fallback replays the tail, which a running turn forbids.
2007
+ if (busy) throw new HttpError(409, 'busy', 'the session is still working')
2008
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
2009
+ } else {
2010
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
2011
+ }
1984
2012
  }
2013
+ if (plan.patch === true) {
1985
2014
  const flush = await flushSession(ctx, session)
1986
2015
  return {
1987
2016
  kind: 'reply',
@@ -1999,6 +2028,7 @@ async function applyEdit(ctx, sessionId, body, config) {
1999
2028
  shadowed: [],
2000
2029
  ...flush,
2001
2030
  }
2031
+ }
2002
2032
  }
2003
2033
 
2004
2034
  // The live surface is the append authority; a node that vanished between the
@@ -2433,9 +2463,25 @@ function requireSessionId(value) {
2433
2463
  return value
2434
2464
  }
2435
2465
 
2466
+ /**
2467
+ * Whether the host refused a plugin record because its projection is no longer
2468
+ * registered for this session (a live reload that dropped it, or a session
2469
+ * loaded before the owning plugin). Such a save can still go through the replay
2470
+ * path, so it is not fatal.
2471
+ *
2472
+ * @param message - the host's error text.
2473
+ * @returns true when the record's projection is gone.
2474
+ */
2475
+ export function isProjectionUnavailable(message) {
2476
+ return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
2477
+ }
2478
+
2436
2479
  function failure(res, error) {
2437
2480
  const status = error instanceof HttpError ? error.status : 500
2438
2481
  const code = error instanceof HttpError ? error.code : 'internal'
2482
+ // An unexpected throw is the only case the client cannot explain; leave the
2483
+ // stack in the host log so a report can name the cause.
2484
+ if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
2439
2485
  sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2440
2486
  }
2441
2487
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",