dsh-edit-turn 0.5.0 → 0.5.3

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/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.3'
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': '整行旧版',
@@ -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': '正在保存…',
@@ -115,6 +114,7 @@ window.__ModuleLoader__.load({
115
114
  'error.invalid': '请求无效,请刷新后重试。',
116
115
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
117
116
  'error.session-not-found': '找不到该会话的日志。',
117
+ 'error.session-empty': '这个会话还没有内容,暂时没有可编辑的东西。',
118
118
  'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
119
119
  'error.not-editable': '这条消息不支持编辑。',
120
120
  'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
@@ -132,6 +132,7 @@ window.__ModuleLoader__.load({
132
132
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
133
133
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
134
134
  'error.internal': '服务器内部错误,请稍后重试。',
135
+ 'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
135
136
  'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
136
137
  'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
137
138
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
@@ -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',
@@ -189,7 +190,6 @@ window.__ModuleLoader__.load({
189
190
  'editor.block.reasoning': 'Reasoning',
190
191
  'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
191
192
  '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
193
  'editor.review': 'Confirm',
194
194
  'editor.cancel': 'Cancel',
195
195
  'editor.pending': 'Working...',
@@ -201,6 +201,7 @@ window.__ModuleLoader__.load({
201
201
  'error.invalid': 'Invalid request; refresh and try again.',
202
202
  'error.session-not-active': 'This session is not open in DSH; open it first.',
203
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.',
204
205
  'error.busy': 'This session is still replying; wait for it to finish.',
205
206
  'error.not-editable': 'This message cannot be edited.',
206
207
  'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
@@ -218,6 +219,7 @@ window.__ModuleLoader__.load({
218
219
  'error.already-retired': 'This turn is no longer part of the current context.',
219
220
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
220
221
  'error.internal': 'The host hit an internal error; try again shortly.',
222
+ 'error.projection-missing': 'The edit projection is not registered; restart DSH and retry.',
221
223
  'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
222
224
  'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
223
225
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
@@ -236,7 +238,157 @@ 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 ----------------------------------------------------------------
306
+ /**
307
+ * Take the active skin's own surfaces when they are actually opaque.
308
+ *
309
+ * A skin may paint its surfaces translucent so artwork shows through, and it
310
+ * compensates with its own text halo - which a plugin element does not have.
311
+ * So the theme's surfaces are adopted only after resolving them to a real
312
+ * colour and finding them opaque; a translucent skin leaves this editor on
313
+ * its own opaque palette. Colours, radii and shadows need no such guard:
314
+ * those are hue and geometry, and they survive any skin.
315
+ *
316
+ * The result is written as inline variables on `body`, which beats both the
317
+ * `:root` defaults and the dark-theme rule below them.
318
+ *
319
+ * @returns nothing; the stylesheet defaults stand when no probe is possible.
320
+ */
321
+ function resolveSkinPalette() {
322
+ if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
323
+ if (typeof getComputedStyle !== 'function') return
324
+ const probe = document.createElement('span')
325
+ probe.setAttribute('aria-hidden', 'true')
326
+ probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
327
+ document.body.appendChild(probe)
328
+ const read = (name) => {
329
+ try {
330
+ probe.style.color = `var(${name})`
331
+ const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
332
+ if (match === null) return null
333
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
334
+ if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
335
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
336
+ } catch {
337
+ return null
338
+ }
339
+ }
340
+ const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
341
+ const style = document.body.style
342
+ const setVar = (name, value) => {
343
+ if (style === undefined || style === null) return
344
+ if (typeof style.setProperty === 'function') style.setProperty(name, value)
345
+ else style[name] = value
346
+ }
347
+ const clearVar = (name) => {
348
+ if (style === undefined || style === null) return
349
+ if (typeof style.removeProperty === 'function') style.removeProperty(name)
350
+ else delete style[name]
351
+ }
352
+ try {
353
+ const panel = read('--dsw-alias-bg-layer-1')
354
+ const layer2 = read('--dsw-alias-bg-layer-2')
355
+ const accent = read('--dsw-alias-button-primary-fill')
356
+ // The skin's surface HUE is taken; its transparency is not. A skin paints
357
+ // its own elements translucent over artwork and gives them a text halo -
358
+ // a plugin element has none, so the same colour is adopted at full
359
+ // opacity. That keeps a dark artwork skin dark (instead of falling back
360
+ // to this plugin's light palette, which is what a skin that never sets
361
+ // `data-ds-dark-theme` used to get) without ever painting text on art.
362
+ const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
363
+ const panelSolid = solid(panel)
364
+ if (panelSolid === null) {
365
+ clearVar('--dshet-panel')
366
+ clearVar('--dshet-chip')
367
+ clearVar('--dshet-field')
368
+ } else {
369
+ const fieldSolid = solid(layer2) ?? panelSolid
370
+ setVar('--dshet-panel', css(panelSolid))
371
+ setVar('--dshet-chip', css(panelSolid))
372
+ setVar('--dshet-field', css(fieldSolid))
373
+ }
374
+ if (accent === null) {
375
+ clearVar('--dshet-on-accent')
376
+ } else {
377
+ // The label that sits ON the accent fill: the platform exposes no
378
+ // inverse token here, so it is picked from the fill's own luminance.
379
+ const channel = (value) => {
380
+ const v = value / 255
381
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
382
+ }
383
+ const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
384
+ setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
385
+ }
386
+ } finally {
387
+ if (typeof probe.remove === 'function') probe.remove()
388
+ }
389
+ }
390
+
391
+
240
392
 
241
393
  // A skin is free to make the theme's surface colours translucent - that is
242
394
  // the point of one: the artwork shows through, and the skin compensates by
@@ -251,8 +403,21 @@ window.__ModuleLoader__.load({
251
403
  // UI packages use. Theme variables are still used for *colours* (hue,
252
404
  // semantic tones) - those survive a skin; it is alpha that does not.
253
405
  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)}',
406
+ // Colours, radii and shadows come from the active theme's own tokens, so a
407
+ // skin restyles this editor the way it restyles the rest of the app; every
408
+ // one of them falls back to the value this plugin shipped with. The palette
409
+ // hangs off `body`, not `:root`: a custom property substitutes against the
410
+ // values in scope where it is DEFINED, and the theme declares its tokens on
411
+ // body - on `:root` every reference would silently fall back. Only the
412
+ // SURFACES stay namespaced: a skin is free to make its own surfaces
413
+ // translucent (that is the point of one), and a plugin's element has no
414
+ // artwork-aware halo to stay readable behind. The surfaces are taken from
415
+ // the theme too, but only when a runtime probe finds them opaque (see
416
+ // resolveSkinPalette).
417
+ '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)}',
418
+ '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))}',
419
+ // A hover wash that follows the skin's ink instead of a fixed grey.
420
+ '@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
421
  // The pencil sits among the platform's own action icons, so it takes the
257
422
  // platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
258
423
  // read as a different, "active" icon next to the neutral grey ones. The
@@ -278,7 +443,7 @@ window.__ModuleLoader__.load({
278
443
  // inherit picks up the row's 16px, fixed ignores the reader's content
279
444
  // font setting, and either way the rewritten text changed size against
280
445
  // 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}',
446
+ '.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
447
  '.dshet-revision:empty{display:none}',
283
448
  '.dshet-revision{position:relative}',
284
449
  // Fallback only: a row with no action bar to keep is displayed away
@@ -289,7 +454,7 @@ window.__ModuleLoader__.load({
289
454
  '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
290
455
  '.dshet-revision-text{display:block}',
291
456
  '.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}',
457
+ '.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
458
  '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
294
459
  '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
295
460
  // A sibling plugin can append its own button (dsh-delete-turn's bin) into
@@ -331,15 +496,16 @@ window.__ModuleLoader__.load({
331
496
  '.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
332
497
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
333
498
  '.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}',
499
+ '.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
500
  '.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
336
501
  '.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}',
502
+ '.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
503
  '.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}',
504
+ '.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
505
  '.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
341
506
  '.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}',
507
+ '.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}',
508
+ '.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
509
 
344
510
  '.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
511
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
@@ -368,7 +534,7 @@ window.__ModuleLoader__.load({
368
534
  '.dshet-note{color:var(--dshet-ink-dim)}',
369
535
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
370
536
  '.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}',
537
+ '.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
538
  '.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
373
539
  '.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
540
  '.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 +558,11 @@ window.__ModuleLoader__.load({
392
558
  '.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
559
  '.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
560
  '.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}',
561
+ '.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
562
  '.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
563
+ '.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
397
564
  '.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}',
565
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
399
566
  '.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
567
  '.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
568
  '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
@@ -403,13 +570,10 @@ window.__ModuleLoader__.load({
403
570
  '.dshet-config{display:flex;flex-direction:column;gap:10px}',
404
571
  '.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
405
572
  '.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}',
573
+ '.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
574
  '.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
408
575
  '.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
409
576
  '[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
577
  '.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
578
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
415
579
  ].join('')
@@ -476,6 +640,8 @@ window.__ModuleLoader__.load({
476
640
  this.editorStyle = 'frame'
477
641
  this.editorFocus = false
478
642
  this.editorDraft = ''
643
+ this.editorBlockKey = ''
644
+ this.editorDrafts = new Map()
479
645
  // The block list being edited: what the carrier will be rebuilt from.
480
646
  // Every entry is either `{ keep: <index> }` - a block of this message the
481
647
  // user left alone - or `{ add: { data, mediaType?, name } }` for
@@ -634,6 +800,7 @@ window.__ModuleLoader__.load({
634
800
  ? data.config.editorStyle
635
801
  : 'frame',
636
802
  surfaceReady: true,
803
+ busy: data.busy === true,
637
804
  loaded: true,
638
805
  loadError: false,
639
806
  confirmStep: !(data.config && data.config.confirm === false),
@@ -648,6 +815,14 @@ window.__ModuleLoader__.load({
648
815
  })
649
816
  .finally(() => {
650
817
  this.inflight = null
818
+ // While the session is replying, the editor stays open but cannot
819
+ // save; re-check so the banner clears itself when the turn ends.
820
+ if (this.view.busy === true && this.editor !== null) {
821
+ const token = (this.busyToken = (this.busyToken ?? 0) + 1)
822
+ window.setTimeout(() => {
823
+ if (this.busyToken === token && this.editor !== null) this.load()
824
+ }, 3000)
825
+ }
651
826
  })
652
827
  this.inflight = pending
653
828
  return pending
@@ -695,8 +870,16 @@ window.__ModuleLoader__.load({
695
870
  this.editorBlockKind = chosen === undefined ? null : chosen.kind
696
871
  this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
697
872
  this.editorAll = false
698
- 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()
699
881
  if (!same) {
882
+ this.editorBlockKey = chosen === undefined ? '' : blockDraftKey(chosen)
700
883
  this.editorDraft = chosen === undefined ? target.text : chosen.text
701
884
  this.editorParts = blocksToParts(normalized, this.sessionId)
702
885
  }
@@ -763,25 +946,37 @@ window.__ModuleLoader__.load({
763
946
  if (target === null || !Array.isArray(target.blocks)) return
764
947
  const raw = String(value)
765
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.
766
951
  this.editorAll = true
767
- this.editorAllDrafts = new Map(target.blocks.map((block) => [`${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`, block.text]))
768
952
  this.editorElement = null
769
953
  this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
770
954
  return
771
955
  }
772
- this.editorAll = false
773
956
  const split = raw.includes(':') ? raw.split(':') : ['', raw]
774
957
  const seq = split[0] === '' ? null : Number(split[0])
775
958
  const index = Number(split[1])
776
959
  const block = target.blocks.find((entry) =>
777
960
  entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
778
- if (block === undefined || block.kind === undefined) return
961
+ // Leaving the all-blocks view has to say so: the picker change alone left
962
+ // `allBlocks` true, so the box was redrawn as the all view again with a
963
+ // picker that said "正文" - two fields over a single-block selection.
964
+ this.editorAll = false
965
+ if (block === undefined || block.kind === undefined) {
966
+ // Nothing matched: still leave the all view, keeping the block that was
967
+ // selected before it, so the picker describes the box again.
968
+ this.publish({ allBlocks: false, failure: null })
969
+ return
970
+ }
779
971
  this.editorBlockIndex = block.index
780
972
  this.editorBlockKind = block.kind
781
973
  this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
782
- 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)
783
978
  this.editorElement = null
784
- this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, failure: null })
979
+ this.publish({ draft: this.editorDraft, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
785
980
  }
786
981
 
787
982
  close() {
@@ -803,12 +998,40 @@ window.__ModuleLoader__.load({
803
998
  this.editorBlockKind = null
804
999
  this.editorTargetSeq = null
805
1000
  this.editorAll = false
806
- this.editorAllDrafts = new Map()
1001
+ this.editorDrafts = new Map()
1002
+ this.editorBlockKey = ''
1003
+ this.editorBaseHeight = null
807
1004
  this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
808
1005
  }
809
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
+
810
1024
  setDraft(value) {
811
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)
812
1035
  }
813
1036
 
814
1037
  setEditorStyle(style) {
@@ -886,11 +1109,9 @@ window.__ModuleLoader__.load({
886
1109
  /** The all-blocks save: one request carrying every edited block. */
887
1110
  async confirmAll(target) {
888
1111
  const blocks = Array.isArray(target.blocks) ? target.blocks : []
889
- const drafts = this.editorAllDrafts instanceof Map ? this.editorAllDrafts : new Map()
890
1112
  const targets = []
891
1113
  for (const block of blocks) {
892
- const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
893
- const text = drafts.get(key)
1114
+ const text = this.draftOfBlock(block)
894
1115
  if (typeof text !== 'string' || text.trim() === '') {
895
1116
  this.publish({ failure: 'empty' })
896
1117
  return
@@ -905,22 +1126,27 @@ window.__ModuleLoader__.load({
905
1126
  this.publish({ editing: null })
906
1127
  return
907
1128
  }
908
- this.publish({ pending: true, failure: null })
1129
+ this.publish({ pending: true, failure: null, failureDetail: null })
909
1130
  try {
910
1131
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
911
1132
  method: 'POST',
912
1133
  headers: { 'content-type': 'application/json' },
1134
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1135
+ ? { signal: AbortSignal.timeout(20000) }
1136
+ : {}),
913
1137
  body: JSON.stringify({ sessionId: this.sessionId, targets }),
914
1138
  })
915
1139
  const data = await res.json().catch(() => ({}))
916
1140
  if (!res.ok || !data.ok) {
917
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1141
+ const code = data && data.code ? String(data.code) : 'generic'
1142
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1143
+ this.publish({ pending: false, failure: code, failureDetail: detail })
918
1144
  return
919
1145
  }
920
1146
  this.editor = null
921
1147
  this.editorDraft = ''
922
1148
  this.editorAll = false
923
- this.editorAllDrafts = new Map()
1149
+ this.editorDrafts = new Map()
924
1150
  this.editorElement = null
925
1151
  this.editorBox = null
926
1152
  this.editorRow = null
@@ -983,11 +1209,16 @@ window.__ModuleLoader__.load({
983
1209
  const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
984
1210
  ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
985
1211
  : undefined
986
- this.publish({ pending: true, failure: null })
1212
+ this.publish({ pending: true, failure: null, failureDetail: null })
987
1213
  try {
988
1214
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
989
1215
  method: 'POST',
990
1216
  headers: { 'content-type': 'application/json' },
1217
+ // A hung host must not leave the editor pending forever with a
1218
+ // button that looks dead.
1219
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1220
+ ? { signal: AbortSignal.timeout(20000) }
1221
+ : {}),
991
1222
  body: JSON.stringify({
992
1223
  sessionId: this.sessionId,
993
1224
  seq: target.seq,
@@ -1007,7 +1238,9 @@ window.__ModuleLoader__.load({
1007
1238
  })
1008
1239
  const data = await res.json().catch(() => ({}))
1009
1240
  if (!res.ok || !data.ok) {
1010
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1241
+ const code = data && data.code ? String(data.code) : 'generic'
1242
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1243
+ this.publish({ pending: false, failure: code, failureDetail: detail })
1011
1244
  return
1012
1245
  }
1013
1246
  const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
@@ -1050,8 +1283,17 @@ window.__ModuleLoader__.load({
1050
1283
  let patches = this.view.patches
1051
1284
  let patchBySeq = this.view.patchBySeq
1052
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
1053
1295
  const targetPatch = {
1054
- seq: target.seq,
1296
+ seq: patchSeq,
1055
1297
  blockIndex: data.target.blockIndex,
1056
1298
  kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
1057
1299
  text: String(data.target.text ?? ''),
@@ -1063,10 +1305,21 @@ window.__ModuleLoader__.load({
1063
1305
  const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
1064
1306
  byBlock.set(targetPatch.blockIndex, targetPatch)
1065
1307
  patchBySeq.set(targetPatch.seq, byBlock)
1066
- 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
+ }
1067
1319
  if (entry !== undefined && Array.isArray(entry.blocks)) {
1068
1320
  const blocks = entry.blocks.map((block) =>
1069
- block.index === targetPatch.blockIndex
1321
+ block.index === targetPatch.blockIndex &&
1322
+ (block.seq === undefined || block.seq === patchSeq)
1070
1323
  ? { ...block, text: targetPatch.text, patched: true }
1071
1324
  : block)
1072
1325
  const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
@@ -1496,15 +1749,23 @@ window.__ModuleLoader__.load({
1496
1749
  // like the images were deleted.
1497
1750
  const signature = text + '|' + extraBlocks.map((block) =>
1498
1751
  `${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
1499
- 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)
1500
1759
  bubble.textContent = ''
1501
- const textNode = document.createElement('span')
1502
- textNode.className = 'dshet-revision-text'
1503
- textNode.textContent = text
1504
- 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
+ }
1505
1766
  const files = revisionAttachments(entry, controller)
1506
1767
  if (files !== null) bubble.appendChild(files)
1507
- bubble.dataset.dshetRevisionContent = signature
1768
+ bubble.dataset.dshetRevisionContent = markdownSignature
1508
1769
  }
1509
1770
  // The edit entry goes where every other action of this row lives: the
1510
1771
  // platform's own bar, which the collapse keeps standing, at its right end
@@ -1907,9 +2168,39 @@ window.__ModuleLoader__.load({
1907
2168
  // `_actions` suffix is stable) and hides that strip's ancestors' other
1908
2169
  // children up to the row. Returns false when there is no strip to keep, and
1909
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
+
1910
2198
  function collapseRowContent(row, hide) {
1911
2199
  const strip = row.querySelector('[class*="_actions"]')
1912
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
1913
2204
  let node = strip
1914
2205
  while (node !== null && node !== row) {
1915
2206
  const parent = node.parentElement
@@ -1929,6 +2220,16 @@ window.__ModuleLoader__.load({
1929
2220
  ) {
1930
2221
  continue
1931
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
+ }
1932
2233
  if (hide) {
1933
2234
  // Written only when the value changes: this runs on every DOM pass,
1934
2235
  // and an attribute write the observer can see would never settle.
@@ -2229,9 +2530,28 @@ window.__ModuleLoader__.load({
2229
2530
  element.style[property] = value
2230
2531
  }
2231
2532
 
2533
+ /**
2534
+ * The surface an in-place editor paints itself with, so the recorded text
2535
+ * underneath cannot show through the draft.
2536
+ *
2537
+ * The skin's own panel colour comes first. A skin that paints the app on
2538
+ * artwork usually leaves `body` transparent (the artwork is a layer behind
2539
+ * it), and the white fallback then put a white box on a night sky. The
2540
+ * palette keeps the skin's hue and drops its transparency, so what comes back
2541
+ * here is opaque by construction.
2542
+ *
2543
+ * @returns a CSS colour for the editor's own background.
2544
+ */
2232
2545
  function pageBackground() {
2233
2546
  try {
2234
- if (typeof getComputedStyle === 'function' && typeof document !== 'undefined') {
2547
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
2548
+ const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
2549
+ const match = /rgba?\(([^)]+)\)/.exec(panel)
2550
+ if (match !== null) {
2551
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
2552
+ const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
2553
+ if (opaque) return panel
2554
+ }
2235
2555
  const value = getComputedStyle(document.body).backgroundColor
2236
2556
  if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
2237
2557
  }
@@ -2398,7 +2718,7 @@ window.__ModuleLoader__.load({
2398
2718
  return bounds
2399
2719
  }
2400
2720
 
2401
- 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) {
2402
2722
  const layer = ensureEditorLayer()
2403
2723
  if (box.parentElement !== layer) layer.appendChild(box)
2404
2724
  const placement = readStoredEditorPlacement() ?? 'overlay'
@@ -2572,11 +2892,23 @@ window.__ModuleLoader__.load({
2572
2892
  try {
2573
2893
  const previousValue = area.value
2574
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
2575
2906
  const measure = (text) => {
2576
2907
  area.value = text
2577
- area.style.height = 'auto'
2578
- const measured = area.scrollHeight
2579
- 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)))
2580
2912
  }
2581
2913
  const own = measure(previousValue)
2582
2914
  if (own > 0) contentHeight = own
@@ -2602,11 +2934,26 @@ window.__ModuleLoader__.load({
2602
2934
  }
2603
2935
  area.value = previousValue
2604
2936
  area.style.height = previousHeight
2937
+ if (typeof area.scrollTop === 'number' && area.scrollTop !== previousScroll) {
2938
+ area.scrollTop = previousScroll
2939
+ }
2605
2940
  } catch {}
2606
2941
  }
2607
2942
  // Every mode hugs the measured content; `coveredHeight` is only a
2608
2943
  // fallback for a host whose textarea cannot be measured.
2609
- 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)
2610
2957
  let clampedTop = null
2611
2958
  if (clamped) {
2612
2959
  const bounds = conversationBounds(row)
@@ -2617,14 +2964,32 @@ window.__ModuleLoader__.load({
2617
2964
  // fills the WHOLE band (not just the message's own position), so the
2618
2965
  // top touches the conversation edge and the bottom the composer.
2619
2966
  const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
2620
- clampedTop = fillAll ? topLimit : Math.max(base.top, topLimit)
2621
- height = fillAll
2622
- ? Math.max(minimumHeight, bottomLimit - clampedTop)
2623
- : 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
+ }
2624
2984
  }
2625
2985
  }
2626
2986
  if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
2627
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
+ }
2628
2993
  box.style.height = `${height}px`
2629
2994
  box.style.maxHeight = ''
2630
2995
  let top = clampedTop === null ? base.top : clampedTop
@@ -2648,18 +3013,24 @@ window.__ModuleLoader__.load({
2648
3013
  // is exactly as tall as its text and the ONE scrollbar appears when it
2649
3014
  // overflows the band.
2650
3015
  if (typeof allBox.querySelectorAll === 'function') {
2651
- for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) {
2652
- blockArea.style.height = 'auto'
2653
- const measured = blockArea.scrollHeight
2654
- if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
2655
- }
3016
+ withScrollKept(allBox, () => {
3017
+ for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) settleAreaHeight(blockArea)
3018
+ })
2656
3019
  }
2657
3020
  } else if (area !== null && area !== undefined) {
2658
3021
  const offsetLeft = Math.max(0, bubble.rect.left - base.left)
2659
3022
  // Reserve a header band for the controls; when the prompt carries image
2660
3023
  // chips they sit above the text, so the text starts below them too.
2661
3024
  const offsetTop = headerOffset
2662
- const available = Math.max(40, height - offsetTop - 8)
3025
+ // The control band is painted over the box's bottom edge, so the field
3026
+ // has to stop above it: the old `height - offsetTop - 8` let the field
3027
+ // run 40px under the footer, and the last lines of a long block were
3028
+ // covered for good (the field scrolled, the footer never moved).
3029
+ const footerHeight =
3030
+ footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
3031
+ ? footerEl.offsetHeight
3032
+ : FOOTER_BAND
3033
+ const available = Math.max(40, height - offsetTop - footerHeight)
2663
3034
  area.style.position = 'absolute'
2664
3035
  area.style.boxSizing = 'border-box'
2665
3036
  area.style.left = `${Math.round(offsetLeft)}px`
@@ -2682,6 +3053,73 @@ window.__ModuleLoader__.load({
2682
3053
  }
2683
3054
  }
2684
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
+
2685
3123
  // Grow the textarea to its content, the way a normal input box behaves.
2686
3124
  // Without this it sits at a fixed `rows` height and a two-line edit floats
2687
3125
  // in half an empty box. The stylesheet owns the limits (`min-height`,
@@ -2691,9 +3129,12 @@ window.__ModuleLoader__.load({
2691
3129
 
2692
3130
  function fitEditorHeight(area) {
2693
3131
  if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
2694
- area.style.height = 'auto'
2695
- const height = area.scrollHeight
2696
- 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`
2697
3138
  }
2698
3139
 
2699
3140
  // Wire an action so a host re-render cannot eat the click.
@@ -2840,6 +3281,9 @@ window.__ModuleLoader__.load({
2840
3281
  // saving would write, so both have to redraw the strip - and the chip
2841
3282
  // keys are enough to tell one list from another.
2842
3283
  view.parts.map((part) => part.key).join('.'),
3284
+ // The mode itself: leaving the all-blocks view must rebuild even when the
3285
+ // single block that comes back is the one that was selected before it.
3286
+ view.allBlocks === true ? 'all' : 'one',
2843
3287
  // The selected reply block: switching blocks has to rebuild the box.
2844
3288
  view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
2845
3289
  view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
@@ -2977,7 +3421,7 @@ window.__ModuleLoader__.load({
2977
3421
  ? controller.editorBox
2978
3422
  : null
2979
3423
  if (existing && existing.dataset.dshetFor === marker) {
2980
- placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3424
+ placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
2981
3425
  return
2982
3426
  }
2983
3427
 
@@ -3078,13 +3522,13 @@ window.__ModuleLoader__.load({
3078
3522
  fitEditorHeight(area)
3079
3523
  const row = controller.editorRow
3080
3524
  const box = controller.editorBox
3081
- 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)
3082
3526
  })
3083
3527
  area.addEventListener('compositionend', () => {
3084
3528
  fitEditorHeight(area)
3085
3529
  const row = controller.editorRow
3086
3530
  const box = controller.editorBox
3087
- 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)
3088
3532
  })
3089
3533
  area.addEventListener('focus', () => {
3090
3534
  controller.editorFocus = true
@@ -3099,13 +3543,15 @@ window.__ModuleLoader__.load({
3099
3543
  if (event.isComposing === true || event.keyCode === 229) return
3100
3544
  if (event.key === 'Enter' && event.shiftKey !== true) {
3101
3545
  event.preventDefault()
3102
- if (view.confirming) controller.confirm()
3546
+ const live = liveView()
3547
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
3103
3548
  else controller.review()
3104
3549
  return
3105
3550
  }
3106
3551
  if (event.key === 'Escape') {
3107
3552
  event.preventDefault()
3108
- if (view.confirming) controller.back()
3553
+ const live = liveView()
3554
+ if (live !== null && live !== undefined && live.confirming === true) controller.back()
3109
3555
  else controller.close()
3110
3556
  }
3111
3557
  })
@@ -3155,15 +3601,15 @@ window.__ModuleLoader__.load({
3155
3601
  blockArea.style.position = 'static'
3156
3602
  blockArea.style.left = 'auto'
3157
3603
  blockArea.style.top = 'auto'
3158
- blockArea.value = controller.editorAllDrafts instanceof Map ? (controller.editorAllDrafts.get(key) ?? block.text) : block.text
3604
+ blockArea.value = controller.draftOfBlock(block)
3159
3605
  blockArea.disabled = view.pending || view.confirming
3160
- const grow = () => {
3161
- blockArea.style.height = 'auto'
3162
- const measured = blockArea.scrollHeight
3163
- if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
3164
- }
3606
+ const grow = () =>
3607
+ withScrollKept(
3608
+ typeof blockArea.closest === 'function' ? blockArea.closest('.dshet-all') : null,
3609
+ () => settleAreaHeight(blockArea),
3610
+ )
3165
3611
  blockArea.addEventListener('input', () => {
3166
- if (controller.editorAllDrafts instanceof Map) controller.editorAllDrafts.set(key, blockArea.value)
3612
+ controller.setDraftFor(block, blockArea.value)
3167
3613
  grow()
3168
3614
  })
3169
3615
  section.appendChild(head)
@@ -3234,14 +3680,34 @@ window.__ModuleLoader__.load({
3234
3680
  warn.textContent = texts.warn
3235
3681
  box.appendChild(warn)
3236
3682
  }
3237
- if (typeof view.failure === 'string' && view.failure !== '') {
3683
+ // A running turn only blocks the paths that move the surface (a prompt
3684
+ // rewrite, or a reply edit on a host without the in-place record). The
3685
+ // in-place record appends one event and changes nothing that is already
3686
+ // on the surface, so waiting for the agent to finish is not required -
3687
+ // the model picks the edit up on its next fold.
3688
+ const inPlaceEdit = target.mode === 'reply' && target.patch === true
3689
+ const busyBlocked = view.busy === true && !inPlaceEdit
3690
+ const failureCode = typeof view.failure === 'string' && view.failure !== ''
3691
+ ? view.failure
3692
+ : busyBlocked
3693
+ ? 'busy'
3694
+ : null
3695
+ if (failureCode !== null) {
3238
3696
  const error = document.createElement('p')
3239
3697
  error.className = 'dshet-error'
3240
3698
  error.setAttribute('role', 'status')
3241
- error.textContent = errorText(t, view.failure)
3699
+ const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
3700
+ error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
3701
+ ? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3702
+ : errorText(t, failureCode)
3242
3703
  box.appendChild(error)
3243
3704
  }
3244
3705
 
3706
+ // Handlers must read the CURRENT controller state: the host may not have
3707
+ // rebuilt the button after the first press, and a stale `view.confirming`
3708
+ // made the confirmation step a dead end (press again -> review again).
3709
+ const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
3710
+
3245
3711
  const footer = document.createElement('div')
3246
3712
  footer.className = 'dshet-footer'
3247
3713
  const footerActions = document.createElement('div')
@@ -3282,11 +3748,17 @@ window.__ModuleLoader__.load({
3282
3748
  submit.appendChild(submitText)
3283
3749
  submit.title = `${submitLabel} (Enter)`
3284
3750
  submit.setAttribute('aria-label', submitLabel)
3285
- submit.disabled = view.pending
3751
+ submit.disabled = view.pending || busyBlocked
3286
3752
  pressable(submit, () => {
3287
- if (view.confirming) controller.confirm()
3753
+ const live = liveView()
3754
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
3288
3755
  else controller.review()
3289
3756
  })
3757
+ if (view.confirming === true) {
3758
+ // The save label is visually hidden, so the confirmation step needs a
3759
+ // visible state of its own or the first press looks like nothing.
3760
+ submit.classList.add('dshet-btn-confirm')
3761
+ }
3290
3762
 
3291
3763
  footerActions.appendChild(cancel)
3292
3764
  // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
@@ -3338,7 +3810,7 @@ window.__ModuleLoader__.load({
3338
3810
  controller.editorBox = box
3339
3811
  controller.editorRow = row
3340
3812
  controller.editorElement = area
3341
- placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3813
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3342
3814
  fitEditorHeight(area)
3343
3815
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
3344
3816
  try {
@@ -3377,8 +3849,11 @@ window.__ModuleLoader__.load({
3377
3849
  // collapsed panel that may not be in the DOM at all.
3378
3850
 
3379
3851
  const PATCH_ATTR = 'data-dshet-patch'
3380
- const NOTE_ATTR = 'data-dshet-patch-note'
3381
3852
  const patchOriginals = new WeakMap()
3853
+ // The text this plugin last painted for a key, so a second edit of the same
3854
+ // block can still find the element after the first text replaced the
3855
+ // recorded original in the DOM.
3856
+ const patchedTexts = new Map()
3382
3857
  // React may re-render the assistant container and reset its attributes and
3383
3858
  // style while leaving the text node we replaced. Remember the element by
3384
3859
  // seq:block so the next pass can restore the marker and, on a later edit,
@@ -3456,10 +3931,11 @@ window.__ModuleLoader__.load({
3456
3931
  // element in the owning row whose whole text is exactly the recorded text;
3457
3932
  // never a button, a timestamp, our own chrome, or an ancestor that merely
3458
3933
  // contains the text.
3459
- function findPatchTextFallback(owner, wanted) {
3934
+ function findPatchTextFallback(owner, wanted, previousText = '') {
3460
3935
  if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
3461
3936
  const original = matchText(wanted.original)
3462
- if (original === '') return null
3937
+ const previous = matchText(previousText)
3938
+ if (original === '' && previous === '') return null
3463
3939
  let scope = owner
3464
3940
  try {
3465
3941
  const node = owner.querySelector('[data-slot="conversation.chat.node"]')
@@ -3471,12 +3947,12 @@ window.__ModuleLoader__.load({
3471
3947
  if (el === scope) continue
3472
3948
  if (el.getAttribute(PATCH_ATTR) !== null) continue
3473
3949
  try {
3474
- if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
3950
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3475
3951
  } catch {
3476
3952
  continue
3477
3953
  }
3478
3954
  const text = matchText(el.textContent)
3479
- if (text !== original) continue
3955
+ if (text !== original && !(previous !== '' && text === previous)) continue
3480
3956
  let deeper = false
3481
3957
  for (const child of el.children ?? []) {
3482
3958
  if (matchText(child.textContent) === text) {
@@ -3489,6 +3965,140 @@ window.__ModuleLoader__.load({
3489
3965
  return null
3490
3966
  }
3491
3967
 
3968
+ /**
3969
+ * The collapsed reasoning header draws its own truncation of the RECORDED
3970
+ * text ("思考 · ..."), and when the panel is closed the body is not in the
3971
+ * DOM at all - so a patch of the reasoning body alone leaves the edit
3972
+ * invisible. Rewrite that snippet too, keeping whatever the host appended
3973
+ * (its ellipsis) and its own truncation length.
3974
+ */
3975
+ function patchReasoningSummary(owner, wanted) {
3976
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
3977
+ const original = String(wanted.original ?? '')
3978
+ const patched = String(wanted.text ?? '')
3979
+ if (original === '' || patched === '') return false
3980
+ const head = original.slice(0, 12)
3981
+ let changed = false
3982
+ const apply = (get, set) => {
3983
+ const data = String(get() ?? '')
3984
+ if (data === '' || !data.includes(head)) return
3985
+ // A node holding the whole text gets the whole replacement; a truncated
3986
+ // snippet keeps the host's length and gains an ellipsis when the new
3987
+ // text is longer.
3988
+ const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
3989
+ if (next === data) return
3990
+ set(next)
3991
+ changed = true
3992
+ }
3993
+ const walk = (node) => {
3994
+ for (const child of node.childNodes ?? []) {
3995
+ const type = child.nodeType
3996
+ if (type === 3) {
3997
+ apply(() => child.data, (value) => { child.data = value })
3998
+ continue
3999
+ }
4000
+ if (type !== 1) continue
4001
+ try {
4002
+ if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4003
+ } catch {
4004
+ /* a node without closest is walked anyway */
4005
+ }
4006
+ const kids = child.childNodes ?? []
4007
+ if (kids.length === 0) {
4008
+ apply(() => child.textContent, (value) => { child.textContent = value })
4009
+ continue
4010
+ }
4011
+ walk(child)
4012
+ }
4013
+ }
4014
+ walk(owner)
4015
+ return changed
4016
+ }
4017
+
4018
+ /**
4019
+ * The reasoning body on this host is its own row (the flow key carries a
4020
+ * `,reasoning` group part), so neither the answer row nor the first chat node
4021
+ * the shared fallback picks contains it. Search the whole conversation for
4022
+ * the deepest element whose text is the recorded original (or what we last
4023
+ * painted there).
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
+
4068
+ function findReasoningElement(root, wanted, previousText) {
4069
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
4070
+ const original = matchText(wanted.original)
4071
+ const previous = matchText(previousText)
4072
+ if (original === '' && previous === '') return null
4073
+ let best = null
4074
+ let bestScore = Infinity
4075
+ for (const el of root.querySelectorAll('*')) {
4076
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4077
+ try {
4078
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4079
+ } catch {
4080
+ /* a detached node is still a candidate */
4081
+ }
4082
+ const text = matchText(el.textContent)
4083
+ if (text === '') continue
4084
+ if (text !== original && !(previous !== '' && text === previous)) continue
4085
+ let deeper = false
4086
+ for (const child of el.children ?? []) {
4087
+ if (matchText(child.textContent) === text) {
4088
+ deeper = true
4089
+ break
4090
+ }
4091
+ }
4092
+ if (deeper) continue
4093
+ const score = Math.abs(text.length - original.length)
4094
+ if (score < bestScore) {
4095
+ best = el
4096
+ bestScore = score
4097
+ }
4098
+ }
4099
+ return best
4100
+ }
4101
+
3492
4102
  function applyPatchDisplay(snapshot, view, t) {
3493
4103
  // Map a reply seq to the host row that draws it, so a short answer like
3494
4104
  // "好的" cannot be matched in a different row. Fall back to a global
@@ -3522,11 +4132,40 @@ window.__ModuleLoader__.load({
3522
4132
  if (root === null) return
3523
4133
  const replies = view.replies instanceof Map ? view.replies : new Map()
3524
4134
  const wantedText = new Map()
3525
- const wantedNotes = new Map()
4135
+ const wantedReasoning = new Map()
3526
4136
  for (const patch of Array.isArray(view.patches) ? view.patches : []) {
3527
- const reply = replies.get(patch.seq)
3528
- if (reply === undefined || !Array.isArray(reply.blocks)) continue
3529
- 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
+ }
3530
4169
  if (block === undefined) continue
3531
4170
  if (patch.kind === 'text') {
3532
4171
  wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
@@ -3536,8 +4175,10 @@ window.__ModuleLoader__.load({
3536
4175
  text: String(patch.text ?? ''),
3537
4176
  })
3538
4177
  } else if (patch.kind === 'reasoning') {
3539
- wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
4178
+ wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
3540
4179
  key: `${patch.seq}:${patch.blockIndex}`,
4180
+ seq: patch.seq,
4181
+ original: String(block.original ?? ''),
3541
4182
  text: String(patch.text ?? ''),
3542
4183
  reply,
3543
4184
  block,
@@ -3548,7 +4189,7 @@ window.__ModuleLoader__.load({
3548
4189
  // A patch that no longer exists must give the host's recorded text back,
3549
4190
  // even when a re-render already stripped our marker from the element.
3550
4191
  for (const [key, el] of [...patchedElements]) {
3551
- if (wantedText.has(key)) continue
4192
+ if (wantedText.has(key) || wantedReasoning.has(key)) continue
3552
4193
  patchedElements.delete(key)
3553
4194
  if (el.isConnected === true) restorePatchedBlock(el)
3554
4195
  }
@@ -3566,18 +4207,14 @@ window.__ModuleLoader__.load({
3566
4207
  el.textContent = shown
3567
4208
  }
3568
4209
  }
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
4210
+ if (wantedText.size === 0 && wantedReasoning.size === 0) return
3574
4211
 
3575
4212
  const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
3576
4213
  const containers = []
3577
4214
  for (const node of nodes) {
3578
4215
  for (const el of node.querySelectorAll('*')) {
3579
4216
  try {
3580
- if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
4217
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
3581
4218
  } catch {
3582
4219
  /* a detached node is skipped */
3583
4220
  }
@@ -3608,7 +4245,8 @@ window.__ModuleLoader__.load({
3608
4245
  continue
3609
4246
  }
3610
4247
  const original = matchText(wanted.original)
3611
- if (original === '') continue
4248
+ const previous = matchText(patchedTexts.get(key) ?? '')
4249
+ if (original === '' && previous === '') continue
3612
4250
  let best = null
3613
4251
  let bestScore = Infinity
3614
4252
  for (const el of containers) {
@@ -3622,7 +4260,7 @@ window.__ModuleLoader__.load({
3622
4260
  }
3623
4261
  const text = matchText(el.textContent)
3624
4262
  if (text === '') continue
3625
- const exact = text === original
4263
+ const exact = text === original || (previous !== '' && text === previous)
3626
4264
  const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
3627
4265
  if (!exact && !prefix) continue
3628
4266
  // Prefer the exact match, then the container closest in length: a
@@ -3634,47 +4272,87 @@ window.__ModuleLoader__.load({
3634
4272
  bestScore = score
3635
4273
  }
3636
4274
  }
3637
- if (best === null) best = findPatchTextFallback(owner, wanted)
4275
+ if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
3638
4276
  if (best === null) continue
3639
4277
  patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
3640
4278
  best.textContent = displayText(wanted.text)
3641
4279
  best.setAttribute(PATCH_ATTR, key)
3642
4280
  best.style.whiteSpace = 'pre-wrap'
3643
4281
  patchedElements.set(key, best)
4282
+ patchedTexts.set(key, wanted.text)
3644
4283
  }
3645
4284
 
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
3658
- }
3659
- if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
3660
- row = node
3661
- break
4285
+ // A reasoning patch is drawn in place when the host renders that
4286
+ // reasoning body; the note is only the fallback for a panel that is not
4287
+ // in the DOM (or is drawn as a short summary without the full text).
4288
+ for (const [key, wanted] of wantedReasoning) {
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()
4302
+ const remembered = patchedElements.get(key)
4303
+ if (remembered !== undefined) {
4304
+ if (remembered.isConnected === true) targets.add(remembered)
4305
+ else patchedElements.delete(key)
4306
+ }
4307
+ for (const el of root.querySelectorAll(`[${PATCH_ATTR}]`)) {
4308
+ if (el.getAttribute(PATCH_ATTR) === key) targets.add(el)
4309
+ }
4310
+ if (original !== '' || previous !== '') {
4311
+ // The owning row first (a host that draws the reasoning inside the
4312
+ // answer row), then the whole conversation: on this host the reasoning
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.
4316
+ for (const scope of owner === undefined ? [null] : [owner, null]) {
4317
+ let foundInScope = false
4318
+ for (const el of containers) {
4319
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4320
+ if (scope !== null) {
4321
+ try {
4322
+ if (el.closest('[data-chat-flow-key]') !== scope) continue
4323
+ } catch {
4324
+ /* keep the global match */
4325
+ }
4326
+ }
4327
+ const text = matchText(el.textContent)
4328
+ if (text === '') continue
4329
+ if (text !== original && !(previous !== '' && text === previous)) continue
4330
+ targets.add(el)
4331
+ foundInScope = true
4332
+ }
4333
+ if (foundInScope) break
3662
4334
  }
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)
3663
4338
  }
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)
4339
+ if (targets.size === 0) {
4340
+ // No body in the DOM (the panel is closed): the header snippet is the
4341
+ // only place the edit can show.
4342
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
4343
+ patchReasoningSummary(root, wanted)
4344
+ continue
4345
+ }
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)
3674
4352
  }
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)
4353
+ patchedTexts.set(key, wanted.text)
4354
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
4355
+ patchReasoningSummary(root, wanted)
3678
4356
  }
3679
4357
  }
3680
4358
 
@@ -3895,7 +4573,7 @@ window.__ModuleLoader__.load({
3895
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' : ''}`
3896
4574
  if (key === lastPlacementKey) return
3897
4575
  lastPlacementKey = key
3898
- placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
4576
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3899
4577
  }
3900
4578
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
3901
4579
  else window.setTimeout(run, 16)
@@ -4292,6 +4970,31 @@ window.__ModuleLoader__.load({
4292
4970
  // A reply's edit entry lives in the official assistant-actions strip, one
4293
4971
  // entry per assistant message. order 5 puts it ahead of the feedback
4294
4972
  // entries (order 10), so the pencil sits with the leading icons.
4973
+ // The active skin repaints its surfaces at any time (switching one
4974
+ // rewrites body's attributes and inline variables), so the palette is
4975
+ // resolved once and then again on every theme or skin change.
4976
+ ctx.effect(() => {
4977
+ const run = () => {
4978
+ try {
4979
+ resolveSkinPalette()
4980
+ } catch {
4981
+ /* the stylesheet defaults stand */
4982
+ }
4983
+ }
4984
+ run()
4985
+ const observer =
4986
+ typeof MutationObserver === 'function' && document && document.body
4987
+ ? new MutationObserver(run)
4988
+ : null
4989
+ if (observer !== null) {
4990
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
4991
+ if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
4992
+ }
4993
+ return () => {
4994
+ if (observer !== null) observer.disconnect()
4995
+ }
4996
+ }, 'dsh-edit-turn:skin-palette')
4997
+
4295
4998
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
4296
4999
  ctx.slots.register(
4297
5000
  {