dsh-edit-turn 0.2.21 → 0.3.0

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.0'
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,211 @@ 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
+
2456
+ function normalizedText(value) {
2457
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
2458
+ }
2459
+
2460
+ // Markdown syntax is not part of the rendered textContent. Strip the
2461
+ // structural characters before comparing, so a reply that uses **bold** or
2462
+ // a fenced block still matches its own recorded text.
2463
+ function plainText(value) {
2464
+ return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
2465
+ }
2466
+
2467
+ function isMarkdownContainer(el) {
2468
+ if (el === null || el === undefined || !el.children) return false
2469
+ const kids = el.children
2470
+ if (kids.length === 0) return false
2471
+ let blocks = 0
2472
+ for (const kid of kids) {
2473
+ try {
2474
+ if (kid.matches && kid.matches('p, pre, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table')) blocks += 1
2475
+ } catch {
2476
+ return false
2477
+ }
2478
+ }
2479
+ return blocks >= 1 && blocks * 2 >= kids.length
2480
+ }
2481
+
2482
+ function conversationRootForPatches() {
2483
+ const root = document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
2484
+ return root ?? document.body
2485
+ }
2486
+
2487
+ function restorePatchedBlock(el) {
2488
+ const saved = patchOriginals.get(el)
2489
+ if (saved !== null && saved !== undefined) {
2490
+ try {
2491
+ if (typeof saved.html === 'string' && saved.html !== '') el.innerHTML = saved.html
2492
+ else if (typeof saved.text === 'string') el.textContent = saved.text
2493
+ } catch {
2494
+ /* a detached row is not worth a throw */
2495
+ }
2496
+ }
2497
+ el.removeAttribute(PATCH_ATTR)
2498
+ el.style.whiteSpace = ''
2499
+ }
2500
+
2501
+ function applyPatchDisplay(snapshot, view, t) {
2502
+ // Map a reply seq to the host row that draws it, so a short answer like
2503
+ // "好的" cannot be matched in a different row. Fall back to a global
2504
+ // search when the row is not mounted (virtualised histories).
2505
+ const rowBySeq = new Map()
2506
+ try {
2507
+ for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
2508
+ const key = row.getAttribute('data-chat-flow-key')
2509
+ if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
2510
+ const node = snapshot.nodes.get(key)
2511
+ if (!node) continue
2512
+ for (const seq of seqsFor(node)) if (!rowBySeq.has(seq)) rowBySeq.set(seq, row)
2513
+ }
2514
+ } catch {
2515
+ /* a host shape this pass cannot read falls back below */
2516
+ }
2517
+ const root = conversationRootForPatches()
2518
+ if (root === null) return
2519
+ const replies = view.replies instanceof Map ? view.replies : new Map()
2520
+ const wantedText = new Map()
2521
+ const wantedNotes = new Map()
2522
+ for (const patch of Array.isArray(view.patches) ? view.patches : []) {
2523
+ const reply = replies.get(patch.seq)
2524
+ if (reply === undefined || !Array.isArray(reply.blocks)) continue
2525
+ const block = reply.blocks.find((entry) => entry.index === patch.blockIndex)
2526
+ if (block === undefined) continue
2527
+ if (patch.kind === 'text') {
2528
+ wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
2529
+ key: `${patch.seq}:${patch.blockIndex}`,
2530
+ seq: patch.seq,
2531
+ original: String(block.original ?? ''),
2532
+ text: String(patch.text ?? ''),
2533
+ })
2534
+ } else if (patch.kind === 'reasoning') {
2535
+ wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
2536
+ key: `${patch.seq}:${patch.blockIndex}`,
2537
+ text: String(patch.text ?? ''),
2538
+ reply,
2539
+ block,
2540
+ })
2541
+ }
2542
+ }
2543
+
2544
+ // Remove overrides whose patch is gone (or was superseded by a prompt
2545
+ // rollback). The original HTML is what the host rendered.
2546
+ for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
2547
+ const key = el.getAttribute(PATCH_ATTR)
2548
+ const wanted = wantedText.get(key)
2549
+ if (wanted === undefined) {
2550
+ restorePatchedBlock(el)
2551
+ continue
2552
+ }
2553
+ if (normalizedText(el.textContent) !== normalizedText(wanted.text)) {
2554
+ el.textContent = wanted.text
2555
+ }
2556
+ }
2557
+ for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
2558
+ const key = note.getAttribute(NOTE_ATTR)
2559
+ if (!wantedNotes.has(key)) note.remove()
2560
+ }
2561
+ if (wantedText.size === 0 && wantedNotes.size === 0) return
2562
+
2563
+ const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
2564
+ const containers = []
2565
+ for (const node of nodes) {
2566
+ for (const el of node.querySelectorAll('*')) {
2567
+ try {
2568
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
2569
+ } catch {
2570
+ /* a detached node is skipped */
2571
+ }
2572
+ }
2573
+ }
2574
+ for (const [key, wanted] of wantedText) {
2575
+ const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
2576
+ if (done !== undefined) continue
2577
+ const original = plainText(wanted.original)
2578
+ if (original === '') continue
2579
+ let best = null
2580
+ let bestScore = Infinity
2581
+ const owner = rowBySeq.get(wanted.seq)
2582
+ for (const el of containers) {
2583
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
2584
+ if (owner !== undefined) {
2585
+ try {
2586
+ if (el.closest('[data-chat-flow-key]') !== owner) continue
2587
+ } catch {
2588
+ /* no closest on this node: keep the global match */
2589
+ }
2590
+ }
2591
+ const text = plainText(el.textContent)
2592
+ if (text === '') continue
2593
+ const exact = text === original
2594
+ const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
2595
+ if (!exact && !prefix) continue
2596
+ // Prefer the exact match, then the container closest in length: a
2597
+ // nested paragraph and the whole markdown body can both match a
2598
+ // prefix, and the paragraph is the one to rewrite.
2599
+ const score = (exact ? 0 : 1000) + Math.abs(text.length - original.length)
2600
+ if (score < bestScore) {
2601
+ best = el
2602
+ bestScore = score
2603
+ }
2604
+ }
2605
+ if (best === null) continue
2606
+ patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
2607
+ best.textContent = wanted.text
2608
+ best.setAttribute(PATCH_ATTR, key)
2609
+ best.style.whiteSpace = 'pre-wrap'
2610
+ }
2611
+
2612
+ // Reasoning notes: put one under the reply row, found by the reply's own
2613
+ // answer text (the panel itself may be collapsed and absent).
2614
+ for (const [key, wanted] of wantedNotes) {
2615
+ let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
2616
+ let row = rowBySeq.get(wanted.reply.seq) ?? null
2617
+ const replyText = plainText(wanted.reply.text)
2618
+ for (const node of row === null ? nodes : []) {
2619
+ const text = plainText(node.textContent)
2620
+ if (text === '') continue
2621
+ if (replyText !== '' && text.includes(replyText)) {
2622
+ row = node
2623
+ break
2624
+ }
2625
+ if (plainText(wanted.block.original) !== '' && text.includes(plainText(wanted.block.original))) {
2626
+ row = node
2627
+ break
2628
+ }
2629
+ }
2630
+ if (note === undefined || note === null) {
2631
+ note = document.createElement('details')
2632
+ note.className = 'dshet-patch-note'
2633
+ note.setAttribute(NOTE_ATTR, key)
2634
+ const summary = document.createElement('summary')
2635
+ summary.textContent = t('editor.patch-note')
2636
+ const body = document.createElement('pre')
2637
+ body.className = 'dshet-patch-note-body'
2638
+ note.appendChild(summary)
2639
+ note.appendChild(body)
2640
+ }
2641
+ const body = note.querySelector('.dshet-patch-note-body')
2642
+ if (body !== null && body.textContent !== wanted.text) body.textContent = wanted.text
2643
+ if (note.isConnected !== true && row !== null) row.after(note)
2644
+ }
2645
+ }
2646
+
2267
2647
  function applyDom(snapshot, view, controller, t) {
2268
2648
  if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
2269
2649
  const animate = controller.consumeAnimate()
@@ -2329,6 +2709,13 @@ window.__ModuleLoader__.load({
2329
2709
  controller.editorBox = null
2330
2710
  controller.editorRow = null
2331
2711
  }
2712
+ // Draw the projected reply text last: the host has just rebuilt the rows,
2713
+ // and an override applied before that would be wiped by the rebuild.
2714
+ try {
2715
+ applyPatchDisplay(snapshot, view, t)
2716
+ } catch (error) {
2717
+ console.error('[dsh-edit-turn] patch display failed:', error)
2718
+ }
2332
2719
  // Whatever of ours no row of this pass claimed is a ghost of an earlier
2333
2720
  // apply: the host rebuilt the row under it, or the bundle was reloaded
2334
2721
  // over it. Contract §5 - a button nobody can update must not pile up.
@@ -2398,10 +2785,11 @@ window.__ModuleLoader__.load({
2398
2785
  messageId: entry.messageId,
2399
2786
  text: typeof entry.text === 'string' ? entry.text : '',
2400
2787
  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: [],
2788
+ // On the in-place path the reply keeps its tool calls and other
2789
+ // blocks, so the editor can offer each editable block (output text
2790
+ // and reasoning). On the replay path there is no block picker: the
2791
+ // whole reply is replaced.
2792
+ ...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
2405
2793
  })
2406
2794
  }
2407
2795
  // The config object is always an object: this runtime's `jsx` reads