dsh-edit-turn 0.2.16 → 0.2.18

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
@@ -56,6 +56,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
56
56
  编辑器是**平台输入框那样的一个圆角盒子**:输入区在上、取消与主按钮在右下,聚焦时整圈描边高亮;**随文字长高**(空的时候不留空白),没有标题栏、没有常驻说明、没有拖拽手柄。键盘也和输入框一致:**Enter 保存、Shift+Enter 换行、Esc 取消**(确认步里 Esc 是退回上一步)。**输入法优先**:组合中的 Enter 与"候选选词的 Enter"(`isComposing` 或 `keyCode 229`,平台自己的输入框也是这两条一起挡)都不会被当成保存,组合期间也不重排输入框——此前少挡了 229 那半,拼音选词按回车会直接保存/关闭,看起来就是"打不了中文"。
57
57
 
58
58
  编辑器里如果提示「这条消息包含图片或文件附件」,说明改写只保留文字,附件会被丢弃。
59
+ **一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器关闭并提示「内容没有变化,未做任何修改」。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
59
60
 
60
61
  **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存」——这条回答被替换为你写的内容,它之后的内容一并移除,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
61
62
 
@@ -96,6 +97,8 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
96
97
 
97
98
  **重跑不由本插件做。** "改完立刻重答"是姊妹插件 `dsh-rerun-turn` 的操作:它遮蔽该轮、用表面现在显示的提示词(也就是你改后的文本)重新生成,再把后续轮次逐事件重放回来。本插件只负责把编辑落地;编辑器里的「重跑」按钮仅在检测到它挂载时出现,点击后先保存、再调用它的 `POST /dsh-rerun-turn/apply`。它不在(例如桌面版没装)时,没有这个按钮。
98
99
 
100
+ **文本没变就不写(0.2.18)。** 决定「写什么」的那一层在落任何事件之前,把请求里的文本与**当前活节点的文本**做**逐字**比较(`readMessageText`——`/state` 的 `turns[].text` / `replies[].text` 用的就是它,所以编辑器预填的文本与这里的比较对象天然同源)。相同则直接返回 `{ applied:false, unchanged:true, shadowed:[] }`:不落替换、不 flush、不开轮次、不动活循环的轮次计数器。比较**不 trim**:只有空白差异(哪怕多一个尾随空格)也算真修改,走原来的写入路径,行为与本版之前完全一致。`unchanged` 是给调用方的**标志**而不是拒绝——路由照常 200 返回,编辑器「重跑」的链式调用照常发起;所以这道守卫只拦得住「写」,拦不住用户要的重跑。
101
+
99
102
  **为什么不做文件截断?** 因为那不是官方能力。`dsh-session-persistence-jsonl` 只暴露残帧崩溃修复用的 `truncateTornTail`,正常路径下「Committed events are never rewritten」;运行中的宿主把会话放在内存 append-only 日志 + 投影缓存里,改磁盘不会让它重新读取。截断还会破坏 `sourceEventSeqs` 的 `[start,end]` 压缩表示与多帧 zstd 结构。surface-replace 是官方为这件事准备的机制。
100
103
 
101
104
  **插件接口**
@@ -103,7 +106,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
103
106
  | 类型 | 名称 | 说明 |
104
107
  |---|---|---|
105
108
  | 路由 | `GET /dsh-edit-turn/state?sessionId=` | 可编辑轮次、已遮蔽行账本(`hidden` + `revisions`)、surface、忙碌状态 |
106
- | 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → 写入改后内容;响应含 `applied` / `shadowed` / `replacementSeq` |
109
+ | 路由 | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text }` → 写入改后内容;响应含 `applied` / `shadowed` / `replacementSeq`;改后文本与当前活文本一字不差时什么都不写,响应改为 `applied:false` + `unchanged:true` + `shadowed:[]`(`original` 是它比较的那份文本)。这是一个**标志,不是拒绝**:调用方自己决定下一步(编辑器的「重跑」正是靠它继续发起重跑) |
107
110
  | 工具 | `edit_turn_targets` | 只读:列出该会话当前可编辑的轮次与原文(供 agent 自查) |
108
111
  | 前端 | `conversation.input.overlay` | 每会话控制器:编辑入口、就地编辑器、被遮蔽行的隐藏账本 |
109
112
 
@@ -131,7 +134,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
131
134
  | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
132
135
  | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
133
136
  | 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 80 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
134
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 105 项通过 |
137
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 147 项通过(含「文本没变 → 不写」的四条:改提问、改回答(不新增轮次)、真改了仍照旧写入、走「重跑」时不写但重跑仍发起) |
135
138
  | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
136
139
  | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
137
140
  | **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
@@ -169,11 +172,13 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
169
172
  - 编辑一条用户消息**只替换那一条**,**保存永不调用模型**;编辑一条模型回答只替换内容,同样不问模型。想立刻重答,用 `dsh-rerun-turn` 的「重跑」。
170
173
  - **提示词编辑只动那一条**:它下面的回复与之后的对话**原样保留**。**回复编辑**才会移除该回答及其后的内容——回答变了,建立在它之上的一切都不再成立。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
171
174
  - **只改写文本**。消息里含图片/文件附件时,改写后只保留文字(编辑器会提示)。
175
+ - **文本一字不差时保存不写任何记录**(0.2.18):编辑器预填的就是这条消息**现在**的文本,原样保存不会写入任何事件(不落替换、不开轮次),编辑器关闭并提示「内容没有变化」,日志与轨迹都保持原样;**「重跑」例外,它照常发起**。副作用:一条带**工具调用或思考过程**的模型回答,如果你一字不改地保存(本意只是想借替换把它们丢掉),本版不会再替换它,那些调用会留在上下文里——要丢掉它们,请把文本改成与原文不同的内容再保存。
172
176
  - **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
173
177
  - **进行中拒绝编辑**:未闭合的轮次或正在压缩时返回 `409 busy`。
174
178
  - **回退是持久的,隐藏不是**。回退写进日志后,模型上下文永久改变;转录里那些行的隐藏是本插件客户端半部做的。卸载插件后,旧行会重新显示出来(而模型上下文里的回退仍然生效)——因为替换事件是官方事件,不会随插件消失。
175
179
  - 回退后**系统提示词保持不变**(窗口永不包含 surface 节点 0,该节点也永不可编辑)。
176
180
  - **旧会话(v3 格式日志)在 DSH 0.1.7 下读不到**:DSH 的会话读取层对这类日志返回 `session-not-found`,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。
181
+ - **改写过的行,只有转录里的那颗复制按钮被接管**。平台自己画的复制按钮在别处(**轨迹视图** `dsh-client-ui-trajectory`)读的是同一份投影数据,本版没有接管它:在那里复制一条被改写过的消息,仍会拿到改写前的文本。
177
182
 
178
183
  ### 排查:编辑入口整个不见了
179
184
 
@@ -215,6 +220,30 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
215
220
 
216
221
  ### 更新日志
217
222
 
223
+ **0.2.18** —— 修「一字不改地保存,也会写记录 / 多出轮次」。只修 Bug,交互语义不变。
224
+
225
+ - **症状(真实会话 `session-f728ff1b`)**:用户 5 次编辑的文本全是「回复1」,一字未变。3 次「改提问」各落一条替换事件(`seq 29/30/31`,`source.editedBy:dsh-edit-turn`),2 次「改回答」各开一个合成轮次(`developer/message` 空载体 + 新 `assistant/message`,`seq 32-37` 与 `38-43`)。用户只发了 2 条消息,轨迹视图里却有 4 轮(「怎么有四轮?」)。加守卫后这 5 条记录、2 个轮次都不该产生。
226
+ - **守卫的位置**:`applyEdit`(宿主半部)里、算出回退窗口并确认目标仍在 surface 上之后、**任何 `session.append` 之前**。文本没变则直接返回 `{ kind, applied:false, unchanged:true, shadowed:[], original }`——不落替换、不 flush、不开轮次、不动活循环的 `lastTurn`。
227
+ - **怎么比较**:`text === plan.original`,而 `plan.original` 就是 `readMessageText(target)` 的结果,也就是 `/state` 的 `turns[].text` / `replies[].text` 用的**同一份解析**(编辑器预填的正是它)。**逐字比较、不 trim**:只有空白差异(多一个尾随空格)仍算真修改,走原来的写入路径,行为与旧版**完全一致**——这是防回归的关键一条。
228
+ - **两条路都走同一个守卫**:改提问(`user/message` 载体)不写替换;改回答(`developer/message` 空载体 + 合成轮次 + 新 `assistant/message`)既不写替换,也**不开轮次、不追加回答**。
229
+ - **「重跑」不被守卫拦掉(关键陷阱)**:编辑器的「重跑」= 先保存、再链式调用 `POST /dsh-rerun-turn/apply`,所以守卫只能拦「写」。`unchanged` 是**标志**而非拒绝:路由照常 200,客户端读到它以后,`confirm()` 里那句 `if (rerunIntent) this.startSiblingRerun(target.turn)` 仍然执行(它与「写没写」无关,不被 `applied === false` 的报错分支吃到),只在**没有**重跑意图时才提示「内容没有变化」。
230
+ - **客户端只做三件事**:不把 `unchanged` 当成 `applied:false` 的失败(旧文案会说「已回退,但替换没有落地」)、不隐藏任何行(`shadowed` 为空,消息留在屏上、铅笔还在)、照常把重跑交出去。
231
+ - **English**: saving a draft that is verbatim the text the message already carries now writes NOTHING - no replacement event, no synthetic turn, no appended answer - and answers `unchanged:true`. The decision lives in the host, in the layer that decides what to write, and compares against the same parse the editor was filled with (`readMessageText`), verbatim and without trimming. The flag is not a refusal: the editor still starts the sibling re-run when that is what the user pressed.
232
+ - 本版:`npm test` **147/147**、`npm run verify:contract` 80/80、`npm run verify:client` **163/163**(`npm run check` exit 0);新增 4 条宿主用例(改提问不写、改回答不写且不新增轮次、真改了仍逐字写入且随后同样的保存成为 no-op、no-op 不越过 busy/not-editable 等既有拒绝)与 2 条 DOM 用例(原样保存 → 提示且不隐藏行、不发重跑;文本没变但点「重跑」→ 不写但重跑照常发起)。
233
+ - **负向验证**(`/tmp` 副本,仓库文件未动):去掉宿主守卫 → **恰好 3 条**新用例转红(144/147);把客户端的 `data.unchanged === true` 改成 `false` → **恰好 2 条**新用例转红;把 `unchanged` 分支改成提前 return(即“在路由/客户端直接 return 掉整个 apply”那种错法)→ **恰好 1 条**转红,正是「文本没变但走重跑 → 重跑仍发起」。
234
+
235
+ **0.2.17** —— 修「改写过的提示词,复制出来还是旧文本」。只修 Bug,交互语义不变。
236
+
237
+ - **根因在平台侧,但它就在下发的 bundle 里,读得到**:平台的复制按钮把它**画那一行时用的那条消息**交给剪贴板——文本取自投影节点(`dsh-client-ui-chat/lib/client.js:1117` 的 `MessageIconActions` 收一个 `text` prop,`onCopy` 里调 `writeClipboard(text)`,`:1129-1141`;这个 `text` 由 `UserStyleBubble` 从节点数据 `contentParts(data.content)` 解出,`:1398-1404` 把它交出去)。而**改写是替换事件,平台不给它建行**:`messageDefinition.match` 只认 `isAppendSurfaceEvent`(`surfaceOp === 'append'`,`:9267`)。于是那一行永远是**原来那条 append 消息**,行上复制按钮手里是「回复1」,界面上显示「回复2」的却是本插件的替代气泡(`bubble.textContent`)。用户按复制 → 粘出旧文本。**换编辑器无效**:编辑器的文本来自宿主账本(`targetFor` 读 `view.editable`),跟那颗按钮不是一回事。(行号按本机下发版本:DSH `0.2.0-rc.2`。)
238
+ - **修法:接住那颗按钮的按下,平台节点一根手指都不碰**。改写过的行由本插件在**平台自己的**复制按钮上装一个**捕获阶段**的 click 监听(捕获先于宿主挂在 root 上的冒泡处理器),命中即 `stopPropagation`(不让宿主再用旧文本写一次剪贴板),改用**宿主自己的** `writeClipboard`(`@deepseek-ai/dsh-client-ui-primitives`)写入**这一行现在显示的文本**(存在 `data-dshet-copy-text` 上),并自己画出宿主那次「复制成功」:1 秒的勾(样式表 `[data-dshet-copy-flash="1"]`,和宿主一样把图标换成勾)加上宿主自己的文案(`aria-label` 换成「复制成功」,按钮原文案先记在 `data-dshet-copy-label` 里,1 秒后还回去)。
239
+ - **按钮本身不移除、不隐藏、不禁用**(节点归宿主,宿主用自己的 reconciler 卸载它)。**插件走了之后那颗按钮退化成宿主本来的行为(复制原文),而不是变成一颗死按钮**;宿主版本的 `ui-primitives` 若没有 `writeClipboard`,本插件干脆不接管(同样保持宿主行为)。
240
+ - **归属与幂等(I3 / I4)**:接住按钮要用的一切(文本、文案、写入函数)都在**按下那一刻从按钮上读**,所以重载后的新实例能接管旧实例装的那颗监听;`data-dshet-copy-own` 保证同一颗按钮上永远只有一颗监听;行不再被改写、或本插件卸载时,只交还**本插件自己写下的**那些属性(卸载按 `[data-dshet-copy-text]` 全局收回)。兄弟插件的按钮(带自己命名空间的那些)不会被误认成宿主的复制按钮。
241
+ - **不做的**:不改平台代码、不改会话日志;**回复**编辑不受影响(它追加的是**新的一条**回答行,那行自带正确文本);轨迹视图(`dsh-client-ui-trajectory:5273`)有同一个读法,本版未涉及(见「已知限制」)。
242
+ - **English**: the platform's copy action hands over the text of the message the row was DRAWN for, and a rewritten prompt gets no row of its own (rows are built for append-origin surface events only), so copying from that row pasted the wording the user had replaced. This plugin now answers that press from a capture-phase listener on the host's own copy button - stopping the host's handler from writing the stale text - writes the text the row shows through the host's own `writeClipboard`, draws the host's own one-second "copied" check and label itself, and touches nothing else on the button: never removed, never hidden, never disabled, so a bundle that goes away leaves a working button that copies its own message again.
243
+ - 本版:`npm test` **141/141**、`npm run verify:contract` 80/80、`npm run verify:client` **163/163**(`npm run check` exit 0);新增 8 条 DOM 用例、6 条静态检查、2 个 `verify:live` 标记。DOM 桩顺带补上 `data-*` 属性与 `dataset` 的**双向反射**(此前只有 dataset→属性 一个方向:`setAttribute('data-...')` 造出来的夹具对 `dataset` 查不到,而浏览器里两者是同一件事)。**负向验证**(`/tmp` 副本,仓库文件未动;逐点还原后只跑 `test/client.dom.test.js`):整份 `lib/client.js` 还原到 HEAD → **7 条新用例转红**(第 8 条「没被改写的行交给宿主」是不越权的守卫,本就该绿);逐点:去掉接管调用 → 7 红、恢复行时不再释放 → 1 红、卸载不再收回 → 1 红、去掉 `stopPropagation` → 1 红、不画勾 → 1 红、不还文案 → 1 红、不再删文本标记 → 2 红、不再跳过兄弟按钮 → 1 红、去掉捕获标志 → 1 红、去掉 `data-dshet-copy-own` 优先查找 → 1 红。两套识别(按宿主的文案、按「条里没人认领的第一个按钮」)互为兜底:单独去掉任一条用例仍全绿,**同时去掉正好 2 红**。
244
+ - **验证表里 `npm test` 的旧数字(105)是过期值**,一并改成实测的 139(英文表同处一并改)。
245
+ - **真机提示**:运行中的实例下发的是 profile 里**安装的副本**(`~/.dsh/profiles/web/node_modules/dsh-edit-turn`,仍是 0.2.16),要看到本修复需要**重新安装本目录 + 重启 DSH**;本次没有重启,也没跑 `verify:live` / `verify:ui`(前者需要启动时打印的 token,后者没有可驱动的调试标签页)。
246
+
218
247
  **0.2.15** —— 修一个自己埋的隐患:回复编辑开的合成轮次会让下一次提问撞号。
219
248
 
220
249
  - **发现**:回复编辑必须在日志里开一个轮次(读路径只在"打开的轮次+步骤"里认 `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` 推到该轮号**。
@@ -367,6 +396,7 @@ This plugin adds it:
367
396
  - **The smallest window that is correct.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface - a changed answer invalidates everything built on it - which also keeps an assistant message (carrying its own tool_use blocks) and the tool/result it produced together. A dangling call/result pair is impossible.
368
397
  - **A rewritten message stays editable.** It still shows where the original stood, and hovering it offers the pencil again.
369
398
  - **Saving never calls the model.** It only writes the context. Regenerating a turn after the edit is a different operation, owned by the sister plugin **dsh-rerun-turn** (it shadows the turn, regenerates from the prompt the surface now shows, and replays the turns that followed); this plugin only forwards to it, through the "Re-run" button, when it is mounted.
399
+ - **A save that changes nothing writes nothing** (0.2.18). The layer that decides what to write compares the draft with the live text **verbatim**, through the same parse the editor was prefilled from (`readMessageText`, the one behind `/state`’s `turns[].text`), and answers `unchanged: true` before appending anything: no replacement event, no synthetic turn, no appended answer, no loop-counter sync. The flag is not a refusal - the route still answers 200, and the editor still starts the re-run when that was the button the user pressed.
370
400
  - **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The answer is rolled back together with everything after it, and the corrected text is **appended** as a fresh reply - the model goes on treating it as its own. `source.editedBy` records honestly that the plugin wrote those words.
371
401
  - **One click applies** - no second confirmation by default. The editor is already an explicit action the user opened; with `confirm: true` the confirmation step states what this save will discard, while the input box itself stays clean.
372
402
  - **Bilingual UI** that follows the current DSH locale.
@@ -404,6 +434,14 @@ an Enter that commits IME composition is never taken as save).
404
434
  If the editor warns that the message carries attachments, the rewrite keeps the
405
435
  text only and drops them.
406
436
 
437
+ **Saving a draft that is word-for-word what the message already says does
438
+ nothing at all.** The editor was prefilled with that text, so an untouched save
439
+ is not a request to change anything: the host writes no event - no replacement,
440
+ no synthetic turn, no fresh answer - closes the editor and says
441
+ "nothing changed". The log, the turn rail and the Trajectory view stay exactly
442
+ as they were. **"Re-run" is not affected**: an unchanged draft plus that button
443
+ still re-runs the turn, because regenerating it is what the user asked for.
444
+
407
445
  **Editing what the model said.** Hover any model reply and the same edit action
408
446
  appears. Click "Save" - the reply is replaced with your text,
409
447
  everything after it is removed, and the model goes on treating your words as its
@@ -441,6 +479,8 @@ node, then:
441
479
  the corrected text is appended as an assistant message inside that turn, and
442
480
  `step/end` + `turn/end` close it behind the correction.
443
481
 
482
+ **An unchanged draft is not written at all** (0.2.18): the layer that decides what to write compares the request’s text with the live node’s text - through the same `readMessageText` the editor was prefilled from - and returns `{ applied: false, unchanged: true, shadowed: [] }` **before** appending anything: no replacement, no flush, no turn, no loop-counter sync. The comparison is verbatim and does not trim, so any real difference (a trailing space included) takes the old path unchanged. `unchanged` is a flag for the caller, never a refusal: the route still answers 200, and the editor’s "Re-run" chain still starts (its whole point is to regenerate the turn even when the wording is what it already was).
483
+
444
484
  Then append:
445
485
 
446
486
  ```js
@@ -498,7 +538,7 @@ Verified against DSH `0.1.6-alpha.2` and `0.1.7-rc.1`, entirely **without model
498
538
  | Is the plugin actually mounted in a running instance? (read-only route-guard probe, no token needed) | `npm run probe:loaded [port]` | pass: `/state` answers 400 and `/apply` answers 405 - status codes only this plugin produces |
499
539
  | What the browser half asked for and got back (read-only diagnostics, failures included) | `GET /dsh-edit-turn/debug` | pass: a real browser was confirmed fetching replies, and an old v3 session was reproduced as session-not-found |
500
540
  | Official append AND READ contract against the real validator, in process | `npm run verify:contract` | 80 checks pass (every write sequence is reloaded through the reader afterwards): the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty developer carrier adds no model message, **two consecutive rollbacks are both accepted**, **the whole reply-editing mechanism is accepted**, **the invariants siblings depend on (single-node window, same event type, full `sourceEventSeqs` coverage, an `editedBy` marker on every replacement, a rewritten prompt whose `source.kind` is not `user`, a correction that carries no `sourceEventSeqs`)** |
501
- | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 105 tests pass |
541
+ | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 147 tests pass (including the four no-op cases: an unchanged prompt, an unchanged reply - no new turn - a real revision still written as before, and re-run with an unchanged draft) |
502
542
  | Browser-half static checks (registration, i18n completeness, styles, skin legibility, three-way version sync, wire contract) | `npm run verify:client` | all pass |
503
543
  | Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass, **including anchors on the host's node shapes** |
504
544
  | **Real browser rendering smoke** (CDP drives an already-open tab, read-only) | `npm run verify:ui` | all pass: the served bytes are the edited bytes, no empty placeholder left by a retired entry, the reply pencil sits in the platform action bar, a rolled-back row keeps its action bar, **every collapsed user message still shows the text that replaced it**, **that bubble sits above the time and the copy, in the order the platform draws a message**, **the rewriting pencil sits at the end of the surviving bar (confirmed live in the original session, the other session, and back again)**, **the reply pencil leads its bar, ahead of the copy (confirmed live)**, no `.dshet-floating` covers the revision text, and the entries survive a session round trip |
@@ -724,11 +764,13 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
724
764
  - **Editing a user message replaces that one message and leaves the rest of the conversation untouched; saving never calls the model.** For an immediate answer, use `dsh-rerun-turn`'s "Re-run"; editing a model reply only replaces the text, too.
725
765
  - **A prompt edit touches only that message.** The reply under it and the whole conversation after it are kept; saving never calls the model. A **reply edit** removes that answer and everything built on it, because a changed answer invalidates what followed. Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
726
766
  - **Text only.** A message carrying image or file attachments keeps its text and drops them (the editor warns first).
767
+ - **A draft identical to the live text writes nothing** (0.2.18): saving it as-is closes the editor with a "nothing changed" notice and leaves both the log and the rail alone; "Re-run" is the exception and still starts. The consequence worth knowing: a reply edit that is *textually* identical - the one case where you might have wanted the save only to drop that reply’s tool calls or reasoning - is now a no-op, and those calls stay in the context.
727
768
  - **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
728
769
  - **Running work is refused**: an unclosed turn or an in-flight compaction answers `409 busy`.
729
770
  - **The rollback is durable; the hiding is not.** Once written, the rollback permanently changes the model context. Hiding those rows in the transcript is this plugin's browser half. Uninstall the plugin and the old rows reappear - while the rollback in the model context still stands, because the replacement is an official event that outlives the plugin.
730
771
  - **The system prompt is never touched**: the window can never include surface node 0, and that node is never editable.
731
772
  - **Old sessions (v3-format logs) cannot be read under DSH 0.1.7**: the session reader answers `session-not-found` for them, so the plugin does not work in one at all (the visible symptom is no edit entry). Newly written sessions are v4 and work normally.
773
+ - **Only the transcript's own copy button is taken over on a rewritten row.** The platform draws the same stale text in its other copy affordance - the **Trajectory view** (`dsh-client-ui-trajectory`) - which this release does not answer: copying a rewritten message there still hands over the pre-rewrite wording.
732
774
 
733
775
  ### Troubleshooting: the edit entry disappeared entirely
734
776
 
package/lib/client.js CHANGED
@@ -34,13 +34,21 @@ 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.16'
37
+ const PLUGIN_VERSION = '0.2.18'
38
38
 
39
39
  // --- copy -----------------------------------------------------------------
40
40
 
41
41
  const zh = {
42
42
  'action.edit': '编辑这条消息',
43
43
  'action.edit-reply': '编辑这条回答',
44
+ // The platform's own words for its copy action, spelled here because the
45
+ // row's copy button is recognised BY them (see `nativeCopyButton`): the host
46
+ // labels that button through the shared `common` dictionary, and this is the
47
+ // same wording. Namespace keys shadow the shared ones, so these two keep the
48
+ // bundle's own key surface complete; the structural rule does not need them.
49
+ 'copy': '复制',
50
+ 'copied': '复制成功',
51
+ 'action.copied': '复制成功',
44
52
  'editor.title': '编辑这条消息',
45
53
  'editor.title-reply': '编辑这条回答',
46
54
  'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
@@ -67,6 +75,7 @@ window.__ModuleLoader__.load({
67
75
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
68
76
  'error.reply': '已回退,但替换没有落地。请重试。',
69
77
  'error.empty': '改写后的内容不能为空。',
78
+ 'error.unchanged': '内容没有变化,未做任何修改。',
70
79
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
71
80
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
72
81
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
@@ -78,6 +87,9 @@ window.__ModuleLoader__.load({
78
87
  const en = {
79
88
  'action.edit': 'Edit this message',
80
89
  'action.edit-reply': 'Edit this reply',
90
+ 'copy': 'Copy',
91
+ 'copied': 'Copied',
92
+ 'action.copied': 'Copied',
81
93
  'editor.title': 'Edit this message',
82
94
  'editor.title-reply': 'Edit this reply',
83
95
  'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
@@ -104,6 +116,7 @@ window.__ModuleLoader__.load({
104
116
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
105
117
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
106
118
  'error.empty': 'The revised message cannot be empty.',
119
+ 'error.unchanged': 'Nothing changed, so no edit was made.',
107
120
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
108
121
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
109
122
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
@@ -190,6 +203,15 @@ window.__ModuleLoader__.load({
190
203
  // the platform's time / copy / delete buttons (and every other plugin's)
191
204
  // stay reachable, so only rows with nothing worth keeping go fully away.
192
205
  '[data-dshet-hidden="1"]:not([data-dshet-keep-actions]){display:none!important}',
206
+ // The one platform action on such a row that cannot answer for itself:
207
+ // copy hands over the text of the message the row was DRAWN for - the
208
+ // wording the user replaced, because the host draws no row for a
209
+ // replacement event. This half answers that press and draws the host's
210
+ // own "copied" moment on the same button (`takeOverRowCopy`): the icon
211
+ // is swapped for a check the way the host swaps it, for the same second.
212
+ '[data-dshet-copy-flash="1"]{position:relative}',
213
+ '[data-dshet-copy-flash="1"]>svg{opacity:0}',
214
+ '[data-dshet-copy-flash="1"]::after{content:"";position:absolute;left:50%;top:50%;width:7px;height:12px;margin:-7px 0 0 -4px;border:solid currentColor;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}',
193
215
  // One rounded container, the way the platform's own composer box is
194
216
  // built: the input is transparent and borderless inside it, the actions
195
217
  // sit at its bottom-right, and focus is shown on the container rather
@@ -579,10 +601,26 @@ window.__ModuleLoader__.load({
579
601
  replies,
580
602
  failure: null,
581
603
  })
582
- // The rollback has landed, so the editor is gone with its row: a
583
- // failed second half has to be reported somewhere that survives that.
584
- if (data.kind === 'reply' && data.applied === false) this.notify('reply')
585
- if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
604
+ // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
605
+ // the text the message already carries. There is no failure to report -
606
+ // the edit was simply not an edit - so it is noticed as such, and
607
+ // nothing else about this pass changes: no row was shadowed, so none is
608
+ // hidden, and the message stays on screen with the pencil it had.
609
+ //
610
+ // The re-run intent survives it. "Re-run" saves first only because the
611
+ // sibling regenerates the turn from the prompt the surface shows; a
612
+ // user who pressed it wants that turn regenerated whether or not the
613
+ // wording moved, so the chain below is NOT gated on a write having
614
+ // happened. (The decision itself is the host's - the text is parsed
615
+ // there, once - and this half only reads the flag it sends back.)
616
+ if (data.unchanged === true) {
617
+ if (!rerunIntent) this.notify('unchanged')
618
+ } else {
619
+ // The rollback has landed, so the editor is gone with its row: a
620
+ // failed second half has to be reported somewhere that survives that.
621
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
622
+ if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
623
+ }
586
624
  // Save first, re-run second - always: the sibling re-runs the turn
587
625
  // from the prompt the surface NOW shows, so the revised wording has
588
626
  // to be on the surface before it is asked to regenerate. Not
@@ -893,6 +931,9 @@ window.__ModuleLoader__.load({
893
931
  const existing = ownRevisionBubble(row)
894
932
  if (revisionSeq === null || !hidden) {
895
933
  if (existing !== null) existing.remove()
934
+ // Nothing of ours stands in for this row any more: the row's own copy
935
+ // button answers for itself again.
936
+ releaseRowCopy(row)
896
937
  return true
897
938
  }
898
939
  // Only one bubble per row, ever: drop any other bubble that claims it
@@ -912,6 +953,7 @@ window.__ModuleLoader__.load({
912
953
  const text = entry && typeof entry.text === 'string' ? entry.text : ''
913
954
  if (text === '') {
914
955
  if (existing !== null) existing.remove()
956
+ releaseRowCopy(row)
915
957
  return node.kind !== 'user'
916
958
  }
917
959
  const bubble = existing === null ? cloneUserBubble(row) : existing
@@ -936,14 +978,21 @@ window.__ModuleLoader__.load({
936
978
  injectRowAction(row, editTarget, controller, t)
937
979
  removeRowAction(bubble)
938
980
  bubble.classList.remove('dshet-revision-action')
981
+ // The bar that survived still holds the host's own copy button, and it
982
+ // reads the message this row was drawn for - the wording the user just
983
+ // replaced. This row's text is the bubble above it, so the press is
984
+ // answered here (`takeOverRowCopy`).
985
+ takeOverRowCopy(row, text, t)
939
986
  } else {
940
987
  injectRowAction(bubble, editTarget, controller, t, { gutter: true })
941
988
  removeRowAction(row)
942
989
  bubble.classList.add('dshet-revision-action')
990
+ releaseRowCopy(row)
943
991
  }
944
992
  } else {
945
993
  removeRowAction(bubble)
946
994
  bubble.classList.remove('dshet-revision-action')
995
+ releaseRowCopy(row)
947
996
  }
948
997
  return true
949
998
  }
@@ -995,6 +1044,237 @@ window.__ModuleLoader__.load({
995
1044
  return null
996
1045
  }
997
1046
 
1047
+ // --- the rewritten row answers its own copy -------------------------------
1048
+ //
1049
+ // The platform's copy action hands over the text of the message it DREW the
1050
+ // row for. A prompt this plugin rewrote in place never gets a row of its own:
1051
+ // the host draws transcript rows for append-origin surface events only
1052
+ // (`isAppendSurfaceEvent` in dsh-client-ui-chat), and a rewrite is a
1053
+ // replacement, so the row that stands in for the message is the ORIGINAL one
1054
+ // and still carries the wording the user replaced. Pressing copy on a
1055
+ // rewritten prompt therefore pastes what the user was trying to get rid of:
1056
+ // rewrite 回复1 into 回复2, press copy, paste 回复1.
1057
+ //
1058
+ // The button cannot be repaired from here - its text is read off the host's
1059
+ // projection node, while the rewritten wording is the textContent of a bubble
1060
+ // this half owns - and it must not be taken out either: the host owns that
1061
+ // node and unmounts it through its own reconciler, and a bundle that goes
1062
+ // away has to leave a working button behind, not a dead one. What this half
1063
+ // CAN do is answer the press first: the listener is planted in the CAPTURE
1064
+ // phase, the host listens for clicks at its root in the bubble phase, so this
1065
+ // runs ahead of the host's handler and stops it, and the text it writes is
1066
+ // the text this row actually shows.
1067
+ //
1068
+ // Everything the listener needs is read off the BUTTON when the press
1069
+ // happens - the text, the label, the writer - never captured in the closure.
1070
+ // A reloaded bundle finds the button by its marker, refreshes that state, and
1071
+ // a listener the instance before it planted goes on working (the rule
1072
+ // `wireAction` follows for the pencil).
1073
+ const COPY_TEXT = 'dshetCopyText'
1074
+ const COPY_FLASH = 'dshetCopyFlash'
1075
+ const COPY_LABEL = 'dshetCopyLabel'
1076
+ const COPY_OWN = 'dshetCopyOwn'
1077
+ /** The live state of one press; a name no sibling plugin uses. */
1078
+ const COPY_PRESS = 'dshetCopyPress'
1079
+ const COPY_TIMER = 'dshetCopyTimer'
1080
+ /** The host draws its own check for a second; this is the same second. */
1081
+ const COPY_FEEDBACK_MS = 1000
1082
+
1083
+ /**
1084
+ * A button another plugin put in this bar.
1085
+ *
1086
+ * Every injected action in this family carries its own namespace marker (the
1087
+ * three sibling plugins do, by contract), and the host's own actions carry no
1088
+ * `data-*` of their own at all. This plugin's four markers are excluded:
1089
+ * they are written on the HOST's button, not on one of ours.
1090
+ */
1091
+ function pluginOwned(button) {
1092
+ const data = button.dataset
1093
+ if (data === undefined || data === null) return false
1094
+ for (const key of Object.keys(data)) {
1095
+ if (key === COPY_TEXT || key === COPY_FLASH || key === COPY_LABEL || key === COPY_OWN) continue
1096
+ if (key !== '') return true
1097
+ }
1098
+ return false
1099
+ }
1100
+
1101
+ /**
1102
+ * The platform's own copy button in a row's action bar, or null when the bar
1103
+ * holds none of its own to answer for.
1104
+ *
1105
+ * The host draws the clock, then the copy button, and gives a user row neither
1106
+ * a branch action nor an extra strip - so the bar's FIRST button is the copy
1107
+ * button. The CSS-module class the host's icons carry is deliberately not
1108
+ * required: it is a build hash that can be renamed, and a rule that reads it
1109
+ * would go quietly dead on the day it is (the pencil placement still matches
1110
+ * that suffix, which is a different, visible contract). A sibling plugin's
1111
+ * button is skipped: it carries its own namespace marker, and a sibling's is
1112
+ * never this half's to answer for. A button this half already answers is
1113
+ * found by ITS marker alone (I3), however the bar has been rebuilt around it.
1114
+ */
1115
+ function nativeCopyButton(row, t) {
1116
+ if (row === null || row === undefined || typeof row.querySelector !== 'function') return null
1117
+ const bar = row.querySelector('[class*="_actions"]')
1118
+ if (bar === null || bar === undefined) return null
1119
+ // The host's own wording for the two states of its copy button. It resolves
1120
+ // through the same namespace -> shared `common` fallback the host's own
1121
+ // lookup uses, so this names the button rather than guessing at it; a host
1122
+ // that cannot resolve them simply falls through to the rule below.
1123
+ const copy = typeof t === 'function' ? t('copy') : null
1124
+ const copied = typeof t === 'function' ? t('copied') : null
1125
+ let unclaimed = null
1126
+ for (const child of bar.children) {
1127
+ if (child.tagName !== 'BUTTON') continue
1128
+ if (child.dataset !== undefined && child.dataset[COPY_OWN] === '1') return child
1129
+ if (pluginOwned(child)) continue
1130
+ const label = typeof child.getAttribute === 'function' ? child.getAttribute('aria-label') : null
1131
+ if (typeof label === 'string' && label !== '' && (label === copy || label === copied)) return child
1132
+ if (unclaimed === null) unclaimed = child
1133
+ }
1134
+ return unclaimed
1135
+ }
1136
+
1137
+ /**
1138
+ * Hand a rewritten row's copy press to this half.
1139
+ *
1140
+ * The button is left exactly as the host rendered it - not removed, not
1141
+ * hidden, not disabled; only this half's own markers go on it. Without the
1142
+ * host's own clipboard writer the button keeps the host's behaviour too: a
1143
+ * takeover that cannot copy is worse than none.
1144
+ */
1145
+ function takeOverRowCopy(row, text, t) {
1146
+ const button = nativeCopyButton(row, t)
1147
+ if (button === null) return
1148
+ const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
1149
+ ? primitives.writeClipboard
1150
+ : null
1151
+ if (write === null) return
1152
+ if (button.dataset !== undefined && button.dataset[COPY_OWN] !== '1') {
1153
+ button.dataset[COPY_OWN] = '1'
1154
+ if (typeof button.addEventListener === 'function') {
1155
+ // Capture: this runs ahead of the host's own click handling, which
1156
+ // listens at the root and would write the message the row was drawn for.
1157
+ // Stopping the event there is also what keeps that handler from running
1158
+ // at all, so the host's own follow-up (its tooltip withdraw, its check)
1159
+ // does not happen for this press - which is why the check is drawn here.
1160
+ button.addEventListener('click', (event) => pressRowCopy(button, event), true)
1161
+ }
1162
+ }
1163
+ if (button.dataset !== undefined) {
1164
+ // The host's own wording, kept to give back: what the button said before
1165
+ // this half drew its check over it.
1166
+ if (button.dataset[COPY_LABEL] === undefined) {
1167
+ const label = typeof button.getAttribute === 'function' ? button.getAttribute('aria-label') : null
1168
+ if (typeof label === 'string' && label !== '') button.dataset[COPY_LABEL] = label
1169
+ }
1170
+ if (button.dataset[COPY_TEXT] !== text) button.dataset[COPY_TEXT] = text
1171
+ }
1172
+ button[COPY_PRESS] = { done: t('action.copied'), write }
1173
+ }
1174
+
1175
+ /**
1176
+ * The press itself.
1177
+ *
1178
+ * No text marker means the row is not (or no longer) a rewritten prompt, and
1179
+ * the press is left completely alone: the host's own handler answers it, and a
1180
+ * released row behaves like every other row.
1181
+ */
1182
+ function pressRowCopy(button, event) {
1183
+ const data = button.dataset
1184
+ if (data === undefined || typeof data[COPY_TEXT] !== 'string') return
1185
+ if (event !== null && event !== undefined) {
1186
+ if (typeof event.preventDefault === 'function') event.preventDefault()
1187
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
1188
+ }
1189
+ // One copy per check, the way the host paces its own button.
1190
+ if (data[COPY_FLASH] === '1') return
1191
+ const press = button[COPY_PRESS]
1192
+ if (press === null || press === undefined || typeof press.write !== 'function') return
1193
+ let pending = null
1194
+ try {
1195
+ pending = press.write(data[COPY_TEXT])
1196
+ } catch {
1197
+ return
1198
+ }
1199
+ if (pending === null || pending === undefined || typeof pending.then !== 'function') return
1200
+ pending.then(
1201
+ (ok) => {
1202
+ if (ok === true) flashRowCopy(button, press.done)
1203
+ },
1204
+ () => {},
1205
+ )
1206
+ }
1207
+
1208
+ /**
1209
+ * The host's own "copied" moment, drawn on the button it belongs to.
1210
+ *
1211
+ * The host shows it by swapping its icon and its label. On this row its own
1212
+ * React state never hears about the press, so this half draws the same thing
1213
+ * from its stylesheet (`[data-dshet-copy-flash="1"]`) and gives the borrowed
1214
+ * label back when the moment is over. Both are this half's own writes, and
1215
+ * both are taken back on release (I4).
1216
+ */
1217
+ function flashRowCopy(button, done) {
1218
+ const data = button.dataset
1219
+ if (data === undefined) return
1220
+ data[COPY_FLASH] = '1'
1221
+ if (typeof done === 'string' && done !== '' && typeof button.setAttribute === 'function') {
1222
+ button.setAttribute('aria-label', done)
1223
+ }
1224
+ if (button[COPY_TIMER] !== undefined && button[COPY_TIMER] !== null) {
1225
+ if (typeof window.clearTimeout === 'function') window.clearTimeout(button[COPY_TIMER])
1226
+ }
1227
+ if (typeof window.setTimeout !== 'function') return
1228
+ button[COPY_TIMER] = window.setTimeout(() => {
1229
+ button[COPY_TIMER] = null
1230
+ clearRowCopyFlash(button)
1231
+ }, COPY_FEEDBACK_MS)
1232
+ }
1233
+
1234
+ /**
1235
+ * Take the check back.
1236
+ *
1237
+ * `data-dshet-copy-label` is what the host's own button said before this half
1238
+ * drew its check on it; the host never wrote it, so giving it back is this
1239
+ * half returning its own change (I4).
1240
+ */
1241
+ function clearRowCopyFlash(button) {
1242
+ if (button[COPY_TIMER] !== undefined && button[COPY_TIMER] !== null) {
1243
+ if (typeof window.clearTimeout === 'function') window.clearTimeout(button[COPY_TIMER])
1244
+ button[COPY_TIMER] = null
1245
+ }
1246
+ const data = button.dataset
1247
+ if (data === undefined) return
1248
+ delete data[COPY_FLASH]
1249
+ const label = data[COPY_LABEL]
1250
+ if (typeof label === 'string' && label !== '' && typeof button.setAttribute === 'function') {
1251
+ button.setAttribute('aria-label', label)
1252
+ }
1253
+ }
1254
+
1255
+ /** Hand one platform copy button back: nothing of ours is left WRITTEN on it. */
1256
+ function releaseCopyButton(button) {
1257
+ if (button === null || button === undefined || typeof button !== 'object') return
1258
+ clearRowCopyFlash(button)
1259
+ const data = button.dataset
1260
+ if (data !== undefined) {
1261
+ delete data[COPY_TEXT]
1262
+ delete data[COPY_LABEL]
1263
+ }
1264
+ button[COPY_PRESS] = undefined
1265
+ // `data-dshet-copy-own` stays, and so does the listener: neither claims
1266
+ // anything about the message any more, and that marker is what stops a
1267
+ // reloaded bundle from planting a second listener on the same button. A
1268
+ // press with no text marker returns above and reaches the host's own
1269
+ // handler - exactly the behaviour the row had before this plugin saw it.
1270
+ }
1271
+
1272
+ /** The copy handover of a row, wherever the last pass left it. */
1273
+ function releaseRowCopy(row) {
1274
+ const button = nativeCopyButton(row)
1275
+ if (button !== null) releaseCopyButton(button)
1276
+ }
1277
+
998
1278
  // The message a chain of rewrites ends at.
999
1279
  //
1000
1280
  // Rewriting the same message twice lands two replacements: 8 -> 19 for the
@@ -1894,6 +2174,11 @@ window.__ModuleLoader__.load({
1894
2174
  // markers are this plugin's own writes, so this is its own state - and
1895
2175
  // only its own: the restore path asks the attribution guard first (I4).
1896
2176
  each('[data-dshet-hidden="1"]', (row) => setRowHidden(row, false))
2177
+ // A row's copy button was answering for this half; that handover is this
2178
+ // half's own write on a HOST node and goes back the same way (I4). The
2179
+ // button itself stays where the host owns it - it simply copies its own
2180
+ // message again, through the host's handler.
2181
+ each('[data-dshet-copy-text]', (button) => releaseCopyButton(button))
1897
2182
  each('[data-dshet-collapsed="1"]', (child) => {
1898
2183
  child.style.display = ''
1899
2184
  delete child.dataset.dshetCollapsed
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.16'
60
+ export const PLUGIN_VERSION = '0.2.18'
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
@@ -898,6 +898,37 @@ async function applyEdit(ctx, sessionId, body, config) {
898
898
  if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
899
899
  }
900
900
 
901
+ // Nothing to write when the revision IS the live text.
902
+ //
903
+ // The editor opens prefilled with exactly this text - it comes from
904
+ // `readMessageText`, the same parse behind `/state`'s `turns[].text` and
905
+ // `replies[].text` - so "open the editor and save without typing" arrives here
906
+ // as an exact string match. It must not write: every write is a replacement
907
+ // event (and, for a reply, a synthetic turn plus a fresh answer), so an
908
+ // unchanged save used to add a revision with no revision behind it and a whole
909
+ // turn to the rail while the conversation had not moved at all.
910
+ //
911
+ // Compared verbatim, deliberately, which is the same caliber the editor was
912
+ // filled with: a trailing space is a real difference and keeps the old
913
+ // behaviour, and nothing here trims (the only trim in this function is the
914
+ // emptiness check above, which is about validity, not equality).
915
+ //
916
+ // The answer is a FLAG, not a refusal: the editor's "re-run" button saves
917
+ // first and re-runs regardless, and its whole point is that the user wants the
918
+ // turn regenerated even when the wording is what it already was. So the caller
919
+ // decides what happens next, and this returns before anything is appended.
920
+ if (text === plan.original) {
921
+ return {
922
+ kind: plan.mode,
923
+ applied: false,
924
+ unchanged: true,
925
+ // Nothing left the surface, so nothing is shadowed: the caller must not
926
+ // hide a row on account of this answer.
927
+ shadowed: [],
928
+ original: plan.original,
929
+ }
930
+ }
931
+
901
932
  // A revised prompt has to (a) replace the old wording in the model context and
902
933
  // (b) NOT be answered. The platform answers any user message it finds on the
903
934
  // surface, so the revision cannot be *appended* - appending one made the loop
@@ -1256,6 +1287,11 @@ export function apply(ctx, config) {
1256
1287
  edit: result.kind,
1257
1288
  seq: typeof body.seq === 'number' ? body.seq : null,
1258
1289
  replacement: typeof result.replacementSeq === 'number' ? result.replacementSeq : null,
1290
+ // A save the host did not write because the text was already the
1291
+ // live text. Worth distinguishing from a landed edit here: the log
1292
+ // shows nothing for it, so this ring is the only place that records
1293
+ // "the user pressed save and nothing needed saving".
1294
+ unchanged: result.unchanged === true,
1259
1295
  })
1260
1296
  sendJson(res, 200, { ok: true, ...result })
1261
1297
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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",