dsh-edit-turn 0.2.21 → 0.3.1

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
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
42
42
  const RERUN_PREFIX = '/dsh-rerun-turn'
43
43
 
44
44
  /** Keep in sync with package.json and lib/index.js. */
45
- const PLUGIN_VERSION = '0.2.21'
45
+ const PLUGIN_VERSION = '0.3.1'
46
46
 
47
47
  // --- copy -----------------------------------------------------------------
48
48
 
@@ -64,6 +64,12 @@ window.__ModuleLoader__.load({
64
64
  'editor.save': '保存',
65
65
  'editor.rerun': '重跑',
66
66
  'editor.save-reply': '保存',
67
+ 'editor.block': '编辑哪一块',
68
+ 'editor.block.text': '正文',
69
+ 'editor.block.reasoning': '思维链',
70
+ 'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
71
+ 'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
72
+ 'editor.patch-note': '思维链已改写(模型可见)',
67
73
  'editor.review': '确认执行',
68
74
  'editor.cancel': '取消',
69
75
  'editor.pending': '正在保存…',
@@ -77,6 +83,7 @@ window.__ModuleLoader__.load({
77
83
  'error.session-not-found': '找不到该会话的日志。',
78
84
  'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
79
85
  'error.not-editable': '这条消息不支持编辑。',
86
+ 'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
80
87
  'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
81
88
  'error.stale': '会话刚刚发生了变化,请重试。',
82
89
  'error.forbidden': '请求来源不被允许。',
@@ -112,6 +119,12 @@ window.__ModuleLoader__.load({
112
119
  'editor.save': 'Save',
113
120
  'editor.rerun': 'Re-run',
114
121
  'editor.save-reply': 'Save',
122
+ 'editor.block': 'Block',
123
+ 'editor.block.text': 'Answer',
124
+ 'editor.block.reasoning': 'Reasoning',
125
+ 'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
126
+ '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.',
127
+ 'editor.patch-note': 'Reasoning patched (model-visible)',
115
128
  'editor.review': 'Confirm',
116
129
  'editor.cancel': 'Cancel',
117
130
  'editor.pending': 'Working...',
@@ -125,6 +138,7 @@ window.__ModuleLoader__.load({
125
138
  'error.session-not-found': 'No session log was found for this id.',
126
139
  'error.busy': 'This session is still replying; wait for it to finish.',
127
140
  'error.not-editable': 'This message cannot be edited.',
141
+ 'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
128
142
  'error.already-rolled-back': 'This message is no longer in the current context.',
129
143
  'error.stale': 'The session just changed; try again.',
130
144
  'error.forbidden': 'The request origin is not allowed.',
@@ -283,6 +297,12 @@ window.__ModuleLoader__.load({
283
297
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
284
298
  '.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))}',
285
299
  '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
300
+ '.dshet-pick{display:flex;align-items:center;gap:8px;padding:8px 12px 0;font-size:12px;color:var(--dshet-ink-dim)}',
301
+ '.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}',
302
+ '[data-dshet-patch]{white-space:pre-wrap}',
303
+ '.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}',
304
+ '.dshet-patch-note summary{cursor:pointer}',
305
+ '.dshet-patch-note-body{margin:6px 0 0;white-space:pre-wrap;color:var(--dshet-ink);font-size:12px;line-height:18px}',
286
306
  '.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}',
287
307
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
288
308
  ].join('')
@@ -370,6 +390,15 @@ window.__ModuleLoader__.load({
370
390
  editable: new Map(),
371
391
  replies: new Map(),
372
392
  repliesByMessage: new Map(),
393
+ // Landed in-place reply patches, latest per block, and the same data
394
+ // indexed by seq for the in-place text display.
395
+ patches: [],
396
+ patchBySeq: new Map(),
397
+ replyMode: 'replay',
398
+ // The block being edited in the reply editor (null for a prompt).
399
+ blocks: [],
400
+ blockIndex: null,
401
+ blockKind: null,
373
402
  editing: null,
374
403
  draft: '',
375
404
  parts: [],
@@ -448,6 +477,22 @@ window.__ModuleLoader__.load({
448
477
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
449
478
  if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
450
479
  }
480
+ // The in-place reply patches, latest per block. The model sees them
481
+ // through the host projection; this half uses them to draw the
482
+ // projected text over the recorded row.
483
+ const patches = []
484
+ const patchBySeq = new Map()
485
+ for (const patch of Array.isArray(data.patches) ? data.patches : []) {
486
+ if (!patch || typeof patch.seq !== 'number' || typeof patch.blockIndex !== 'number') continue
487
+ if (patch.kind !== 'text' && patch.kind !== 'reasoning') continue
488
+ const normalized = { seq: patch.seq, blockIndex: patch.blockIndex, kind: patch.kind, text: String(patch.text ?? '') }
489
+ const previous = patches.findIndex((entry) => entry.seq === normalized.seq && entry.blockIndex === normalized.blockIndex)
490
+ if (previous >= 0) patches[previous] = normalized
491
+ else patches.push(normalized)
492
+ let byBlock = patchBySeq.get(normalized.seq)
493
+ if (byBlock === undefined) { byBlock = new Map(); patchBySeq.set(normalized.seq, byBlock) }
494
+ byBlock.set(normalized.blockIndex, normalized)
495
+ }
451
496
  // The model's replies are editable too, as a separate list: they are
452
497
  // rewritten rather than re-run, so the editor words the consequence
453
498
  // differently.
@@ -466,6 +511,9 @@ window.__ModuleLoader__.load({
466
511
  editable,
467
512
  replies,
468
513
  repliesByMessage,
514
+ patches,
515
+ patchBySeq,
516
+ replyMode: data.mode === 'patch' ? 'patch' : 'replay',
469
517
  surfaceReady: true,
470
518
  loaded: true,
471
519
  loadError: false,
@@ -493,10 +541,34 @@ window.__ModuleLoader__.load({
493
541
  const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
494
542
  const previous = this.editorElement
495
543
  this.load()
496
- this.editor = target
544
+ // A reply on the patch path carries its editable blocks; open the one
545
+ // the editor was asked for, or the answer text when none was named.
546
+ const replyBlocks = target.mode === 'reply' && Array.isArray(target.blocks) ? target.blocks : []
547
+ const requested = typeof target.blockIndex === 'number'
548
+ ? replyBlocks.find((block) => block.index === target.blockIndex)
549
+ : undefined
550
+ // Pressing the same pencil again keeps the block the user already
551
+ // switched to; only a fresh open picks the answer block by default.
552
+ const current = same && typeof this.editorBlockIndex === 'number'
553
+ ? replyBlocks.find((block) => block.index === this.editorBlockIndex)
554
+ : undefined
555
+ const chosen = current ?? requested ?? replyBlocks.find((block) => block.kind === 'text') ?? replyBlocks[0]
556
+ const normalized = replyBlocks.length > 0
557
+ ? {
558
+ ...target,
559
+ patch: true,
560
+ blocks: replyBlocks,
561
+ blockIndex: chosen === undefined ? undefined : chosen.index,
562
+ blockKind: chosen === undefined ? undefined : chosen.kind,
563
+ }
564
+ : target
565
+ this.editor = normalized
566
+ this.editorBlocks = replyBlocks
567
+ this.editorBlockIndex = chosen === undefined ? null : chosen.index
568
+ this.editorBlockKind = chosen === undefined ? null : chosen.kind
497
569
  if (!same) {
498
- this.editorDraft = target.text
499
- this.editorParts = blocksToParts(target, this.sessionId)
570
+ this.editorDraft = chosen === undefined ? target.text : chosen.text
571
+ this.editorParts = blocksToParts(normalized, this.sessionId)
500
572
  }
501
573
  this.confirming = false
502
574
  this.editorElement = null
@@ -513,7 +585,16 @@ window.__ModuleLoader__.load({
513
585
  /* focus is best-effort */
514
586
  }
515
587
  }
516
- this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
588
+ this.publish({
589
+ editing: normalized,
590
+ draft: this.editorDraft,
591
+ parts: this.editorParts.slice(),
592
+ blocks: replyBlocks,
593
+ blockIndex: this.editorBlockIndex,
594
+ blockKind: this.editorBlockKind,
595
+ confirming: false,
596
+ failure: null,
597
+ })
517
598
  this.probeSiblingRerun()
518
599
  }
519
600
 
@@ -541,6 +622,22 @@ window.__ModuleLoader__.load({
541
622
  if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
542
623
  }
543
624
 
625
+ // Pick another block of the same reply. The editor is rebuilt by the
626
+ // block index in its marker; the draft becomes that block's current
627
+ // (already projected) text.
628
+ selectBlock(index) {
629
+ if (this.view.pending) return
630
+ const target = this.editor
631
+ if (target === null || !Array.isArray(target.blocks)) return
632
+ const block = target.blocks.find((entry) => entry.index === index)
633
+ if (block === undefined || block.kind === undefined) return
634
+ this.editorBlockIndex = block.index
635
+ this.editorBlockKind = block.kind
636
+ this.editorDraft = block.text
637
+ this.editorElement = null
638
+ this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, failure: null })
639
+ }
640
+
544
641
  close() {
545
642
  if (this.view.pending) return
546
643
  this.pendingRerun = false
@@ -553,7 +650,10 @@ window.__ModuleLoader__.load({
553
650
  this.editorParts = []
554
651
  this.confirming = false
555
652
  this.editorElement = null
556
- this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
653
+ this.editorBlocks = []
654
+ this.editorBlockIndex = null
655
+ this.editorBlockKind = null
656
+ this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
557
657
  }
558
658
 
559
659
  setDraft(value) {
@@ -683,6 +783,11 @@ window.__ModuleLoader__.load({
683
783
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
684
784
  text,
685
785
  ...(parts === undefined ? {} : { parts }),
786
+ // The in-place reply path names one block; the host validates that
787
+ // it is still that kind at that index before writing the record.
788
+ ...(target.patch === true && typeof this.editorBlockIndex === 'number'
789
+ ? { blockIndex: this.editorBlockIndex, kind: this.editorBlockKind }
790
+ : {}),
686
791
  }),
687
792
  })
688
793
  const data = await res.json().catch(() => ({}))
@@ -724,6 +829,40 @@ window.__ModuleLoader__.load({
724
829
  // replies here and re-enters through the next /state refresh (the
725
830
  // appended correction is itself an editable reply).
726
831
  const replies = new Map(this.view.replies)
832
+ // The in-place path wrote one small record and touched no surface
833
+ // row: the reply stays where it is, and its block list is updated
834
+ // optimistically so the next render already shows the new text.
835
+ let patches = this.view.patches
836
+ let patchBySeq = this.view.patchBySeq
837
+ if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
838
+ const targetPatch = {
839
+ seq: target.seq,
840
+ blockIndex: data.target.blockIndex,
841
+ kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
842
+ text: String(data.target.text ?? ''),
843
+ }
844
+ patches = patches
845
+ .filter((entry) => !(entry.seq === targetPatch.seq && entry.blockIndex === targetPatch.blockIndex))
846
+ .concat([targetPatch])
847
+ patchBySeq = new Map(patchBySeq)
848
+ const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
849
+ byBlock.set(targetPatch.blockIndex, targetPatch)
850
+ patchBySeq.set(targetPatch.seq, byBlock)
851
+ const entry = replies.get(target.seq)
852
+ if (entry !== undefined && Array.isArray(entry.blocks)) {
853
+ const blocks = entry.blocks.map((block) =>
854
+ block.index === targetPatch.blockIndex
855
+ ? { ...block, text: targetPatch.text, patched: true }
856
+ : block)
857
+ const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
858
+ replies.set(target.seq, {
859
+ ...entry,
860
+ blocks,
861
+ text,
862
+ patched: blocks.some((block) => block.patched === true),
863
+ })
864
+ }
865
+ }
727
866
  for (const entry of replies.values()) {
728
867
  if (hidden.has(entry.seq)) replies.delete(entry.seq)
729
868
  }
@@ -744,6 +883,11 @@ window.__ModuleLoader__.load({
744
883
  revisions,
745
884
  editable,
746
885
  replies,
886
+ patches,
887
+ patchBySeq,
888
+ blocks: [],
889
+ blockIndex: null,
890
+ blockKind: null,
747
891
  parts: [],
748
892
  failure: null,
749
893
  })
@@ -1965,6 +2109,8 @@ window.__ModuleLoader__.load({
1965
2109
  // saving would write, so both have to redraw the strip - and the chip
1966
2110
  // keys are enough to tell one list from another.
1967
2111
  view.parts.map((part) => part.key).join('.'),
2112
+ // The selected reply block: switching blocks has to rebuild the box.
2113
+ view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
1968
2114
  ].join('|')
1969
2115
  }
1970
2116
 
@@ -1973,9 +2119,10 @@ window.__ModuleLoader__.load({
1973
2119
  // from drifting apart in wording.
1974
2120
  function editorTexts(t, target) {
1975
2121
  const reply = target.mode === 'reply'
2122
+ const block = reply && target.patch === true
1976
2123
  return {
1977
2124
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
1978
- placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
2125
+ placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
1979
2126
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
1980
2127
  // Saving writes the revision into the context; only the opt-in config asks
1981
2128
  // the model again, and the button says which one will happen.
@@ -2108,6 +2255,34 @@ window.__ModuleLoader__.load({
2108
2255
  box.dataset[EDITOR_ATTR] = '1'
2109
2256
  box.dataset.dshetFor = marker
2110
2257
 
2258
+ // A reply on the patch path may carry several editable blocks (answer
2259
+ // text and reasoning). The picker chooses which one this save targets;
2260
+ // every option carries the block's CURRENT projected text, so switching
2261
+ // never shows the recorded original over a landed patch.
2262
+ if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length > 1) {
2263
+ const picker = document.createElement('label')
2264
+ picker.className = 'dshet-pick'
2265
+ const caption = document.createElement('span')
2266
+ caption.textContent = t('editor.block')
2267
+ const select = document.createElement('select')
2268
+ select.className = 'dshet-select'
2269
+ select.disabled = view.pending || view.confirming
2270
+ select.setAttribute('aria-label', t('editor.block'))
2271
+ for (const block of target.blocks) {
2272
+ const option = document.createElement('option')
2273
+ option.value = String(block.index)
2274
+ const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
2275
+ option.textContent = t(kind)
2276
+ option.selected = block.index === view.blockIndex
2277
+ select.appendChild(option)
2278
+ }
2279
+ if (view.blockIndex !== null && view.blockIndex !== undefined) select.value = String(view.blockIndex)
2280
+ select.addEventListener('change', () => controller.selectBlock(Number(select.value)))
2281
+ picker.appendChild(caption)
2282
+ picker.appendChild(select)
2283
+ box.appendChild(picker)
2284
+ }
2285
+
2111
2286
  const area = document.createElement('textarea')
2112
2287
  area.rows = 1
2113
2288
  area.value = controller.editorDraft
@@ -2167,12 +2342,12 @@ window.__ModuleLoader__.load({
2167
2342
  if (view.confirming) {
2168
2343
  const note = document.createElement('p')
2169
2344
  note.className = 'dshet-note'
2170
- note.textContent = t('confirm.body')
2345
+ note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
2171
2346
  box.appendChild(note)
2172
2347
  }
2173
2348
  // Shown when saving really would drop something: a reply's tool calls
2174
2349
  // always, and a prompt's blocks when this host cannot carry them.
2175
- if (target.attachments > 0 && !chips) {
2350
+ if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
2176
2351
  const warn = document.createElement('p')
2177
2352
  warn.className = 'dshet-warn'
2178
2353
  warn.textContent = texts.warn
@@ -2264,6 +2439,318 @@ window.__ModuleLoader__.load({
2264
2439
  controller.load(true)
2265
2440
  }
2266
2441
 
2442
+ // --- projected reply display ---------------------------------------------
2443
+ //
2444
+ // The host draws a reply from the recorded event, not from the projected
2445
+ // message, so a landed patch would be invisible in the transcript. The
2446
+ // projection is the model's copy; this is the reader's copy. Text blocks are
2447
+ // replaced in place inside the host's own markdown container (the row,
2448
+ // actions and tool cards stay untouched); a patched reasoning block gets a
2449
+ // small note under the reply, because the host renders reasoning as a
2450
+ // collapsed panel that may not be in the DOM at all.
2451
+
2452
+ const PATCH_ATTR = 'data-dshet-patch'
2453
+ const NOTE_ATTR = 'data-dshet-patch-note'
2454
+ const patchOriginals = new WeakMap()
2455
+ // React may re-render the assistant container and reset its attributes and
2456
+ // style while leaving the text node we replaced. Remember the element by
2457
+ // seq:block so the next pass can restore the marker and, on a later edit,
2458
+ // update it without having to re-find the original text (which is gone).
2459
+ const patchedElements = new Map()
2460
+
2461
+ function normalizedText(value) {
2462
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
2463
+ }
2464
+
2465
+ // Markdown syntax is not part of the rendered textContent. Strip the
2466
+ // structural characters before comparing, so a reply that uses **bold** or
2467
+ // a fenced block still matches its own recorded text.
2468
+ function plainText(value) {
2469
+ return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
2470
+ }
2471
+
2472
+ // Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
2473
+ // the spaces left by stripping the markers must not defeat the comparison.
2474
+ // Whitespace carries no block identity here, so remove it entirely.
2475
+ function matchText(value) {
2476
+ return plainText(value).replace(/\s+/g, '')
2477
+ }
2478
+
2479
+ // What the reader sees for a patched block. The model context keeps the
2480
+ // raw Markdown the editor wrote; the transcript should not show the markers
2481
+ // as literal text (`**1**`), which is what made a successful edit look like
2482
+ // an unrendered draft. This is deliberately minimal: bold, inline code and
2483
+ // headings are unwrapped; everything else stays as written.
2484
+ function displayText(value) {
2485
+ return String(value ?? '')
2486
+ .replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
2487
+ .replace(/`([^`]*)`/g, '$1')
2488
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
2489
+ .replace(/__([^_]+)__/g, '$1')
2490
+ .replace(/^#{1,6}\s+/gm, '')
2491
+ }
2492
+
2493
+ function isMarkdownContainer(el) {
2494
+ if (el === null || el === undefined || !el.children) return false
2495
+ const kids = el.children
2496
+ if (kids.length === 0) return false
2497
+ let blocks = 0
2498
+ for (const kid of kids) {
2499
+ try {
2500
+ if (kid.matches && kid.matches('p, pre, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table')) blocks += 1
2501
+ } catch {
2502
+ return false
2503
+ }
2504
+ }
2505
+ return blocks >= 1 && blocks * 2 >= kids.length
2506
+ }
2507
+
2508
+ function conversationRootForPatches() {
2509
+ const root = document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
2510
+ return root ?? document.body
2511
+ }
2512
+
2513
+ function restorePatchedBlock(el) {
2514
+ const saved = patchOriginals.get(el)
2515
+ if (saved !== null && saved !== undefined) {
2516
+ try {
2517
+ if (typeof saved.html === 'string' && saved.html !== '') el.innerHTML = saved.html
2518
+ else if (typeof saved.text === 'string') el.textContent = saved.text
2519
+ } catch {
2520
+ /* a detached row is not worth a throw */
2521
+ }
2522
+ }
2523
+ el.removeAttribute(PATCH_ATTR)
2524
+ el.style.whiteSpace = ''
2525
+ }
2526
+
2527
+ // A short reply can be a bare text node (or a span) rather than the
2528
+ // block-level markdown container the picker looks for. Match the deepest
2529
+ // element in the owning row whose whole text is exactly the recorded text;
2530
+ // never a button, a timestamp, our own chrome, or an ancestor that merely
2531
+ // contains the text.
2532
+ function findPatchTextFallback(owner, wanted) {
2533
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
2534
+ const original = matchText(wanted.original)
2535
+ if (original === '') return null
2536
+ let scope = owner
2537
+ try {
2538
+ const node = owner.querySelector('[data-slot="conversation.chat.node"]')
2539
+ if (node !== null && node !== undefined) scope = node
2540
+ } catch {
2541
+ /* keep the row as the scope */
2542
+ }
2543
+ for (const el of scope.querySelectorAll('*')) {
2544
+ if (el === scope) continue
2545
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
2546
+ try {
2547
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
2548
+ } catch {
2549
+ continue
2550
+ }
2551
+ const text = matchText(el.textContent)
2552
+ if (text !== original) continue
2553
+ let deeper = false
2554
+ for (const child of el.children ?? []) {
2555
+ if (matchText(child.textContent) === text) {
2556
+ deeper = true
2557
+ break
2558
+ }
2559
+ }
2560
+ if (!deeper) return el
2561
+ }
2562
+ return null
2563
+ }
2564
+
2565
+ function applyPatchDisplay(snapshot, view, t) {
2566
+ // Map a reply seq to the host row that draws it, so a short answer like
2567
+ // "好的" cannot be matched in a different row. Fall back to a global
2568
+ // search when the row is not mounted (virtualised histories).
2569
+ const rowBySeq = new Map()
2570
+ // A turn-process row also covers the turn's assistant seq (seqsFor reads
2571
+ // its anchor), and it comes before the assistant row in document order.
2572
+ // First-win mapping therefore pointed the patch at the process row, whose
2573
+ // subtree has no message body: zero matches. Prefer the row that actually
2574
+ // draws the message; the process/tail rows are only a last resort.
2575
+ const rowRank = new Map()
2576
+ try {
2577
+ for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
2578
+ const key = row.getAttribute('data-chat-flow-key')
2579
+ if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
2580
+ const node = snapshot.nodes.get(key)
2581
+ if (!node) continue
2582
+ const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
2583
+ for (const seq of seqsFor(node)) {
2584
+ const previous = rowRank.get(seq)
2585
+ if (previous === undefined || rank < previous) {
2586
+ rowBySeq.set(seq, row)
2587
+ rowRank.set(seq, rank)
2588
+ }
2589
+ }
2590
+ }
2591
+ } catch {
2592
+ /* a host shape this pass cannot read falls back below */
2593
+ }
2594
+ const root = conversationRootForPatches()
2595
+ if (root === null) return
2596
+ const replies = view.replies instanceof Map ? view.replies : new Map()
2597
+ const wantedText = new Map()
2598
+ const wantedNotes = new Map()
2599
+ for (const patch of Array.isArray(view.patches) ? view.patches : []) {
2600
+ const reply = replies.get(patch.seq)
2601
+ if (reply === undefined || !Array.isArray(reply.blocks)) continue
2602
+ const block = reply.blocks.find((entry) => entry.index === patch.blockIndex)
2603
+ if (block === undefined) continue
2604
+ if (patch.kind === 'text') {
2605
+ wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
2606
+ key: `${patch.seq}:${patch.blockIndex}`,
2607
+ seq: patch.seq,
2608
+ original: String(block.original ?? ''),
2609
+ text: String(patch.text ?? ''),
2610
+ })
2611
+ } else if (patch.kind === 'reasoning') {
2612
+ wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
2613
+ key: `${patch.seq}:${patch.blockIndex}`,
2614
+ text: String(patch.text ?? ''),
2615
+ reply,
2616
+ block,
2617
+ })
2618
+ }
2619
+ }
2620
+
2621
+ // A patch that no longer exists must give the host's recorded text back,
2622
+ // even when a re-render already stripped our marker from the element.
2623
+ for (const [key, el] of [...patchedElements]) {
2624
+ if (wantedText.has(key)) continue
2625
+ patchedElements.delete(key)
2626
+ if (el.isConnected === true) restorePatchedBlock(el)
2627
+ }
2628
+ // Remove overrides whose patch is gone (or was superseded by a prompt
2629
+ // rollback). The original HTML is what the host rendered.
2630
+ for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
2631
+ const key = el.getAttribute(PATCH_ATTR)
2632
+ const wanted = wantedText.get(key)
2633
+ if (wanted === undefined) {
2634
+ restorePatchedBlock(el)
2635
+ continue
2636
+ }
2637
+ const shown = displayText(wanted.text)
2638
+ if (normalizedText(el.textContent) !== normalizedText(shown)) {
2639
+ el.textContent = shown
2640
+ }
2641
+ }
2642
+ for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
2643
+ const key = note.getAttribute(NOTE_ATTR)
2644
+ if (!wantedNotes.has(key)) note.remove()
2645
+ }
2646
+ if (wantedText.size === 0 && wantedNotes.size === 0) return
2647
+
2648
+ const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
2649
+ const containers = []
2650
+ for (const node of nodes) {
2651
+ for (const el of node.querySelectorAll('*')) {
2652
+ try {
2653
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
2654
+ } catch {
2655
+ /* a detached node is skipped */
2656
+ }
2657
+ }
2658
+ }
2659
+ for (const [key, wanted] of wantedText) {
2660
+ const owner = rowBySeq.get(wanted.seq)
2661
+ const remembered = patchedElements.get(key)
2662
+ if (remembered !== undefined) {
2663
+ const stillThere = remembered.isConnected === true &&
2664
+ (owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
2665
+ if (!stillThere) {
2666
+ patchedElements.delete(key)
2667
+ } else {
2668
+ const shown = displayText(wanted.text)
2669
+ if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
2670
+ remembered.textContent = shown
2671
+ }
2672
+ if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
2673
+ remembered.style.whiteSpace = 'pre-wrap'
2674
+ continue
2675
+ }
2676
+ }
2677
+ const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
2678
+ if (done !== undefined) {
2679
+ patchedElements.set(key, done)
2680
+ if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
2681
+ continue
2682
+ }
2683
+ const original = matchText(wanted.original)
2684
+ if (original === '') continue
2685
+ let best = null
2686
+ let bestScore = Infinity
2687
+ for (const el of containers) {
2688
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
2689
+ if (owner !== undefined) {
2690
+ try {
2691
+ if (el.closest('[data-chat-flow-key]') !== owner) continue
2692
+ } catch {
2693
+ /* no closest on this node: keep the global match */
2694
+ }
2695
+ }
2696
+ const text = matchText(el.textContent)
2697
+ if (text === '') continue
2698
+ const exact = text === original
2699
+ const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
2700
+ if (!exact && !prefix) continue
2701
+ // Prefer the exact match, then the container closest in length: a
2702
+ // nested paragraph and the whole markdown body can both match a
2703
+ // prefix, and the paragraph is the one to rewrite.
2704
+ const score = (exact ? 0 : 1000) + Math.abs(text.length - original.length)
2705
+ if (score < bestScore) {
2706
+ best = el
2707
+ bestScore = score
2708
+ }
2709
+ }
2710
+ if (best === null) best = findPatchTextFallback(owner, wanted)
2711
+ if (best === null) continue
2712
+ patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
2713
+ best.textContent = displayText(wanted.text)
2714
+ best.setAttribute(PATCH_ATTR, key)
2715
+ best.style.whiteSpace = 'pre-wrap'
2716
+ patchedElements.set(key, best)
2717
+ }
2718
+
2719
+ // Reasoning notes: put one under the reply row, found by the reply's own
2720
+ // answer text (the panel itself may be collapsed and absent).
2721
+ for (const [key, wanted] of wantedNotes) {
2722
+ let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
2723
+ let row = rowBySeq.get(wanted.reply.seq) ?? null
2724
+ const replyText = matchText(wanted.reply.text)
2725
+ for (const node of row === null ? nodes : []) {
2726
+ const text = matchText(node.textContent)
2727
+ if (text === '') continue
2728
+ if (replyText !== '' && text.includes(replyText)) {
2729
+ row = node
2730
+ break
2731
+ }
2732
+ if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
2733
+ row = node
2734
+ break
2735
+ }
2736
+ }
2737
+ if (note === undefined || note === null) {
2738
+ note = document.createElement('details')
2739
+ note.className = 'dshet-patch-note'
2740
+ note.setAttribute(NOTE_ATTR, key)
2741
+ const summary = document.createElement('summary')
2742
+ summary.textContent = t('editor.patch-note')
2743
+ const body = document.createElement('pre')
2744
+ body.className = 'dshet-patch-note-body'
2745
+ note.appendChild(summary)
2746
+ note.appendChild(body)
2747
+ }
2748
+ const body = note.querySelector('.dshet-patch-note-body')
2749
+ if (body !== null && body.textContent !== wanted.text) body.textContent = wanted.text
2750
+ if (note.isConnected !== true && row !== null) row.after(note)
2751
+ }
2752
+ }
2753
+
2267
2754
  function applyDom(snapshot, view, controller, t) {
2268
2755
  if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
2269
2756
  const animate = controller.consumeAnimate()
@@ -2329,6 +2816,13 @@ window.__ModuleLoader__.load({
2329
2816
  controller.editorBox = null
2330
2817
  controller.editorRow = null
2331
2818
  }
2819
+ // Draw the projected reply text last: the host has just rebuilt the rows,
2820
+ // and an override applied before that would be wiped by the rebuild.
2821
+ try {
2822
+ applyPatchDisplay(snapshot, view, t)
2823
+ } catch (error) {
2824
+ console.error('[dsh-edit-turn] patch display failed:', error)
2825
+ }
2332
2826
  // Whatever of ours no row of this pass claimed is a ghost of an earlier
2333
2827
  // apply: the host rebuilt the row under it, or the bundle was reloaded
2334
2828
  // over it. Contract §5 - a button nobody can update must not pile up.
@@ -2398,10 +2892,11 @@ window.__ModuleLoader__.load({
2398
2892
  messageId: entry.messageId,
2399
2893
  text: typeof entry.text === 'string' ? entry.text : '',
2400
2894
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2401
- // A reply edits text only on purpose: the tool calls and reasoning it
2402
- // carries stop being true the moment it is replaced, so they are not
2403
- // offered as chips (the editor warns about them instead).
2404
- blocks: [],
2895
+ // On the in-place path the reply keeps its tool calls and other
2896
+ // blocks, so the editor can offer each editable block (output text
2897
+ // and reasoning). On the replay path there is no block picker: the
2898
+ // whole reply is replaced.
2899
+ ...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
2405
2900
  })
2406
2901
  }
2407
2902
  // The config object is always an object: this runtime's `jsx` reads
@@ -2579,6 +3074,14 @@ window.__ModuleLoader__.load({
2579
3074
  // --- plugin ---------------------------------------------------------------
2580
3075
 
2581
3076
  function apply(ctx) {
3077
+ // Diagnostic only: the host restart does not reload the page's JS, so a
3078
+ // stale client bundle and a live host can coexist. This marker says which
3079
+ // browser half actually ran (the old bundle has none).
3080
+ try {
3081
+ document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
3082
+ } catch {
3083
+ /* a non-DOM host is not worth failing over */
3084
+ }
2582
3085
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
2583
3086
 
2584
3087
  const controllers = new Map()