dsh-edit-turn 0.2.20 → 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.20'
45
+ const PLUGIN_VERSION = '0.3.0'
46
46
 
47
47
  // --- copy -----------------------------------------------------------------
48
48
 
@@ -59,11 +59,17 @@ window.__ModuleLoader__.load({
59
59
  'action.copied': '复制成功',
60
60
  'editor.title': '编辑这条消息',
61
61
  'editor.title-reply': '编辑这条回答',
62
- 'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
62
+ 'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
63
63
  'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
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': '正在保存…',
@@ -71,12 +77,13 @@ window.__ModuleLoader__.load({
71
77
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
72
78
  'editor.attach.add': '添加附件',
73
79
  'editor.attach.remove': '移除这个附件',
74
- 'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
80
+ 'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
75
81
  'error.invalid': '请求无效,请刷新后重试。',
76
82
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
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': '请求来源不被允许。',
@@ -96,6 +103,7 @@ window.__ModuleLoader__.load({
96
103
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
97
104
  'error.attachment-too-large': '这个文件太大,无法作为附件。',
98
105
  'error.too-large': '这次修改太大,服务器没有接收。',
106
+ 'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
99
107
  }
100
108
 
101
109
  const en = {
@@ -106,11 +114,17 @@ window.__ModuleLoader__.load({
106
114
  'action.copied': 'Copied',
107
115
  'editor.title': 'Edit this message',
108
116
  'editor.title-reply': 'Edit this reply',
109
- 'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
117
+ 'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
110
118
  'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
111
119
  'editor.save': 'Save',
112
120
  'editor.rerun': 'Re-run',
113
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)',
114
128
  'editor.review': 'Confirm',
115
129
  'editor.cancel': 'Cancel',
116
130
  'editor.pending': 'Working...',
@@ -118,12 +132,13 @@ window.__ModuleLoader__.load({
118
132
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
119
133
  'editor.attach.add': 'Add an attachment',
120
134
  'editor.attach.remove': 'Remove this attachment',
121
- 'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
135
+ 'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
122
136
  'error.invalid': 'Invalid request; refresh and try again.',
123
137
  'error.session-not-active': 'This session is not open in DSH; open it first.',
124
138
  'error.session-not-found': 'No session log was found for this id.',
125
139
  'error.busy': 'This session is still replying; wait for it to finish.',
126
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.',
127
142
  'error.already-rolled-back': 'This message is no longer in the current context.',
128
143
  'error.stale': 'The session just changed; try again.',
129
144
  'error.forbidden': 'The request origin is not allowed.',
@@ -143,6 +158,7 @@ window.__ModuleLoader__.load({
143
158
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
144
159
  'error.attachment-too-large': 'That file is too large to attach.',
145
160
  'error.too-large': 'That revision was too large for the host to accept.',
161
+ 'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
146
162
  }
147
163
 
148
164
  // Every error code the host can return travels through here. A code is
@@ -281,6 +297,12 @@ window.__ModuleLoader__.load({
281
297
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
282
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))}',
283
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}',
284
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}',
285
307
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
286
308
  ].join('')
@@ -368,6 +390,15 @@ window.__ModuleLoader__.load({
368
390
  editable: new Map(),
369
391
  replies: new Map(),
370
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,
371
402
  editing: null,
372
403
  draft: '',
373
404
  parts: [],
@@ -446,6 +477,22 @@ window.__ModuleLoader__.load({
446
477
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
447
478
  if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
448
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
+ }
449
496
  // The model's replies are editable too, as a separate list: they are
450
497
  // rewritten rather than re-run, so the editor words the consequence
451
498
  // differently.
@@ -464,6 +511,9 @@ window.__ModuleLoader__.load({
464
511
  editable,
465
512
  replies,
466
513
  repliesByMessage,
514
+ patches,
515
+ patchBySeq,
516
+ replyMode: data.mode === 'patch' ? 'patch' : 'replay',
467
517
  surfaceReady: true,
468
518
  loaded: true,
469
519
  loadError: false,
@@ -491,10 +541,34 @@ window.__ModuleLoader__.load({
491
541
  const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
492
542
  const previous = this.editorElement
493
543
  this.load()
494
- 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
495
569
  if (!same) {
496
- this.editorDraft = target.text
497
- this.editorParts = blocksToParts(target, this.sessionId)
570
+ this.editorDraft = chosen === undefined ? target.text : chosen.text
571
+ this.editorParts = blocksToParts(normalized, this.sessionId)
498
572
  }
499
573
  this.confirming = false
500
574
  this.editorElement = null
@@ -511,7 +585,16 @@ window.__ModuleLoader__.load({
511
585
  /* focus is best-effort */
512
586
  }
513
587
  }
514
- 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
+ })
515
598
  this.probeSiblingRerun()
516
599
  }
517
600
 
@@ -539,6 +622,22 @@ window.__ModuleLoader__.load({
539
622
  if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
540
623
  }
541
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
+
542
641
  close() {
543
642
  if (this.view.pending) return
544
643
  this.pendingRerun = false
@@ -551,7 +650,10 @@ window.__ModuleLoader__.load({
551
650
  this.editorParts = []
552
651
  this.confirming = false
553
652
  this.editorElement = null
554
- 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 })
555
657
  }
556
658
 
557
659
  setDraft(value) {
@@ -681,6 +783,11 @@ window.__ModuleLoader__.load({
681
783
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
682
784
  text,
683
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
+ : {}),
684
791
  }),
685
792
  })
686
793
  const data = await res.json().catch(() => ({}))
@@ -722,6 +829,40 @@ window.__ModuleLoader__.load({
722
829
  // replies here and re-enters through the next /state refresh (the
723
830
  // appended correction is itself an editable reply).
724
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
+ }
725
866
  for (const entry of replies.values()) {
726
867
  if (hidden.has(entry.seq)) replies.delete(entry.seq)
727
868
  }
@@ -742,6 +883,11 @@ window.__ModuleLoader__.load({
742
883
  revisions,
743
884
  editable,
744
885
  replies,
886
+ patches,
887
+ patchBySeq,
888
+ blocks: [],
889
+ blockIndex: null,
890
+ blockKind: null,
745
891
  parts: [],
746
892
  failure: null,
747
893
  })
@@ -775,6 +921,11 @@ window.__ModuleLoader__.load({
775
921
  // the only place the user can still be told (see applyEdit's
776
922
  // `dropped`).
777
923
  if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
924
+ // The correction landed, but replaying the turns behind it did not:
925
+ // the editor is already gone, so this is the only place left to say
926
+ // that the tail is missing from the context instead of silently
927
+ // hiding rows the user still expects.
928
+ if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
778
929
  }
779
930
  // Save first, re-run second - always: the sibling re-runs the turn
780
931
  // from the prompt the surface NOW shows, so the revised wording has
@@ -1958,6 +2109,8 @@ window.__ModuleLoader__.load({
1958
2109
  // saving would write, so both have to redraw the strip - and the chip
1959
2110
  // keys are enough to tell one list from another.
1960
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),
1961
2114
  ].join('|')
1962
2115
  }
1963
2116
 
@@ -1966,9 +2119,10 @@ window.__ModuleLoader__.load({
1966
2119
  // from drifting apart in wording.
1967
2120
  function editorTexts(t, target) {
1968
2121
  const reply = target.mode === 'reply'
2122
+ const block = reply && target.patch === true
1969
2123
  return {
1970
2124
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
1971
- placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
2125
+ placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
1972
2126
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
1973
2127
  // Saving writes the revision into the context; only the opt-in config asks
1974
2128
  // the model again, and the button says which one will happen.
@@ -2101,6 +2255,34 @@ window.__ModuleLoader__.load({
2101
2255
  box.dataset[EDITOR_ATTR] = '1'
2102
2256
  box.dataset.dshetFor = marker
2103
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
+
2104
2286
  const area = document.createElement('textarea')
2105
2287
  area.rows = 1
2106
2288
  area.value = controller.editorDraft
@@ -2160,12 +2342,12 @@ window.__ModuleLoader__.load({
2160
2342
  if (view.confirming) {
2161
2343
  const note = document.createElement('p')
2162
2344
  note.className = 'dshet-note'
2163
- note.textContent = t('confirm.body')
2345
+ note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
2164
2346
  box.appendChild(note)
2165
2347
  }
2166
2348
  // Shown when saving really would drop something: a reply's tool calls
2167
2349
  // always, and a prompt's blocks when this host cannot carry them.
2168
- if (target.attachments > 0 && !chips) {
2350
+ if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
2169
2351
  const warn = document.createElement('p')
2170
2352
  warn.className = 'dshet-warn'
2171
2353
  warn.textContent = texts.warn
@@ -2257,6 +2439,211 @@ window.__ModuleLoader__.load({
2257
2439
  controller.load(true)
2258
2440
  }
2259
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
+
2260
2647
  function applyDom(snapshot, view, controller, t) {
2261
2648
  if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
2262
2649
  const animate = controller.consumeAnimate()
@@ -2322,6 +2709,13 @@ window.__ModuleLoader__.load({
2322
2709
  controller.editorBox = null
2323
2710
  controller.editorRow = null
2324
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
+ }
2325
2719
  // Whatever of ours no row of this pass claimed is a ghost of an earlier
2326
2720
  // apply: the host rebuilt the row under it, or the bundle was reloaded
2327
2721
  // over it. Contract §5 - a button nobody can update must not pile up.
@@ -2391,10 +2785,11 @@ window.__ModuleLoader__.load({
2391
2785
  messageId: entry.messageId,
2392
2786
  text: typeof entry.text === 'string' ? entry.text : '',
2393
2787
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2394
- // A reply edits text only on purpose: the tool calls and reasoning it
2395
- // carries stop being true the moment it is replaced, so they are not
2396
- // offered as chips (the editor warns about them instead).
2397
- 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: [] }),
2398
2793
  })
2399
2794
  }
2400
2795
  // The config object is always an object: this runtime's `jsx` reads