dsh-edit-turn 0.2.12 → 0.2.15
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 +20 -0
- package/lib/client.js +323 -17
- package/lib/index.js +70 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,26 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
213
213
|
|
|
214
214
|
### 更新日志
|
|
215
215
|
|
|
216
|
+
**0.2.15** —— 修一个自己埋的隐患:回复编辑开的合成轮次会让下一次提问撞号。
|
|
217
|
+
|
|
218
|
+
- **发现**:回复编辑必须在日志里开一个轮次(读路径只在"打开的轮次+步骤"里认 `assistant/message`,修正消息就是 assistant),但 **agent 循环的轮号计数器在 `phase.lastTurn`,是循环构造时播种、只由它自己开的轮推进的**——它看不见我们追加的 `turn/start`。于是下一次提问在同一个会话里会开出**同一个轮号**,两条 `turn/start` 撞号,整份日志在下次加载时报 `turn/start does not open the expected turn`,这正是我们 0.2.4 修掉的那类事故的另一个入口。兄弟插件 dsh-rerun-turn 早踩过并解决了同一问题(其 0.1.1:改用不占轮次的载体 + 事后 `syncLoopTurn`);我们照做:回复编辑闭合自己的轮次后,**把活循环的 `lastTurn` 推到该轮号**。
|
|
219
|
+
- **形状与降级**:`syncLoopTurn(agent, maxTurn)` 只看 `phase.kind === 'idle'` 且 `phase.lastTurn` 为数字时才动,否则 `'unavailable'` 什么也不碰(冷会话、循环不在场都走这条),并把结果以 `loopTurn` 写进 apply 响应、把 `unavailable` 记进 `/dsh-edit-turn/debug` 的请求环(字段刻意不叫 `code:`——静态检查会把它当宿主错误码去要文案)。
|
|
220
|
+
- 新增/改写 host 测试 2 个:命中的循环被推过消耗掉的轮号(连续两次编辑,计数器只向前),够不着的循环原样留着并留诊断;加单测 1 个(守卫:running / 缺字段 / null 都返回 unavailable)。129 单测 / 80 契约 / 静态 exit0 / verify:live / verify:ui 全绿。
|
|
221
|
+
|
|
222
|
+
**0.2.14** —— 修「重新 apply 时上一次注入的节点不清理 → 同一条消息行上堆出多个宿主」(互操作契约 I3 / §5)。只修 Bug,交互语义不变。
|
|
223
|
+
|
|
224
|
+
- **修复:重新 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)。
|
|
225
|
+
- **修复:卸载时不再把自己的注入节点留在页面上**。fiber dispose(`ctx.effect` 的清理)现在按属性选择器**全局**扫掉本插件的注入节点(宿主 / 替代气泡 / 编辑框 / 浮层根),并把**只属于自己**的隐藏交还给宿主行(`data-dshet-hidden` / `data-dshet-collapsed` / 折叠中途的内联样式),恢复可见前照旧先问归属(I4);React 自己渲染的节点(提示条、回答动作条里那支笔)**不扫**——从 React 手里抽走节点会让它在卸载时抛错。
|
|
226
|
+
- **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.
|
|
227
|
+
- 本版:`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(等于不清理)。
|
|
228
|
+
|
|
229
|
+
**0.2.13** —— 隐藏归因(互操作契约 I4)+ 兄弟探测硬化(I5)。只修 Bug,交互语义不变。
|
|
230
|
+
|
|
231
|
+
- **修复:别人隐藏的行,本插件不再替它显示出来**。回退的「恢复可见」分支无条件把 `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` 重新显示出来。
|
|
232
|
+
- **修复:兄弟探测的过期答案不再覆盖新答案**(探测令牌,编辑器连开两次时不再闪一下又消失);`fetch` 抛错仍归入「缺席」且不打印任何日志(I5),探测结果为 `unknown` 时依旧不渲染重跑按钮。
|
|
233
|
+
- **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.
|
|
234
|
+
- 本版:`npm test` 121/121、`npm run verify:contract` 80/80、`npm run verify:client` 145/145 全绿(exit 0);新增 7 条 DOM 用例与 5 条静态检查,三条新用例已用「改回旧代码」验证必红。
|
|
235
|
+
|
|
216
236
|
**0.2.12** —— 编辑器里的「重跑」(转发给 dsh-rerun-turn),并删除旧的 `rerun` 配置。
|
|
217
237
|
|
|
218
238
|
- **新增:「重跑」按钮(提示词编辑专用)**。装了姊妹插件 **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.
|
|
37
|
+
const PLUGIN_VERSION = '0.2.15'
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
|
|
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.
|
|
60
|
+
export const PLUGIN_VERSION = '0.2.15'
|
|
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
|
|
@@ -574,6 +574,23 @@ function findLiveSession(ctx, sessionId) {
|
|
|
574
574
|
// Resolve the live Session that owns the append. An already-open session is
|
|
575
575
|
// used directly; a cold one is resumed through the official controller, which
|
|
576
576
|
// is exactly what the web UI does when the user opens it.
|
|
577
|
+
/**
|
|
578
|
+
* The live agent whose loop owns this session's turn counter, when one exists.
|
|
579
|
+
* @param ctx - plugin context.
|
|
580
|
+
* @param sessionId - session to resolve.
|
|
581
|
+
* @returns the agent, or undefined (cold session, or no controller mounted).
|
|
582
|
+
*/
|
|
583
|
+
async function resolveLoopAgent(ctx, sessionId) {
|
|
584
|
+
const controller = ctx.get('sessionController')
|
|
585
|
+
if (!controller || typeof controller.resolveAgent !== 'function') return undefined
|
|
586
|
+
try {
|
|
587
|
+
const result = await controller.resolveAgent(sessionId)
|
|
588
|
+
return result && result.agent ? result.agent : undefined
|
|
589
|
+
} catch {
|
|
590
|
+
return undefined
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
577
594
|
async function resolveSession(ctx, sessionId) {
|
|
578
595
|
const live = findLiveSession(ctx, sessionId)
|
|
579
596
|
if (live) return live
|
|
@@ -589,6 +606,44 @@ async function resolveSession(ctx, sessionId) {
|
|
|
589
606
|
return undefined
|
|
590
607
|
}
|
|
591
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Make the live agent loop agree with a turn this plugin opened in the log.
|
|
611
|
+
*
|
|
612
|
+
* The loop keeps its own counter in an idle-phase field (`phase.lastTurn`): it
|
|
613
|
+
* is seeded when the loop is constructed from the projection and advanced only
|
|
614
|
+
* by turns the loop itself opens. A reply edit has to open a turn - the read
|
|
615
|
+
* path admits an `assistant/message` only inside an open turn and step - so
|
|
616
|
+
* afterwards that field still names the turn this plugin consumed, and the
|
|
617
|
+
* loop's next run would open the same number. That collides: both turn/start
|
|
618
|
+
* events claim one number and the log fails its next load
|
|
619
|
+
* (`turn/start does not open the expected turn`), which is the corruption this
|
|
620
|
+
* plugin already shipped once and repaired.
|
|
621
|
+
*
|
|
622
|
+
* The shape guard is deliberate and the failure is soft: if the loop is not
|
|
623
|
+
* idle, or its shape is not what this expects, nothing is touched and the
|
|
624
|
+
* caller records why. The sibling plugin dsh-rerun-turn solves the same
|
|
625
|
+
* problem the same way (`syncLoopTurn`); there is no official seam for it.
|
|
626
|
+
*
|
|
627
|
+
* @param agent - the live loop agent, when one exists.
|
|
628
|
+
* @param maxTurn - the highest turn now in the log.
|
|
629
|
+
* @returns `'synced' | 'already-current' | 'unavailable'`.
|
|
630
|
+
*/
|
|
631
|
+
export function syncLoopTurn(agent, maxTurn) {
|
|
632
|
+
try {
|
|
633
|
+
const phase = agent && agent.phase
|
|
634
|
+
if (!phase || typeof phase !== 'object' || phase.kind !== 'idle' || typeof phase.lastTurn !== 'number') {
|
|
635
|
+
return 'unavailable'
|
|
636
|
+
}
|
|
637
|
+
if (maxTurn > phase.lastTurn) {
|
|
638
|
+
phase.lastTurn = maxTurn
|
|
639
|
+
return 'synced'
|
|
640
|
+
}
|
|
641
|
+
return 'already-current'
|
|
642
|
+
} catch {
|
|
643
|
+
return 'unavailable'
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
592
647
|
function eventsFromLive(session) {
|
|
593
648
|
if (session && typeof session.snapshotEvents === 'function') {
|
|
594
649
|
try {
|
|
@@ -885,11 +940,25 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
885
940
|
}
|
|
886
941
|
}
|
|
887
942
|
const corrected = await flushSession(ctx, session)
|
|
943
|
+
// The synthetic turn this edit opened is invisible to the live loop's own
|
|
944
|
+
// counter; without the sync its next run reuses that number and the log
|
|
945
|
+
// fails its next load. Only a reply edit opens a turn - a prompt edit does
|
|
946
|
+
// not, and needs nothing.
|
|
947
|
+
const loopAgent = await resolveLoopAgent(ctx, sessionId)
|
|
948
|
+
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
949
|
+
// `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
|
|
950
|
+
// not called `code` - the static checks read `code:` as host error codes and
|
|
951
|
+
// must not start demanding a user-visible message for an internal sync state.
|
|
952
|
+
const syncNote = loopTurn
|
|
953
|
+
if (syncNote === 'unavailable') {
|
|
954
|
+
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
|
|
955
|
+
}
|
|
888
956
|
return {
|
|
889
957
|
kind: 'reply',
|
|
890
958
|
replacementSeq: replacement.seq,
|
|
891
959
|
appendedSeq: appended.seq,
|
|
892
960
|
applied: true,
|
|
961
|
+
loopTurn,
|
|
893
962
|
shadowed: plan.shadowed,
|
|
894
963
|
...corrected,
|
|
895
964
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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",
|