dsh-edit-turn 0.2.12 → 0.2.14

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
@@ -213,6 +213,20 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
213
213
 
214
214
  ### 更新日志
215
215
 
216
+ **0.2.14** —— 修「重新 apply 时上一次注入的节点不清理 → 同一条消息行上堆出多个宿主」(互操作契约 I3 / §5)。只修 Bug,交互语义不变。
217
+
218
+ - **修复:重新 apply(HMR / 现场重载 / 插件开关 / bundle 组重载)会在同一行里再种一个编辑宿主,而不是复用上一次那个**。旧宿主的身份记在模块实例的 WeakMap 里,实例一换就认不出;于是每 apply 一次,那条操作条里就多一个「编辑这条消息」的笔。真机 CDP 探针实测同一 strip 里 `dshet-action-host` 多达 **7 个**、children 一度到 17(页面重载后回到 1、stripKids = 5),用户看到的是「一行上出现 3 个重跑按钮」。现在**每个注入节点都带命名空间属性**(宿主 `data-dshet-action-host="1"`、浮层根 `data-dshet-layer="1"`、编辑框 `data-dshet-editor="1"`、替代气泡 `data-dshet-revision="1"`),注入前先按属性在行内查一次、查到就**复用**——并且把它重新指到**当前**控制器(激活函数挂在按钮元素上、按下时才读,不再是上次那份捕获了旧闭包的监听;否则复用的笔点开的是已退休的控制器,表现就是「点了没反应」)。每趟 DOM pass 结束还会清掉「本次实例没有认领的」同命名空间宿主,正是那 7 个的直接来源;扫描只认自己的命名空间,兄弟插件的按钮永远不在候选里(I3)。
219
+ - **修复:卸载时不再把自己的注入节点留在页面上**。fiber dispose(`ctx.effect` 的清理)现在按属性选择器**全局**扫掉本插件的注入节点(宿主 / 替代气泡 / 编辑框 / 浮层根),并把**只属于自己**的隐藏交还给宿主行(`data-dshet-hidden` / `data-dshet-collapsed` / 折叠中途的内联样式),恢复可见前照旧先问归属(I4);React 自己渲染的节点(提示条、回答动作条里那支笔)**不扫**——从 React 手里抽走节点会让它在卸载时抛错。
220
+ - **English**: re-applying the bundle (HMR, a live reload, a plugin toggle) over the same page no longer plants a second edit-action host on the row - every injected node now carries a namespace attribute, an existing host is looked up in the row and reused (with its activation re-pointed at the controller that is mounted now, read off the button at press time), any host this apply instance did not claim is swept at the end of the pass, and disposing the fiber removes every node this plugin injected (hosts, revision bubbles, the editor and its layer root) while handing back only its own row/child hiding - React-owned nodes are deliberately left to the reconciler.
221
+ - 本版:`npm test` 126/126、`npm run verify:contract` 80/80、`npm run verify:client` 157/157 全绿(exit 0,`npm run check` 连跑两次同结果);新增 5 条 DOM 用例与 12 条静态检查;5 条新用例已用「把三处修复改回旧写法」验证必红——旧代码下那条 strip 里的宿主数正好是 **7**(与探针一致),卸载后宿主数仍是 1(等于不清理)。
222
+
223
+ **0.2.13** —— 隐藏归因(互操作契约 I4)+ 兄弟探测硬化(I5)。只修 Bug,交互语义不变。
224
+
225
+ - **修复:别人隐藏的行,本插件不再替它显示出来**。回退的「恢复可见」分支无条件把 `row.style.display` 清成 `''`——那一行若正被 **dsh-delete-turn**(`data-dshdt-hidden`)或 **dsh-rerun-turn**(`data-dsrr-hidden`)按归属属性隐藏着,本插件一恢复就把别人的隐藏一并抹掉(行"复活")。现在按契约 §4 在本地拷入 `foreignHideOn(row,'dshet')`:恢复前先确认没有别的归属属性,有则**保持 `display:none`**,只交还本插件自己那份隐藏。同理,折叠子节点时只有**本插件亲手写下**的 `display:none` 才打 `data-dshet-collapsed` 标记,恢复时也不会把别的插件留下的 `none` 重新显示出来。
226
+ - **修复:兄弟探测的过期答案不再覆盖新答案**(探测令牌,编辑器连开两次时不再闪一下又消失);`fetch` 抛错仍归入「缺席」且不打印任何日志(I5),探测结果为 `unknown` 时依旧不渲染重跑按钮。
227
+ - **English**: rows another plugin is keeping hidden are no longer un-hidden by this plugin's restore pass (`foreignHideOn(row, 'dshet')`, contract §4), a `display:none` this plugin did not write is no longer claimed by the collapse marker, and a sibling-probe answer older than the newest probe can no longer take the re-run button back out; a failed probe stays silent and absent.
228
+ - 本版:`npm test` 121/121、`npm run verify:contract` 80/80、`npm run verify:client` 145/145 全绿(exit 0);新增 7 条 DOM 用例与 5 条静态检查,三条新用例已用「改回旧代码」验证必红。
229
+
216
230
  **0.2.12** —— 编辑器里的「重跑」(转发给 dsh-rerun-turn),并删除旧的 `rerun` 配置。
217
231
 
218
232
  - **新增:「重跑」按钮(提示词编辑专用)**。装了姊妹插件 **dsh-rerun-turn** 时,取消/保存 旁多一个「重跑」:先走本插件的保存(就地替换、不花模型调用),紧接着调用它的 `POST /dsh-rerun-turn/apply { sessionId, seq: 该轮作答 }`——它按表面现在显示的提示词(你改后的文本)重新生成,并把后续轮次逐事件重放回来。探测方式与它的加载器探针一致(不带 sessionId 请求它的 `/state`:400=装着、404=没装),**没装就没有这个按钮**(例如桌面版没装时)。回答编辑不加这个按钮——回答动作条里本来就有它自己的 ↻。**它的 apply 不接受改写文本,所以顺序永远是先保存、后重跑**;该轮没有可重跑的作答、或它拒绝(`not-rerunnable`/`already-retired`/`busy`/`rerunning`…)时,按错误码提示,不影响已经落地的保存。
package/lib/client.js CHANGED
@@ -34,7 +34,7 @@ window.__ModuleLoader__.load({
34
34
  const RERUN_PREFIX = '/dsh-rerun-turn'
35
35
 
36
36
  /** Keep in sync with package.json and lib/index.js. */
37
- const PLUGIN_VERSION = '0.2.12'
37
+ const PLUGIN_VERSION = '0.2.14'
38
38
 
39
39
  // --- copy -----------------------------------------------------------------
40
40
 
@@ -286,6 +286,9 @@ window.__ModuleLoader__.load({
286
286
  this.clearTimer = null
287
287
  this.pendingRerun = false
288
288
  this.siblingRerun = 'unknown'
289
+ // Bumped on every probe: an answer older than the newest probe is stale
290
+ // and must not move the button.
291
+ this.probeToken = 0
289
292
  this.confirming = false
290
293
  this.noticeTimer = null
291
294
  this.view = Object.freeze({
@@ -432,13 +435,20 @@ window.__ModuleLoader__.load({
432
435
  // does not exist. A change is published - the editor's marker carries it,
433
436
  // so the button appears (or never does).
434
437
  async probeSiblingRerun() {
438
+ const token = (this.probeToken += 1)
435
439
  let status = 0
436
440
  try {
437
441
  const response = await fetch(`${RERUN_PREFIX}/state`, { headers: { accept: 'application/json' } })
438
442
  status = response.status
439
443
  } catch {
444
+ // A sibling that is not mounted is not an error (I5): a rejected
445
+ // fetch, a timeout and a 404 all land in 'absent', and none of them
446
+ // logs anything.
440
447
  status = 0
441
448
  }
449
+ // Two opens before the first answer arrived: the slower probe used to
450
+ // land second and take the button back out. Only the newest speaks.
451
+ if (token !== this.probeToken) return
442
452
  const next = status === 400 || status === 405 ? 'present' : 'absent'
443
453
  this.siblingRerun = next
444
454
  if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
@@ -736,6 +746,132 @@ window.__ModuleLoader__.load({
736
746
 
737
747
  const rowActions = new WeakMap()
738
748
 
749
+ // --- injected-node namespace ----------------------------------------------
750
+ //
751
+ // The host reloads this bundle in place: a new module instance with fresh
752
+ // closures, a fresh WeakMap and a fresh editor layer, over the DOM the
753
+ // previous instance left behind. Nothing in an instance can recognise its
754
+ // predecessor's nodes by identity, so every node this half injects carries
755
+ // its namespace in an attribute as well as in its class (contract I3): the
756
+ // attribute is what a reload looks for (reuse it, never plant a second) and
757
+ // what a real unload sweeps away. The class forms stay as fallbacks - a
758
+ // bundle older than this one injected the same nodes with the class alone.
759
+ const HOST_ATTR = 'dshetActionHost'
760
+ const LAYER_ATTR = 'dshetLayer'
761
+ const EDITOR_ATTR = 'dshetEditor'
762
+ const HOST_SELECTOR = '[data-dshet-action-host="1"],.dshet-action-host'
763
+ const LAYER_SELECTOR = '[data-dshet-layer="1"],.dshet-layer'
764
+ // The live activation of an injected action, kept on the button element
765
+ // itself: see `wireAction`.
766
+ const ACTIVATE = 'dshetActivate'
767
+ // Everything this half creates itself. The React-owned nodes are NOT in here
768
+ // on purpose (the notice banner, the reply entry in the assistant-actions
769
+ // strip): the host unmounts those through its own reconciler, and pulling a
770
+ // node out from under React makes it throw on the way out.
771
+ const OWN_NODE_SELECTOR = [
772
+ HOST_SELECTOR,
773
+ LAYER_SELECTOR,
774
+ '[data-dshet-editor="1"],.dshet-editor',
775
+ '[data-dshet-revision="1"],.dshet-revision',
776
+ ].join(',')
777
+
778
+ // The action hosts this module instance stands behind. A host carrying the
779
+ // namespace that no pass of this instance claimed cannot be positioned or
780
+ // updated by anyone: it is a leftover of an apply that is gone, and
781
+ // `dropUnclaimedHosts` takes it out.
782
+ const liveHosts = new WeakSet()
783
+
784
+ /** Does this node still hang in a tree? A parentless host is one nobody can see. */
785
+ function hasParent(node) {
786
+ return node !== null && node !== undefined && node.parentElement !== null && node.parentElement !== undefined
787
+ }
788
+
789
+ /** Is this one of our action hosts - by namespace attribute, or by class? */
790
+ function isActionHost(node) {
791
+ if (node === null || node === undefined) return false
792
+ const className = typeof node.className === 'string' ? node.className : ''
793
+ if (/(^|\s)dshet-action-host(\s|$)/.test(className)) return true
794
+ if (typeof node.matches === 'function') {
795
+ try {
796
+ return node.matches(HOST_SELECTOR) === true
797
+ } catch {
798
+ return false
799
+ }
800
+ }
801
+ return false
802
+ }
803
+
804
+ /** Every action host of ours inside `scope`, the scope itself included. */
805
+ function actionHostsIn(scope) {
806
+ const out = []
807
+ if (scope === null || scope === undefined) return out
808
+ if (isActionHost(scope)) out.push(scope)
809
+ if (typeof scope.querySelectorAll === 'function') {
810
+ for (const node of scope.querySelectorAll(HOST_SELECTOR)) {
811
+ if (!out.includes(node)) out.push(node)
812
+ }
813
+ }
814
+ return out
815
+ }
816
+
817
+ /** The button inside an injected host, wherever a previous apply left it. */
818
+ function actionButtonIn(host) {
819
+ for (const child of host.children) {
820
+ const className = typeof child.className === 'string' ? child.className : ''
821
+ if (child.tagName === 'BUTTON' || /(^|\s)dshet-action(\s|$)/.test(className)) return child
822
+ }
823
+ return null
824
+ }
825
+
826
+ function createActionHost() {
827
+ const host = document.createElement('span')
828
+ host.className = 'dshet-action-host'
829
+ host.dataset[HOST_ATTR] = '1'
830
+ return host
831
+ }
832
+
833
+ function createActionButton() {
834
+ const button = document.createElement('button')
835
+ button.type = 'button'
836
+ button.className = 'dshet-action dshet-row-action'
837
+ button.innerHTML = ICON_MARKUP
838
+ return button
839
+ }
840
+
841
+ /**
842
+ * Wire the press/click pair onto an injected action.
843
+ *
844
+ * The action itself is NOT captured here: it is read off the button at press
845
+ * time (`ACTIVATE`). A reloaded bundle reuses the very node the retired one
846
+ * injected - that is what the namespace is for - and it wires that node
847
+ * again, so the handler has to reach the controller that is mounted NOW.
848
+ * A captured closure would open the retired controller, which does nothing
849
+ * at all and reads exactly like a dead pencil.
850
+ */
851
+ function wireAction(button) {
852
+ pressable(button, () => {
853
+ const activate = button[ACTIVATE]
854
+ if (typeof activate === 'function') activate()
855
+ })
856
+ }
857
+
858
+ /** The pencil host a previous apply injected into this row, if there is one (I3). */
859
+ function adoptActionHost(row) {
860
+ const hosts = actionHostsIn(row)
861
+ if (hosts.length === 0) return null
862
+ // The first one wins: a second host in the same row is a ghost of an
863
+ // earlier apply, and the sweep at the end of this pass removes it.
864
+ const host = hosts[0]
865
+ if (host.dataset !== undefined && host.dataset[HOST_ATTR] !== '1') host.dataset[HOST_ATTR] = '1'
866
+ let button = actionButtonIn(host)
867
+ if (button === null) {
868
+ button = createActionButton()
869
+ host.appendChild(button)
870
+ }
871
+ wireAction(button)
872
+ return { host, button, target: null }
873
+ }
874
+
739
875
  // Show a rewritten prompt where its original row is.
740
876
  //
741
877
  // The bubble is cloned from a real user bubble's classes at runtime - the
@@ -779,7 +915,6 @@ window.__ModuleLoader__.load({
779
915
  return node.kind !== 'user'
780
916
  }
781
917
  const bubble = existing === null ? cloneUserBubble(row) : existing
782
- if (existing === null) bubble.dataset.dshetRevision = '1'
783
918
  {
784
919
  const key = row.getAttribute('data-chat-flow-key')
785
920
  if (typeof key === 'string' && key !== '' && bubble.dataset[REVISION_FOR] !== key) {
@@ -823,6 +958,10 @@ window.__ModuleLoader__.load({
823
958
  function cloneUserBubble(row) {
824
959
  const bubble = document.createElement('div')
825
960
  bubble.className = 'dshet-revision'
961
+ // Namespace attribute as well as the class: a reloaded bundle finds this
962
+ // bubble by attribute (it has no memory of the node), and an unload sweeps
963
+ // it by the same selector.
964
+ bubble.dataset.dshetRevision = '1'
826
965
  if (row !== null && row !== undefined) {
827
966
  const key = row.getAttribute('data-chat-flow-key')
828
967
  if (typeof key === 'string' && key !== '') bubble.dataset[REVISION_FOR] = key
@@ -938,8 +1077,13 @@ window.__ModuleLoader__.load({
938
1077
  if (hide) {
939
1078
  // Written only when the value changes: this runs on every DOM pass,
940
1079
  // and an attribute write the observer can see would never settle.
941
- if (child.style.display !== 'none') child.style.display = 'none'
942
- if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
1080
+ // The marker is written only for a `none` this plugin wrote (I4):
1081
+ // a child another plugin had already displayed away is left
1082
+ // untouched, so the restore pass cannot take that hiding back out.
1083
+ if (child.style.display !== 'none') {
1084
+ child.style.display = 'none'
1085
+ if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
1086
+ }
943
1087
  } else if (child.dataset.dshetCollapsed === '1') {
944
1088
  child.style.display = ''
945
1089
  delete child.dataset.dshetCollapsed
@@ -950,6 +1094,25 @@ window.__ModuleLoader__.load({
950
1094
  return true
951
1095
  }
952
1096
 
1097
+ // Attribution for the hiding below (contract §4). Another plugin writes its
1098
+ // own marker on a row it displayed away (delete-turn 'dshdt', rerun-turn
1099
+ // 'dsrr'); this half resets `display` for its OWN hide only, and a row
1100
+ // another plugin is keeping away stays away.
1101
+ const HIDE_OWNERS = [
1102
+ ['dshdt', 'data-dshdt-hidden', 'dshdtHidden'],
1103
+ ['dshet', 'data-dshet-hidden', 'dshetHidden'],
1104
+ ['dsrr', 'data-dsrr-hidden', 'dsrrHidden'],
1105
+ ]
1106
+ /** Is anyone but `own` claiming a hide on this row? */
1107
+ function foreignHideOn(row, own) {
1108
+ for (const [key, attr, prop] of HIDE_OWNERS) {
1109
+ if (key === own) continue
1110
+ if (row.dataset?.[prop] === '1') return true
1111
+ if (typeof row.hasAttribute === 'function' && row.hasAttribute(attr)) return true
1112
+ }
1113
+ return false
1114
+ }
1115
+
953
1116
  function setRowHidden(row, hide, animate) {
954
1117
  // A turn tail is not a message row. It is the strip a turn's end renders
955
1118
  // (duration, usage, actions); when the turn behind it is rolled back the
@@ -1000,7 +1163,10 @@ window.__ModuleLoader__.load({
1000
1163
  delete row.dataset.dshetHidden
1001
1164
  if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
1002
1165
  row.classList.remove('dshet-collapsing')
1003
- row.style.display = ''
1166
+ // I4: dropping our marker ends OUR hide. `display` is only ours to clear
1167
+ // while no other plugin's hide marker stands - a row a sibling displayed
1168
+ // away stays away, whatever this half would prefer.
1169
+ row.style.display = foreignHideOn(row, 'dshet') ? 'none' : ''
1004
1170
  row.style.height = ''
1005
1171
  row.style.opacity = ''
1006
1172
  row.style.marginTop = ''
@@ -1012,6 +1178,7 @@ window.__ModuleLoader__.load({
1012
1178
  function removeRowAction(row) {
1013
1179
  const entry = rowActions.get(row)
1014
1180
  if (!entry) return
1181
+ liveHosts.delete(entry.host)
1015
1182
  entry.host.remove()
1016
1183
  rowActions.delete(row)
1017
1184
  }
@@ -1019,28 +1186,47 @@ window.__ModuleLoader__.load({
1019
1186
  function injectRowAction(row, target, controller, t, options = {}) {
1020
1187
  const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
1021
1188
  let entry = rowActions.get(row)
1022
- if (!entry) {
1023
- const host = document.createElement('span')
1024
- host.className = 'dshet-action-host'
1025
- const button = document.createElement('button')
1026
- button.type = 'button'
1027
- button.className = 'dshet-action dshet-row-action'
1028
- button.innerHTML = ICON_MARKUP
1189
+ // An entry whose host lost its parent is not an entry any more: another
1190
+ // apply swept the node away (or the host took it out with the container it
1191
+ // was in), and a pass that only repositions it would leave this row with
1192
+ // no pencil at all.
1193
+ if (entry !== undefined && !hasParent(entry.host)) {
1194
+ rowActions.delete(row)
1195
+ entry = undefined
1196
+ }
1197
+ // I3: a host a previous apply injected into this row is REUSED, never
1198
+ // doubled. The WeakMap cannot find it - a reloaded bundle starts with an
1199
+ // empty one - so the namespace attribute is what does.
1200
+ if (entry === undefined) {
1201
+ const adopted = adoptActionHost(row)
1202
+ if (adopted !== null) entry = adopted
1203
+ }
1204
+ if (entry === undefined) {
1205
+ const host = createActionHost()
1206
+ const button = createActionButton()
1029
1207
  // The marker goes on the BUTTON, not only on its wrapper: the pass that
1030
1208
  // looks for the official strip's entry queries buttons, so a marker one
1031
1209
  // level up made this pencil look like the strip's own and it deleted
1032
1210
  // itself - then the next pass put it back, which reads as flicker.
1033
- if (options.fallback === true) button.dataset.dshetFallback = '1'
1034
1211
  host.appendChild(button)
1035
1212
  entry = { host, button, target }
1036
- // Wired once, and the action reads the CURRENT target off the entry: the
1037
- // pass runs again on every snapshot, and re-wiring would reset the
1038
- // press guard between a pointerdown and the click it swallows.
1039
- pressable(button, () => controller.open(entry.target))
1213
+ // The action reads the CURRENT target off the entry and the CURRENT
1214
+ // controller off the button, so re-wiring on a later apply is harmless
1215
+ // and a reloaded bundle can take this very node over.
1216
+ wireAction(button)
1040
1217
  rowActions.set(row, entry)
1041
1218
  }
1042
1219
  entry.target = target
1043
1220
  const { host, button } = entry
1221
+ // The fallback marker mirrors the placement chosen below: a host adopted
1222
+ // from a gutter placement must not keep claiming one it no longer has.
1223
+ if (options.fallback === true) {
1224
+ if (button.dataset.dshetFallback !== '1') button.dataset.dshetFallback = '1'
1225
+ } else if (button.dataset !== undefined && button.dataset.dshetFallback !== undefined) {
1226
+ delete button.dataset.dshetFallback
1227
+ }
1228
+ button[ACTIVATE] = () => controller.open(entry.target)
1229
+ liveHosts.add(host)
1044
1230
  if (button.getAttribute('aria-label') !== label) {
1045
1231
  button.setAttribute('aria-label', label)
1046
1232
  button.setAttribute('title', label)
@@ -1078,6 +1264,37 @@ window.__ModuleLoader__.load({
1078
1264
  }
1079
1265
  }
1080
1266
 
1267
+ /**
1268
+ * One pencil per row, however often the bundle is applied over the same DOM.
1269
+ *
1270
+ * Every pass registers the host it stands behind in `liveHosts`. A host that
1271
+ * carries this plugin's namespace and was claimed by NO pass of this
1272
+ * instance cannot be positioned or updated by anyone: it is the leftover of
1273
+ * an apply that is gone (a reload, a plugin toggle, a bundle-group swap),
1274
+ * and left alone the bar collects one more of them per apply - the observed
1275
+ * report was seven action hosts and seventeen children in a single strip,
1276
+ * which the user reads as three re-run buttons on one row. Only nodes
1277
+ * carrying this plugin's own namespace are ever considered (I3).
1278
+ *
1279
+ * The DOM pass runs on every snapshot, so a ghost is taken out on the first
1280
+ * pass after the reload that produced it.
1281
+ */
1282
+ function dropUnclaimedHosts() {
1283
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
1284
+ let hosts = []
1285
+ try {
1286
+ hosts = document.querySelectorAll(HOST_SELECTOR)
1287
+ } catch {
1288
+ return
1289
+ }
1290
+ for (const host of hosts) {
1291
+ if (liveHosts.has(host)) continue
1292
+ try {
1293
+ host.remove()
1294
+ } catch {}
1295
+ }
1296
+ }
1297
+
1081
1298
  // The platform's own action icons all carry a hashed class ending in
1082
1299
  // `_action`; the clock, the usage pill and anything this plugin adds do not
1083
1300
  // (ours are hyphenated). Matching that suffix avoids CSS-module hashes
@@ -1109,12 +1326,32 @@ window.__ModuleLoader__.load({
1109
1326
  let editorLayer = null
1110
1327
  function ensureEditorLayer() {
1111
1328
  if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
1329
+ // A reloaded bundle starts with no layer of its own; the one the previous
1330
+ // instance left is the same node doing the same job, so it is adopted
1331
+ // rather than doubled (I3). The instance that left it takes it out with
1332
+ // its own sweep when it is disposed.
1333
+ const existing = ownLayer()
1334
+ if (existing !== null && existing.isConnected !== false) {
1335
+ editorLayer = existing
1336
+ return editorLayer
1337
+ }
1112
1338
  editorLayer = document.createElement('div')
1113
1339
  editorLayer.className = 'dshet-layer'
1340
+ editorLayer.dataset[LAYER_ATTR] = '1'
1114
1341
  document.body.appendChild(editorLayer)
1115
1342
  return editorLayer
1116
1343
  }
1117
1344
 
1345
+ /** The editor layer a previous apply left on the page, if there is one. */
1346
+ function ownLayer() {
1347
+ if (typeof document === 'undefined' || typeof document.querySelector !== 'function') return null
1348
+ try {
1349
+ return document.querySelector(LAYER_SELECTOR)
1350
+ } catch {
1351
+ return null
1352
+ }
1353
+ }
1354
+
1118
1355
  // Put the box under the row it belongs to: same width, just below its lower
1119
1356
  // edge. Called on render and whenever the page scrolls or resizes, since
1120
1357
  // the layer is fixed to the viewport.
@@ -1233,6 +1470,7 @@ window.__ModuleLoader__.load({
1233
1470
  clearEditor()
1234
1471
  const box = document.createElement('div')
1235
1472
  box.className = 'dshet-editor'
1473
+ box.dataset[EDITOR_ATTR] = '1'
1236
1474
  box.dataset.dshetFor = marker
1237
1475
 
1238
1476
  const area = document.createElement('textarea')
@@ -1442,6 +1680,10 @@ window.__ModuleLoader__.load({
1442
1680
  controller.editorBox = null
1443
1681
  controller.editorRow = null
1444
1682
  }
1683
+ // Whatever of ours no row of this pass claimed is a ghost of an earlier
1684
+ // apply: the host rebuilt the row under it, or the bundle was reloaded
1685
+ // over it. Contract §5 - a button nobody can update must not pile up.
1686
+ dropUnclaimedHosts()
1445
1687
  }
1446
1688
 
1447
1689
  // A snapshot this entry can read, or null when the controller cannot be
@@ -1617,6 +1859,65 @@ window.__ModuleLoader__.load({
1617
1859
  })
1618
1860
  }
1619
1861
 
1862
+ // --- unload -----------------------------------------------------------------
1863
+
1864
+ /**
1865
+ * Take back everything this half put on the page.
1866
+ *
1867
+ * Runs when the plugin is disposed - a reload, a toggle, a bundle-group swap
1868
+ * - and it works by NAMESPACE, never by identity: the WeakMap and the layer
1869
+ * reference die with the module instance, the attributes on the nodes do
1870
+ * not, and the instance that replaces this one starts with no memory of them
1871
+ * at all. That is exactly why a reload used to leave a second pencil (and a
1872
+ * third, and a fourth) in the same bar.
1873
+ *
1874
+ * Only nodes this plugin created are removed, and only state this plugin
1875
+ * wrote is taken back (I3, I4): a row another plugin is keeping away stays
1876
+ * away, and the host's own buttons never move.
1877
+ */
1878
+ function releaseInjectedNodes() {
1879
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
1880
+ const each = (selector, act) => {
1881
+ let nodes = []
1882
+ try {
1883
+ nodes = document.querySelectorAll(selector)
1884
+ } catch {
1885
+ return
1886
+ }
1887
+ for (const node of nodes) {
1888
+ try {
1889
+ act(node)
1890
+ } catch {}
1891
+ }
1892
+ }
1893
+ // 1. The platform's own rows go back to how this half found them. These
1894
+ // markers are this plugin's own writes, so this is its own state - and
1895
+ // only its own: the restore path asks the attribution guard first (I4).
1896
+ each('[data-dshet-hidden="1"]', (row) => setRowHidden(row, false))
1897
+ each('[data-dshet-collapsed="1"]', (child) => {
1898
+ child.style.display = ''
1899
+ delete child.dataset.dshetCollapsed
1900
+ })
1901
+ // A row caught mid-collapse keeps the inline height and opacity this half
1902
+ // wrote, and nothing after this would ever clear them.
1903
+ each('.dshet-collapsing', (row) => {
1904
+ row.classList.remove('dshet-collapsing')
1905
+ row.style.height = ''
1906
+ row.style.opacity = ''
1907
+ row.style.marginTop = ''
1908
+ row.style.marginBottom = ''
1909
+ row.style.paddingTop = ''
1910
+ row.style.paddingBottom = ''
1911
+ })
1912
+ // 2. Every node this half created goes with it: the injected pencils, the
1913
+ // bubbles standing in for rewritten prompts, the editor and the layer
1914
+ // it is hosted on. React's own nodes are deliberately not in the
1915
+ // selector - the host unmounts those, and pulling one out from under
1916
+ // React makes it throw on the way out.
1917
+ each(OWN_NODE_SELECTOR, (node) => node.remove())
1918
+ editorLayer = null
1919
+ }
1920
+
1620
1921
  // --- plugin ---------------------------------------------------------------
1621
1922
 
1622
1923
  function apply(ctx) {
@@ -1635,6 +1936,11 @@ window.__ModuleLoader__.load({
1635
1936
  () => () => {
1636
1937
  for (const controller of controllers.values()) controller.dispose()
1637
1938
  controllers.clear()
1939
+ // The DOM this half injected is not owned by the controllers: it
1940
+ // outlives them, and the next apply has no way to find it except by
1941
+ // namespace. An unload that does not take it out is what left one more
1942
+ // pencil in the bar on every reload (I3, contract §5).
1943
+ releaseInjectedNodes()
1638
1944
  },
1639
1945
  'dsh-edit-turn: per-session controllers',
1640
1946
  )
package/lib/index.js CHANGED
@@ -57,7 +57,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
57
57
  export const name = PLUGIN_ID
58
58
 
59
59
  /** Keep in sync with package.json and lib/client.js. */
60
- export const PLUGIN_VERSION = '0.2.12'
60
+ export const PLUGIN_VERSION = '0.2.14'
61
61
 
62
62
  // A ring of the last few requests, answering "did the browser half even ask, and
63
63
  // what did it get?" without needing its console. Read-only diagnostics: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",