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 +45 -3
- package/lib/client.js +290 -5
- package/lib/index.js +37 -1
- package/package.json +1 -1
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` |
|
|
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` |
|
|
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.
|
|
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
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
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.
|
|
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.
|
|
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",
|