dsh-edit-turn 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,7 +164,7 @@ ctx.sessions.registerMessageProjection({
164
164
  | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
165
165
  | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
166
166
  | 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 96 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)**,以及**就地补丁:一条 ignorable 记录、surface 不变、去掉投影后原文回来、后续轮次与 tool 结果不动、第二条记录可叠加** |
167
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 205 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
167
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 213 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
168
168
  | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) | `npm run verify:client` | 184 项全部通过(含 12 条块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、**两侧都不特判块类型**、块逐字节复制、文字归位、`dropped` 上报、只在无服务时警告、芯片靠 `error` 事件自证) |
169
169
  | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
170
170
  | **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
@@ -250,6 +250,23 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
250
250
 
251
251
  ### 更新日志
252
252
 
253
+ **0.3.2** —— 修复编辑器在长回答或页面底部时位置错误、按钮点不到的问题。
254
+
255
+ - **视口钳制**:编辑框优先放在行下方;放不下就翻到行上方;行比屏幕高、底部在视口外时钉在视口底部,不再跟着行跑到屏幕外。
256
+ - **自身滚动**:编辑框有 `max-height` 和 `overflow-y:auto`,保存/取消所在的 footer 用 `position:sticky` 固定,长草稿也能滚到按钮。
257
+ - **宽度/左边界**也按视口钳制,窄窗口不会被推出屏幕。
258
+ - **测试**:`npm test` **213/213**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
259
+
260
+ **0.3.1** —— 修复 0.3.0 的显示与跨插件清理问题。
261
+
262
+ - **编辑后看不到变化**:`turn-process` 行和 `assistant-step` 行都覆盖同一个 seq,旧的 DOM 映射按文档顺序先拿到过程行,补丁落到没有正文的行上。现在优先 assistant 行;极短回答是裸文本节点时也有兜底。
263
+ - **Markdown 匹配**:原文有 `**1**`、渲染后是 `1。`,旧比较把 `**` 换成空格后变成 `1 。`,逐字比较失败。现在比较忽略全部空白;显示时去掉 `**`、反引号和标题标记,不把 Markdown 源码画在正文里。
264
+ - **React 重渲染**:会清掉补丁元素的属性和样式但保留文本;现在按 `seq:block` 记住元素,连续第二次编辑仍能更新并能在补丁消失时恢复原文。
265
+ - **过时补丁**:目标已被 rerun 退役的 patch 不再出现在 `/state.patches`。
266
+ - **轮次丢失**:prompt 替换载体在 turn 括号之外追加,`turnIndex` 取不到轮次;现在从 `sourceEventSeqs` 继承被替换的轮次。
267
+ - **诊断**:客户端在 `<html>` 上写 `data-dshet-version`,可确认页面实际跑的客户端版本。
268
+ - **测试**:`npm test` **210/210**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
269
+
253
270
  **0.3.0** —— 回复编辑不再重放:用官方 plugin record + message projection 就地打补丁,正文与思维链都能改,日志每次只多一条小记录。
254
271
 
255
272
  - **为什么改**:0.2.21 的回复编辑虽然保住了后续轮次,但代价是「遮蔽到末尾 + 逐事件重放」——改一条中间回答,日志会多出整段副本,轮次号、messageId 全换,工具调用/思维链也留不住。用户要的是「像改提示词一样改回答」,而不是一次 infix splice。
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.3.0'
45
+ const PLUGIN_VERSION = '0.3.2'
46
46
 
47
47
  // --- copy -----------------------------------------------------------------
48
48
 
@@ -262,7 +262,7 @@ window.__ModuleLoader__.load({
262
262
  // positioned under its row by `placeEditor`.
263
263
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
264
264
  '.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
265
- '.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
265
+ '.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 16px);overflow-y:auto;overscroll-behavior:contain;margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
266
266
  '.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
267
267
  '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
268
268
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
@@ -290,7 +290,7 @@ window.__ModuleLoader__.load({
290
290
  '.dshet-note{color:var(--dshet-ink-dim)}',
291
291
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
292
292
  '.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
293
- '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
293
+ '.dshet-footer{position:sticky;bottom:0;z-index:1;display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2)}',
294
294
  '.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
295
295
  '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
296
296
  '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
@@ -1945,13 +1945,51 @@ window.__ModuleLoader__.load({
1945
1945
  // Put the box under the row it belongs to: same width, just below its lower
1946
1946
  // edge. Called on render and whenever the page scrolls or resizes, since
1947
1947
  // the layer is fixed to the viewport.
1948
+ function viewportSize() {
1949
+ const win = typeof window === 'undefined' ? undefined : window
1950
+ const doc = typeof document === 'undefined' ? undefined : document
1951
+ const width = (win !== undefined && win.innerWidth) ||
1952
+ (doc !== undefined && doc.documentElement && doc.documentElement.clientWidth) || 0
1953
+ const height = (win !== undefined && win.innerHeight) ||
1954
+ (doc !== undefined && doc.documentElement && doc.documentElement.clientHeight) || 0
1955
+ return { width, height }
1956
+ }
1957
+
1958
+ // Put the box under the row it belongs to: same width, just below its lower
1959
+ // edge. The layer is fixed to the viewport, so a row near the bottom (or one
1960
+ // taller than the screen) used to push the footer - and its save button -
1961
+ // past the bottom edge where nothing could reach it. Flip above the row when
1962
+ // there is room, otherwise pin the box to the bottom of the viewport and let
1963
+ // its own scrollbar carry the content; the footer stays sticky either way.
1948
1964
  function placeEditor(row, box) {
1949
1965
  const layer = ensureEditorLayer()
1950
1966
  if (box.parentElement !== layer) layer.appendChild(box)
1951
1967
  const rect = row.getBoundingClientRect()
1952
- box.style.left = `${Math.round(rect.left)}px`
1953
- box.style.top = `${Math.round(rect.bottom + 6)}px`
1954
- box.style.width = `${Math.round(rect.width)}px`
1968
+ const viewport = viewportSize()
1969
+ const margin = 8
1970
+ const width = viewport.width > 0
1971
+ ? Math.max(1, Math.min(Math.max(200, rect.width || 0), viewport.width - margin * 2))
1972
+ : Math.max(200, rect.width || 0)
1973
+ box.style.width = `${Math.round(width)}px`
1974
+ const measured = box.getBoundingClientRect()
1975
+ const height = (measured && measured.height) || box.offsetHeight || 0
1976
+ let top = rect.bottom + 6
1977
+ if (viewport.height > 0 && height > 0) {
1978
+ const maxTop = Math.max(margin, viewport.height - height - margin)
1979
+ if (top + height > viewport.height - margin) {
1980
+ const above = rect.top - height - 6
1981
+ top = above >= margin ? above : maxTop
1982
+ }
1983
+ // A row taller than the screen has its bottom above the viewport after
1984
+ // the user scrolls to its action bar; without this clamp the box follows
1985
+ // it off-screen and the buttons become unreachable again.
1986
+ top = Math.max(margin, Math.min(top, maxTop))
1987
+ }
1988
+ let left = rect.left
1989
+ if (viewport.width > 0 && left + width > viewport.width - margin) left = viewport.width - width - margin
1990
+ if (left < margin) left = margin
1991
+ box.style.left = `${Math.round(left)}px`
1992
+ box.style.top = `${Math.round(top)}px`
1955
1993
  }
1956
1994
 
1957
1995
  // Grow the textarea to its content, the way a normal input box behaves.
@@ -2297,8 +2335,16 @@ window.__ModuleLoader__.load({
2297
2335
  // "eats" pinyin.
2298
2336
  if (event.isComposing === true) return
2299
2337
  fitEditorHeight(area)
2338
+ const row = controller.editorRow
2339
+ const box = controller.editorBox
2340
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
2341
+ })
2342
+ area.addEventListener('compositionend', () => {
2343
+ fitEditorHeight(area)
2344
+ const row = controller.editorRow
2345
+ const box = controller.editorBox
2346
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
2300
2347
  })
2301
- area.addEventListener('compositionend', () => fitEditorHeight(area))
2302
2348
  area.addEventListener('focus', () => {
2303
2349
  controller.editorFocus = true
2304
2350
  })
@@ -2408,11 +2454,11 @@ window.__ModuleLoader__.load({
2408
2454
  footer.appendChild(submit)
2409
2455
  box.appendChild(footer)
2410
2456
 
2411
- placeEditor(row, box)
2412
2457
  controller.editorBox = box
2413
2458
  controller.editorRow = row
2414
2459
  controller.editorElement = area
2415
2460
  fitEditorHeight(area)
2461
+ placeEditor(row, box)
2416
2462
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
2417
2463
  try {
2418
2464
  area.focus()
@@ -2452,6 +2498,11 @@ window.__ModuleLoader__.load({
2452
2498
  const PATCH_ATTR = 'data-dshet-patch'
2453
2499
  const NOTE_ATTR = 'data-dshet-patch-note'
2454
2500
  const patchOriginals = new WeakMap()
2501
+ // React may re-render the assistant container and reset its attributes and
2502
+ // style while leaving the text node we replaced. Remember the element by
2503
+ // seq:block so the next pass can restore the marker and, on a later edit,
2504
+ // update it without having to re-find the original text (which is gone).
2505
+ const patchedElements = new Map()
2455
2506
 
2456
2507
  function normalizedText(value) {
2457
2508
  return String(value ?? '').replace(/\s+/g, ' ').trim()
@@ -2464,6 +2515,27 @@ window.__ModuleLoader__.load({
2464
2515
  return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
2465
2516
  }
2466
2517
 
2518
+ // Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
2519
+ // the spaces left by stripping the markers must not defeat the comparison.
2520
+ // Whitespace carries no block identity here, so remove it entirely.
2521
+ function matchText(value) {
2522
+ return plainText(value).replace(/\s+/g, '')
2523
+ }
2524
+
2525
+ // What the reader sees for a patched block. The model context keeps the
2526
+ // raw Markdown the editor wrote; the transcript should not show the markers
2527
+ // as literal text (`**1**`), which is what made a successful edit look like
2528
+ // an unrendered draft. This is deliberately minimal: bold, inline code and
2529
+ // headings are unwrapped; everything else stays as written.
2530
+ function displayText(value) {
2531
+ return String(value ?? '')
2532
+ .replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
2533
+ .replace(/`([^`]*)`/g, '$1')
2534
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
2535
+ .replace(/__([^_]+)__/g, '$1')
2536
+ .replace(/^#{1,6}\s+/gm, '')
2537
+ }
2538
+
2467
2539
  function isMarkdownContainer(el) {
2468
2540
  if (el === null || el === undefined || !el.children) return false
2469
2541
  const kids = el.children
@@ -2498,18 +2570,69 @@ window.__ModuleLoader__.load({
2498
2570
  el.style.whiteSpace = ''
2499
2571
  }
2500
2572
 
2573
+ // A short reply can be a bare text node (or a span) rather than the
2574
+ // block-level markdown container the picker looks for. Match the deepest
2575
+ // element in the owning row whose whole text is exactly the recorded text;
2576
+ // never a button, a timestamp, our own chrome, or an ancestor that merely
2577
+ // contains the text.
2578
+ function findPatchTextFallback(owner, wanted) {
2579
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
2580
+ const original = matchText(wanted.original)
2581
+ if (original === '') return null
2582
+ let scope = owner
2583
+ try {
2584
+ const node = owner.querySelector('[data-slot="conversation.chat.node"]')
2585
+ if (node !== null && node !== undefined) scope = node
2586
+ } catch {
2587
+ /* keep the row as the scope */
2588
+ }
2589
+ for (const el of scope.querySelectorAll('*')) {
2590
+ if (el === scope) continue
2591
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
2592
+ try {
2593
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
2594
+ } catch {
2595
+ continue
2596
+ }
2597
+ const text = matchText(el.textContent)
2598
+ if (text !== original) continue
2599
+ let deeper = false
2600
+ for (const child of el.children ?? []) {
2601
+ if (matchText(child.textContent) === text) {
2602
+ deeper = true
2603
+ break
2604
+ }
2605
+ }
2606
+ if (!deeper) return el
2607
+ }
2608
+ return null
2609
+ }
2610
+
2501
2611
  function applyPatchDisplay(snapshot, view, t) {
2502
2612
  // Map a reply seq to the host row that draws it, so a short answer like
2503
2613
  // "好的" cannot be matched in a different row. Fall back to a global
2504
2614
  // search when the row is not mounted (virtualised histories).
2505
2615
  const rowBySeq = new Map()
2616
+ // A turn-process row also covers the turn's assistant seq (seqsFor reads
2617
+ // its anchor), and it comes before the assistant row in document order.
2618
+ // First-win mapping therefore pointed the patch at the process row, whose
2619
+ // subtree has no message body: zero matches. Prefer the row that actually
2620
+ // draws the message; the process/tail rows are only a last resort.
2621
+ const rowRank = new Map()
2506
2622
  try {
2507
2623
  for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
2508
2624
  const key = row.getAttribute('data-chat-flow-key')
2509
2625
  if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
2510
2626
  const node = snapshot.nodes.get(key)
2511
2627
  if (!node) continue
2512
- for (const seq of seqsFor(node)) if (!rowBySeq.has(seq)) rowBySeq.set(seq, row)
2628
+ const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
2629
+ for (const seq of seqsFor(node)) {
2630
+ const previous = rowRank.get(seq)
2631
+ if (previous === undefined || rank < previous) {
2632
+ rowBySeq.set(seq, row)
2633
+ rowRank.set(seq, rank)
2634
+ }
2635
+ }
2513
2636
  }
2514
2637
  } catch {
2515
2638
  /* a host shape this pass cannot read falls back below */
@@ -2541,6 +2664,13 @@ window.__ModuleLoader__.load({
2541
2664
  }
2542
2665
  }
2543
2666
 
2667
+ // A patch that no longer exists must give the host's recorded text back,
2668
+ // even when a re-render already stripped our marker from the element.
2669
+ for (const [key, el] of [...patchedElements]) {
2670
+ if (wantedText.has(key)) continue
2671
+ patchedElements.delete(key)
2672
+ if (el.isConnected === true) restorePatchedBlock(el)
2673
+ }
2544
2674
  // Remove overrides whose patch is gone (or was superseded by a prompt
2545
2675
  // rollback). The original HTML is what the host rendered.
2546
2676
  for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
@@ -2550,8 +2680,9 @@ window.__ModuleLoader__.load({
2550
2680
  restorePatchedBlock(el)
2551
2681
  continue
2552
2682
  }
2553
- if (normalizedText(el.textContent) !== normalizedText(wanted.text)) {
2554
- el.textContent = wanted.text
2683
+ const shown = displayText(wanted.text)
2684
+ if (normalizedText(el.textContent) !== normalizedText(shown)) {
2685
+ el.textContent = shown
2555
2686
  }
2556
2687
  }
2557
2688
  for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
@@ -2572,13 +2703,33 @@ window.__ModuleLoader__.load({
2572
2703
  }
2573
2704
  }
2574
2705
  for (const [key, wanted] of wantedText) {
2706
+ const owner = rowBySeq.get(wanted.seq)
2707
+ const remembered = patchedElements.get(key)
2708
+ if (remembered !== undefined) {
2709
+ const stillThere = remembered.isConnected === true &&
2710
+ (owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
2711
+ if (!stillThere) {
2712
+ patchedElements.delete(key)
2713
+ } else {
2714
+ const shown = displayText(wanted.text)
2715
+ if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
2716
+ remembered.textContent = shown
2717
+ }
2718
+ if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
2719
+ remembered.style.whiteSpace = 'pre-wrap'
2720
+ continue
2721
+ }
2722
+ }
2575
2723
  const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
2576
- if (done !== undefined) continue
2577
- const original = plainText(wanted.original)
2724
+ if (done !== undefined) {
2725
+ patchedElements.set(key, done)
2726
+ if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
2727
+ continue
2728
+ }
2729
+ const original = matchText(wanted.original)
2578
2730
  if (original === '') continue
2579
2731
  let best = null
2580
2732
  let bestScore = Infinity
2581
- const owner = rowBySeq.get(wanted.seq)
2582
2733
  for (const el of containers) {
2583
2734
  if (el.getAttribute(PATCH_ATTR) !== null) continue
2584
2735
  if (owner !== undefined) {
@@ -2588,7 +2739,7 @@ window.__ModuleLoader__.load({
2588
2739
  /* no closest on this node: keep the global match */
2589
2740
  }
2590
2741
  }
2591
- const text = plainText(el.textContent)
2742
+ const text = matchText(el.textContent)
2592
2743
  if (text === '') continue
2593
2744
  const exact = text === original
2594
2745
  const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
@@ -2602,11 +2753,13 @@ window.__ModuleLoader__.load({
2602
2753
  bestScore = score
2603
2754
  }
2604
2755
  }
2756
+ if (best === null) best = findPatchTextFallback(owner, wanted)
2605
2757
  if (best === null) continue
2606
2758
  patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
2607
- best.textContent = wanted.text
2759
+ best.textContent = displayText(wanted.text)
2608
2760
  best.setAttribute(PATCH_ATTR, key)
2609
2761
  best.style.whiteSpace = 'pre-wrap'
2762
+ patchedElements.set(key, best)
2610
2763
  }
2611
2764
 
2612
2765
  // Reasoning notes: put one under the reply row, found by the reply's own
@@ -2614,15 +2767,15 @@ window.__ModuleLoader__.load({
2614
2767
  for (const [key, wanted] of wantedNotes) {
2615
2768
  let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
2616
2769
  let row = rowBySeq.get(wanted.reply.seq) ?? null
2617
- const replyText = plainText(wanted.reply.text)
2770
+ const replyText = matchText(wanted.reply.text)
2618
2771
  for (const node of row === null ? nodes : []) {
2619
- const text = plainText(node.textContent)
2772
+ const text = matchText(node.textContent)
2620
2773
  if (text === '') continue
2621
2774
  if (replyText !== '' && text.includes(replyText)) {
2622
2775
  row = node
2623
2776
  break
2624
2777
  }
2625
- if (plainText(wanted.block.original) !== '' && text.includes(plainText(wanted.block.original))) {
2778
+ if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
2626
2779
  row = node
2627
2780
  break
2628
2781
  }
@@ -2967,6 +3120,14 @@ window.__ModuleLoader__.load({
2967
3120
  // --- plugin ---------------------------------------------------------------
2968
3121
 
2969
3122
  function apply(ctx) {
3123
+ // Diagnostic only: the host restart does not reload the page's JS, so a
3124
+ // stale client bundle and a live host can coexist. This marker says which
3125
+ // browser half actually ran (the old bundle has none).
3126
+ try {
3127
+ document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
3128
+ } catch {
3129
+ /* a non-DOM host is not worth failing over */
3130
+ }
2970
3131
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
2971
3132
 
2972
3133
  const controllers = new Map()
package/lib/index.js CHANGED
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
80
  export const name = PLUGIN_ID
81
81
 
82
82
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.3.0'
83
+ export const PLUGIN_VERSION = '0.3.2'
84
84
 
85
85
  // A ring of the last few requests, answering "did the browser half even ask, and
86
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -1239,7 +1239,20 @@ export function editableTurns(events, surfaceNodes, options = {}) {
1239
1239
  if (!event || !isHumanPrompt(event)) continue
1240
1240
  if (seq === head) continue
1241
1241
  const { text, attachments } = readMessageText(event)
1242
- const turn = turnOf.get(seq)
1242
+ // A replacement carrier is appended after its original turn's bracket has
1243
+ // closed, so turnIndex cannot name its turn. It still stands at that
1244
+ // prompt's position in the conversation: inherit the turn from the event
1245
+ // it replaced, or the editor's turn list loses the row (turn: null).
1246
+ let turn = turnOf.get(seq)
1247
+ if (typeof turn !== 'number' && Array.isArray(event.sourceEventSeqs)) {
1248
+ for (const source of event.sourceEventSeqs) {
1249
+ const inherited = turnOf.get(source)
1250
+ if (typeof inherited === 'number') {
1251
+ turn = inherited
1252
+ break
1253
+ }
1254
+ }
1255
+ }
1243
1256
  out.push({
1244
1257
  seq,
1245
1258
  turn: typeof turn === 'number' ? turn : null,
@@ -1645,6 +1658,13 @@ async function stateOf(ctx, sessionId, config) {
1645
1658
  const folded = foldSurface(events)
1646
1659
  const ledger = rollbackLedger(events)
1647
1660
  const patches = patchesOf(events)
1661
+ // A patch whose target has been retired (a later rerun shadowed it) is inert:
1662
+ // the projection skips it and no row can show it. Reporting it in /state made
1663
+ // the browser draw a patch that could never land, and made "which patches are
1664
+ // live" ambiguous. Keep the internal map for the replies; publish the active
1665
+ // subset only.
1666
+ const surfaceSet = new Set(folded.nodes)
1667
+ const activePatches = new Map([...patches].filter(([seq]) => surfaceSet.has(seq)))
1648
1668
  const live = findLiveSession(ctx, sessionId)
1649
1669
  // Probed per request: the editor offers what THIS deployment can do right
1650
1670
  // now, so a store that is unmounted (or not yet mounted) cannot leave the
@@ -1676,7 +1696,7 @@ async function stateOf(ctx, sessionId, config) {
1676
1696
  // The landed in-place patches, latest per block. The browser half uses them
1677
1697
  // to draw the projected text in place of the recorded one; the model already
1678
1698
  // sees them through the host projection.
1679
- patches: patchList(patches),
1699
+ patches: patchList(activePatches),
1680
1700
  mode: config.patch === true ? 'patch' : 'replay',
1681
1701
  live: Boolean(live),
1682
1702
  busy: isBusy(events),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",