dsh-turn-undo 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 吕才灵
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # dsh-turn-undo
2
+
3
+ > DSH 插件:在任意用户消息处点击“撤销”,让对话恢复到发送这条消息之前的状态——回滚 AI 造成的文件变更,并 fork 出新会话继续编辑。
4
+
5
+ ## 功能
6
+
7
+ - ✅ 每条用户消息的操作栏注入一个“撤销”按钮(↺)。
8
+ - ✅ 弹窗显示本轮会恢复/删除/修改的文件,并给出警告。
9
+ - ✅ 确认后:
10
+ - 恢复工作区文件到**发送该消息之前**的快照状态。
11
+ - 通过 DSH 原生的 `sessionController.fork` 从该消息之前 fork 出新会话。
12
+ - 旧会话保留,标题前方自动加上 `(已撤销)`。
13
+ - 新会话输入框自动填入被撤销的这条用户消息,方便你修改后重发。
14
+ - ✅ 不依赖 git,适用于任何目录。
15
+ - ✅ 快照去重(sha256 内容寻址),未变化文件零成本存储。
16
+ - ✅ 自动清理:TTL + 每会话数量上限。
17
+
18
+ ## 安装
19
+
20
+ ```sh
21
+ dsh plugin --profile <name> add dsh-turn-undo
22
+ ```
23
+
24
+ ## 使用
25
+
26
+ 1. 在对话中,每条用户消息的操作栏会出现撤销按钮。
27
+ 2. 点击按钮,弹窗列出本轮影响的文件。
28
+ 3. 点击“确认恢复并继续”:
29
+ - 文件恢复。
30
+ - 旧会话被标记为 `(已撤销)`。
31
+ - 新会话打开,输入框里已有原消息内容。
32
+ 4. 在新会话里修改问题后重新发送即可。
33
+
34
+ ## 架构
35
+
36
+ ### 服务端(`index.js`)
37
+
38
+ - **快照触发**:监听 `session/event` 全局事件。
39
+ - `turn/start`:生成 `turn - 0.5` 小数快照,记录用户发消息之前的工作区状态。
40
+ - `turn/end`:生成整数 `turn` 快照,记录 AI 完成工作后的状态。
41
+ - **存储**:
42
+ - `$DSH_HOME/turn-undo/objects/<sha256>`(内容去重)
43
+ - `$DSH_HOME/turn-undo/snapshots/<sessionId>/<turn>.json`(manifest:path → hash)
44
+ - **恢复**:从 `turn - 0.5` 快照回写文件,并删除快照里不存在的文件。
45
+ - **fork**:
46
+ - 调用 DSH 官方 web 同款的 `ctx.sessionController.fork({ sessionId, atSeq })`。
47
+ - 对“第一条用户消息”前面没有 completed turn 时,fall back 到 `ctx.sessionController.create({ cwd, agentPreset })` 创建空白会话。
48
+ - 旧会话通过 `ctx.sessionController.rename` 加上 `(已撤销)` 前缀。
49
+ - **HTTP API**:
50
+ - `GET /api/turn-undo?sessionId=...&messageSeq=...&promptText=...`:预览本轮影响。
51
+ - `POST /api/turn-undo`(body `{ sessionId, messageSeq, promptText }`):执行恢复 + fork。
52
+
53
+ ### 客户端(`client.js`)
54
+
55
+ - `MutationObserver` 自动检测新增的用户消息。
56
+ - 在用户消息的 IconActions 区域通过 portal 注入撤销按钮。
57
+ - 弹窗组件:`RestoreButton` + `RestoreModal`。
58
+ - 恢复完成后先打开新会话,再轮询设置输入框草稿(`conversation.input.shell(...).setDraft(...)`)。
59
+
60
+ ## 配置
61
+
62
+ 在 `cordis.patch.yml` 中配置:
63
+
64
+ ```yaml
65
+ - insert:
66
+ - id: turn-undo
67
+ name: dsh-turn-undo
68
+ config:
69
+ snapshotTtlDays: 7 # 快照保留天数
70
+ maxSnapshotsPerSession: 50 # 每会话最多保留快照数
71
+ maxFileBytes: 10485760 # 单个文件超过 10MB 不纳入快照
72
+ maxFilesPerSnapshot: 10000 # 单次快照最多文件数
73
+ maxSnapshotBytes: 524288000 # 单次快照总大小上限 500MB
74
+ snapshotDelayMs: 250 # turn/end 后等待落盘的延迟
75
+ excludes: # 覆盖默认忽略目录/文件
76
+ - node_modules/
77
+ - .git/
78
+ - dist/
79
+ ```
80
+
81
+ 默认 excludes 还会过滤:`.venv/`、`venv/`、`__pycache__/`、`target/`、`build/`、`.next/`、`.turbo/`、`.gradle/`、`.idea/`、`.vscode/`、`coverage/`、`.DS_Store`、`*.log`。注意:这些只是扫描时的过滤,restore 的删除阶段也遵循同样规则。
82
+
83
+ ## 开发
84
+
85
+ 运行集成测试:
86
+
87
+ ```sh
88
+ node test-integration.mjs
89
+ ```
90
+
91
+ 语法检查:
92
+
93
+ ```sh
94
+ node -e "import('./index.js')"
95
+ node -c client.js
96
+ ```
97
+
98
+ 在 DSH 里热加载/测试:
99
+
100
+ ```sh
101
+ cd /Users/lyu/Documents/Source/deepseek-harness
102
+ pnpm dsh web
103
+ ```
104
+
105
+ > 客户端(`client.js`)修改后,若 `pnpm run dev:web` 没在开,需要重新构建客户端 bundle 并刷新页面。
106
+
107
+ ## 已知限制
108
+
109
+ - 只能恢复 AI 通过 DSH 工具产生的文件变更;无法捕获:
110
+ - 用户通过 VS Code 等外部编辑器直接修改的文件。
111
+ - 用户在终端里直接执行的 shell 命令。
112
+ - MCP 工具中不经过 DSH 工具管线的操作。
113
+ - 撤销依赖本插件在运行期间生成的快照;对安装插件之前的旧会话无效。
114
+ - 第一条用户消息的撤销会创建空白新会话(因为没有 completed turn 可 fork)。
115
+ - 超过容量上限的单次快照会被跳过,并打印警告。
116
+
117
+ ## 许可
118
+
119
+ MIT
package/client.js ADDED
@@ -0,0 +1,470 @@
1
+ // Browser half of the dsh-turn-undo plugin.
2
+ //
3
+ // Injects a "restore to before this message" button into the IconActions row of
4
+ // EVERY USER MESSAGE ONLY (never AI replies), using React.createPortal.
5
+ //
6
+ // Injection logic mirrors dsh-turn-rewind's collectPortalTargets():
7
+ // 1. Locate message rows with [data-chat-flow-kind="user"][data-chat-anchor-key]
8
+ // or [data-chat-flow-kind="steering"][data-chat-anchor-key] — only user
9
+ // messages carry these flow kinds (AI replies use "assistant-step",
10
+ // "tool-call", etc. and are never selected).
11
+ // 2. Inside the row, find the message actions container (the element
12
+ // holding the copy/branch buttons). We locate it by taking the parent of
13
+ // the row's first <button> (dimension-independent of hashed CSS classes).
14
+ // 3. That parent element becomes the portal target for the undo button.
15
+ //
16
+ // Communication:
17
+ // GET /api/turn-undo?sessionId=...&turn=... -> preview
18
+ // POST /api/turn-undo -> restore
19
+ //
20
+ // See DESIGN.md for full architecture.
21
+
22
+ window.__ModuleLoader__.load({
23
+ id: 'dsh-turn-undo',
24
+ factory: function (require) {
25
+ var module = { exports: {} }
26
+ var exports = module.exports
27
+
28
+ var API_PATH = '/api/turn-undo'
29
+ var USER_ROW_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]'
30
+
31
+ // 定位用户/steering 消息的操作行容器。
32
+ // 运行时用户行内没有 data-actions-reveal(该属性只在 turn-tail 上);
33
+ // 直接用稳定结构:取行内第一个 <button>(copy 等)的 parentElement 作为操作行。
34
+ function findIconActions(row) {
35
+ if (!row || row.nodeType !== 1) return null
36
+ var kind = row.getAttribute('data-chat-flow-kind')
37
+ if (kind !== 'user' && kind !== 'steering') return null
38
+ var firstButton = row.querySelector('button')
39
+ if (!firstButton) return null
40
+ var actions = firstButton.parentElement
41
+ if (!actions || actions.nodeType !== 1) return null
42
+ if (actions.querySelectorAll(':scope > button').length < 1) return null
43
+ return actions
44
+ }
45
+
46
+ function truncate(text, max) {
47
+ return text.length > max ? text.substring(0, max) + '…' : text
48
+ }
49
+
50
+ function kindLabel(kind) {
51
+ switch (kind) {
52
+ case 'created': return '已创建'
53
+ case 'modified': return '已修改'
54
+ case 'deleted': return '已删除'
55
+ default: return '已变更'
56
+ }
57
+ }
58
+
59
+ function apply(ctx) {
60
+ var react, reactDom
61
+ try {
62
+ react = require('react')
63
+ reactDom = require('react-dom')
64
+ } catch (error) {
65
+ console.error('[turn-undo] client skipped: cannot load react', error)
66
+ return
67
+ }
68
+ var h = react.createElement
69
+ var useState = react.useState
70
+ var useEffect = react.useEffect
71
+ var useLayoutEffect = react.useLayoutEffect
72
+
73
+ var STYLE_ID = 'dsh-turn-undo'
74
+ if (!document.getElementById('dsh-turn-undo-styles')) {
75
+ var styleEl = document.createElement('style')
76
+ styleEl.id = 'dsh-turn-undo-styles'
77
+ styleEl.textContent = [
78
+ '.dtu-container{display:inline-flex;align-items:center}',
79
+ '.dtu-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
80
+ '.dtu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
81
+ '.dtu-trigger:disabled{cursor:not-allowed;opacity:.5}',
82
+ '.dtu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}',
83
+ '.dtu-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
84
+ '.dtu-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box;padding:18px}',
85
+ '.dtu-header{display:flex;justify-content:space-between;align-items:center;gap:12px}',
86
+ '.dtu-title{margin:0;font-size:18px;font-weight:600;color:var(--dsw-alias-label-primary)}',
87
+ '.dtu-close{width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:20px;cursor:pointer}.dtu-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
88
+ '.dtu-section-label{font-size:14px;font-weight:600;color:var(--dsw-alias-label-secondary);margin:0 0 8px}',
89
+ '.dtu-msgbox{font-size:14px;color:var(--dsw-alias-label-primary);padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;line-height:1.5}',
90
+ '.dtu-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
91
+ '.dtu-files{min-width:0;max-width:100%;box-sizing:border-box;max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}',
92
+ '.dtu-file{display:flex;justify-content:space-between;gap:16px;min-width:0;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}.dtu-file:last-child{border-bottom:0}',
93
+ '.dtu-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}',
94
+ '.dtu-kind{flex:none;color:var(--dsw-alias-label-tertiary)}',
95
+ '.dtu-warning,.dtu-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border-radius:10px;font-size:12px;line-height:18px}',
96
+ '.dtu-warning{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary)}',
97
+ '.dtu-error{border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);color:var(--dsw-alias-state-error-primary)}',
98
+ '.dtu-footer{display:flex;justify-content:flex-end;gap:12px}',
99
+ '.dtu-btn{padding:8px 16px;border:0;border-radius:6px;font-size:14px;cursor:pointer}',
100
+ '.dtu-btn-cancel{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}.dtu-btn-cancel:hover{background:var(--dsw-alias-interactive-bg-hover)}',
101
+ '.dtu-btn-primary{background:var(--dsw-alias-state-business-primary);color:#fff;font-weight:500;min-width:120px}.dtu-btn-primary:hover{opacity:.9}.dtu-btn-primary:disabled{opacity:.5;cursor:not-allowed}',
102
+ ].join('')
103
+ document.head.appendChild(styleEl)
104
+ }
105
+
106
+ function openRestoredSession(sessionId, draftText) {
107
+ try {
108
+ ctx.sessions.open(sessionId)
109
+ } catch (error) {
110
+ console.warn('[turn-undo] openRestoredSession open failed:', error.message)
111
+ return
112
+ }
113
+ if (!draftText) return
114
+ function trySetDraft(remaining) {
115
+ if (remaining <= 0) return
116
+ try {
117
+ var scope = ctx.sessions.scope(sessionId)
118
+ if (scope !== undefined) {
119
+ ctx.conversation.input.for(scope).setDraft(draftText)
120
+ return
121
+ }
122
+ } catch (error) {
123
+ if (remaining <= 1) {
124
+ console.warn('[turn-undo] openRestoredSession setDraft failed:', error.message)
125
+ }
126
+ }
127
+ setTimeout(function () { trySetDraft(remaining - 1) }, 100)
128
+ }
129
+ trySetDraft(20)
130
+ }
131
+
132
+ ctx.inject(['slots', 'sessions', 'conversation'], function (scope) {
133
+ scope.effect(function () {
134
+ return scope.slots.inject('conversation.session.header.actions', function () {
135
+ return scope.slots.register({
136
+ name: 'conversation.session.header.actions',
137
+ id: 'turn-undo-portals',
138
+ order: 100,
139
+ inject: function () {
140
+ var sessionsSvc = scope.sessions
141
+ var conversationSvc = scope.conversation
142
+ return {
143
+ openRestoredSession: function (newSessionId, draftText) {
144
+ // Open first: the input shell only exists after the session binding is materialized.
145
+ if (sessionsSvc && sessionsSvc.open) {
146
+ sessionsSvc.open(newSessionId)
147
+ }
148
+ if (!conversationSvc || !conversationSvc.input || !draftText) return
149
+ function trySetDraft(remaining) {
150
+ if (remaining <= 0) return
151
+ try {
152
+ var shell = conversationSvc.input.shell(newSessionId)
153
+ if (shell && typeof shell.setDraft === 'function') {
154
+ shell.setDraft(draftText)
155
+ return
156
+ }
157
+ } catch (e) {
158
+ if (remaining <= 1) {
159
+ console.warn('[turn-undo] Failed to set draft in new session:', e.message)
160
+ }
161
+ }
162
+ setTimeout(function () { trySetDraft(remaining - 1) }, 100)
163
+ }
164
+ trySetDraft(20)
165
+ },
166
+ }
167
+ },
168
+ }, RestoreMessagePortals)
169
+ })
170
+ })
171
+ })
172
+
173
+ function RestoreMessagePortals(props) {
174
+ var sessionId = props.sessionId
175
+ var openRestoredSessionProp = props.openRestoredSession
176
+ var useChat = props.useChat
177
+ var nodes = useChat(function (snapshot) {
178
+ return snapshot.nodes ? snapshot.nodes.values() : []
179
+ })
180
+ var targetsState = useState([])
181
+ var targets = targetsState[0]
182
+ var setTargets = targetsState[1]
183
+
184
+ useLayoutEffect(function () {
185
+ var active = true
186
+ var queued = false
187
+ var refresh = function () {
188
+ if (!active) return
189
+ var next = collectPortalTargets(nodes)
190
+ setTargets(function (current) {
191
+ return samePortalTargets(current, next) ? current : next
192
+ })
193
+ }
194
+ var queueRefresh = function () {
195
+ if (queued || !active) return
196
+ queued = true
197
+ queueMicrotask(function () {
198
+ queued = false
199
+ refresh()
200
+ })
201
+ }
202
+ refresh()
203
+ var observer = new MutationObserver(queueRefresh)
204
+ observer.observe(document.body, { childList: true, subtree: true })
205
+ return function () {
206
+ active = false
207
+ observer.disconnect()
208
+ }
209
+ }, [nodes])
210
+
211
+ var portals = []
212
+ for (var i = 0; i < targets.length; i++) {
213
+ var target = targets[i]
214
+ portals.push(reactDom.createPortal(
215
+ h(RestoreMessageAction, {
216
+ matched: target.matched,
217
+ sessionId: sessionId,
218
+ openRestoredSession: openRestoredSessionProp,
219
+ }),
220
+ target.container,
221
+ sessionId + ':' + String(target.matched.messageSeq),
222
+ ))
223
+ }
224
+ return portals
225
+ }
226
+
227
+ function RestoreMessageAction(props) {
228
+ var matched = props.matched
229
+ var sessionId = props.sessionId
230
+ var openRestoredSession = props.openRestoredSession
231
+ var messageSeq = matched.messageSeq
232
+ var messageText = matched.promptText
233
+ var openState = useState(false)
234
+ var open = openState[0]
235
+ var setOpen = openState[1]
236
+ var previewState = useState(null)
237
+ var preview = previewState[0]
238
+ var setPreview = previewState[1]
239
+ var loadingState = useState(true)
240
+ var loading = loadingState[0]
241
+ var setLoading = loadingState[1]
242
+ var errorState = useState(null)
243
+ var error = errorState[0]
244
+ var setError = errorState[1]
245
+ var applyingState = useState(false)
246
+ var applying = applyingState[0]
247
+ var setApplying = applyingState[1]
248
+ var doneState = useState(false)
249
+ var done = doneState[0]
250
+ var setDone = doneState[1]
251
+
252
+ useEffect(function () {
253
+ var cancelled = false
254
+ setLoading(true)
255
+ setError(null)
256
+ fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
257
+ method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
258
+ })
259
+ .then(function (res) { return res.json() })
260
+ .then(function (data) { if (!cancelled) setPreview(data) })
261
+ .catch(function (err) { if (!cancelled) setError(err.message || '请求失败') })
262
+ .finally(function () { if (!cancelled) setLoading(false) })
263
+ return function () { cancelled = true }
264
+ }, [])
265
+
266
+ function show() {
267
+ setOpen(true)
268
+ setPreview(null)
269
+ setDone(false)
270
+ setLoading(true)
271
+ // NOTE: cancelled 只存在于 mount effect 闭包,show() 引用它会抛
272
+ // ReferenceError → setLoading(false) 永不执行 → 弹框一直"正在检查…",
273
+ // 且未捕获错误可能导致 React 卸下 portal(按钮消失)。必须无条件收尾。
274
+ fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
275
+ method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
276
+ })
277
+ .then(function (res) { return res.json() })
278
+ .then(function (data) { setPreview(data) })
279
+ .catch(function (err) { setError(err.message || '请求失败') })
280
+ .finally(function () { setLoading(false) })
281
+ }
282
+
283
+ function close() { if (applying) return; setOpen(false) }
284
+
285
+ function canApply() {
286
+ return preview !== null && !preview.error && !loading && !applying && !done
287
+ }
288
+
289
+ function applyRestore() {
290
+ if (!canApply() || applying) return
291
+ setApplying(true)
292
+ setError(null)
293
+ fetch(API_PATH, {
294
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({ sessionId: sessionId, messageSeq: messageSeq, promptText: messageText }),
296
+ })
297
+ .then(function (res) { return res.json() })
298
+ .then(function (data) {
299
+ if (data.error && data.ok === false) { setError(data.error); setApplying(false); return }
300
+ setDone(true)
301
+ setApplying(false)
302
+ setOpen(false)
303
+ if (data.newSessionId && openRestoredSession) {
304
+ openRestoredSession(data.newSessionId, messageText)
305
+ }
306
+ })
307
+ .catch(function (err) { setError(err.message || '请求失败'); setApplying(false) })
308
+ }
309
+
310
+ var changes = (preview && Array.isArray(preview.changes)) ? preview.changes : []
311
+ var noSnapshot = (preview && preview.noSnapshot) === true
312
+ var previewError = (preview && preview.error) ? preview.error : null
313
+
314
+ return h('div', { className: 'dtu-container' },
315
+ h('button', {
316
+ type: 'button', className: 'dtu-trigger',
317
+ title: '恢复到发送这条消息之前', 'aria-label': '恢复到发送这条消息之前',
318
+ onClick: show, disabled: applying,
319
+ },
320
+ h('svg', {
321
+ width: '16', height: '16', viewBox: '0 0 16 16',
322
+ fill: 'none', 'aria-hidden': 'true', style: { display: 'block' },
323
+ },
324
+ h('path', {
325
+ d: 'M6.35 3.25 2.75 7l3.6 3.75M3.1 7h5.15a4.25 4.25 0 0 1 4.25 4.25v1.25',
326
+ stroke: 'currentColor', strokeWidth: '1.45',
327
+ strokeLinecap: 'round', strokeLinejoin: 'round',
328
+ }),
329
+ ),
330
+ ),
331
+ open ? h(RestoreDialog, {
332
+ sessionId: sessionId, messageText: messageText, turn: messageSeq,
333
+ onClose: close, preview: preview, loading: loading, error: error,
334
+ applying: applying, done: done, changes: changes,
335
+ previewError: previewError, noSnapshot: noSnapshot, canApply: canApply, applyRestore: applyRestore,
336
+ }) : null,
337
+ )
338
+ }
339
+
340
+ function RestoreDialog(props) {
341
+ var sessionId = props.sessionId
342
+ var messageText = props.messageText
343
+ var turn = props.turn
344
+ var onClose = props.onClose
345
+ var preview = props.preview
346
+ var loading = props.loading
347
+ var error = props.error
348
+ var applying = props.applying
349
+ var done = props.done
350
+ var changes = props.changes
351
+ var previewError = props.previewError
352
+ var noSnapshot = props.noSnapshot
353
+ var canApply = props.canApply
354
+ var applyRestore = props.applyRestore
355
+
356
+ return reactDom.createPortal(
357
+ h('div', { className: 'dtu-overlay', onClick: onClose },
358
+ h('div', { className: 'dtu-dialog', onClick: function (e) { e.stopPropagation() } },
359
+ h('div', { className: 'dtu-body' },
360
+ h('div', { className: 'dtu-header' },
361
+ h('h3', { className: 'dtu-title' }, '恢复到发送这条消息之前'),
362
+ h('button', { onClick: onClose, className: 'dtu-close', 'aria-label': '关闭' },
363
+ h('span', {}, '×'),
364
+ ),
365
+ ),
366
+ messageText ? h('div', { className: 'dtu-section' },
367
+ h('div', { className: 'dtu-section-label' }, '消息'),
368
+ h('div', { className: 'dtu-msgbox' }, truncate(messageText, 200)),
369
+ ) : null,
370
+ loading ? h('p', { className: 'dtu-status' }, '正在检查可以恢复的项目文件…') : null,
371
+ previewError ? h('p', { className: 'dtu-error' }, previewError) : null,
372
+ (!loading && noSnapshot)
373
+ ? h('p', { className: 'dtu-status' }, '该时点没有可用快照,将仅创建新会话。') : null,
374
+ (!loading && !previewError && !noSnapshot && changes.length === 0)
375
+ ? h('p', { className: 'dtu-status' }, '这条消息之前没有需要恢复的文件。') : null,
376
+ (!loading && !previewError && changes.length > 0)
377
+ ? h('div', { className: 'dtu-section' },
378
+ h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
379
+ h('div', { className: 'dtu-files' },
380
+ changes.map(function (change, idx) {
381
+ return h('div', { key: idx, className: 'dtu-file' },
382
+ h('code', {}, change.path),
383
+ h('span', { className: 'dtu-kind' }, kindLabel(change.kind)),
384
+ )
385
+ }),
386
+ ),
387
+ ) : null,
388
+ (!loading && !previewError && changes.length > 0)
389
+ ? h('div', { className: 'dtu-warning' },
390
+ '警告:恢复会把文件覆盖到这条消息发送前的状态。'
391
+ ) : null,
392
+ (error && !previewError) ? h('p', { className: 'dtu-error' }, error) : null,
393
+ h('div', { className: 'dtu-footer' },
394
+ h('button', { onClick: onClose, className: 'dtu-btn dtu-btn-cancel', disabled: applying }, '取消'),
395
+ h('button', {
396
+ onClick: applyRestore, className: 'dtu-btn dtu-btn-primary',
397
+ disabled: !canApply(),
398
+ }, done ? '已完成' : (applying ? '正在恢复…' : '确认恢复并继续')),
399
+ ),
400
+ ),
401
+ ),
402
+ ),
403
+ document.body,
404
+ )
405
+ }
406
+
407
+ function selectUndoMessage(node) {
408
+ if (node.kind !== 'user' && node.kind !== 'steering') return null
409
+ if (typeof node.seq !== 'number' || node.seq < 0) return null
410
+ var promptText = ''
411
+ var content = node.content || []
412
+ for (var i = 0; i < content.length; i++) {
413
+ if (content[i].type === 'text' && typeof content[i].text === 'string') {
414
+ promptText += content[i].text + '\n'
415
+ }
416
+ }
417
+ if (!promptText.trim()) return null
418
+ return { messageSeq: node.seq, promptText: promptText.trim().substring(0, 200) }
419
+ }
420
+
421
+ function selectUndoMessageTarget(value) {
422
+ var node = ('key' in value && 'data' in value) ? value.data : value
423
+ var matched = selectUndoMessage(node)
424
+ if (matched === null) return null
425
+ return {
426
+ matched: matched,
427
+ rowKey: ('key' in value && 'data' in value) ? value.key : ('node:' + String(node.seq)),
428
+ }
429
+ }
430
+
431
+ function collectPortalTargets(nodes) {
432
+ var nodeArr = (nodes && typeof nodes.length === 'number') ? nodes : Array.from(nodes || [])
433
+ var rows = new Map()
434
+ var elements = document.querySelectorAll(USER_ROW_SELECTOR)
435
+ for (var i = 0; i < elements.length; i++) {
436
+ var element = elements[i]
437
+ var key = element.getAttribute('data-chat-anchor-key')
438
+ if (key) rows.set(key, element)
439
+ }
440
+ var targets = []
441
+ for (var i = 0; i < nodeArr.length; i++) {
442
+ var node = nodeArr[i]
443
+ var target = selectUndoMessageTarget(node)
444
+ if (target === null) continue
445
+ var row = rows.get(target.rowKey)
446
+ if (!row) continue
447
+ // 用与 findIconActions 相同的稳定策略定位操作行容器。
448
+ var actions = findIconActions(row)
449
+ if (!actions) continue
450
+ targets.push({ container: actions, matched: target.matched })
451
+ }
452
+ return targets
453
+ }
454
+
455
+ function samePortalTargets(left, right) {
456
+ return left.length === right.length && left.every(function (target, index) {
457
+ var other = right[index]
458
+ return other !== undefined
459
+ && target.container === other.container
460
+ && target.matched.messageSeq === other.matched.messageSeq
461
+ && target.matched.promptText === other.matched.promptText
462
+ })
463
+ }
464
+ }
465
+
466
+ exports.apply = apply
467
+ exports.inject = ['slots', 'sessions', 'conversation']
468
+ return module.exports
469
+ },
470
+ })
@@ -0,0 +1,9 @@
1
+ # The dsh-turn-undo bundle patch
2
+ # Inserted over whatever layers came before it.
3
+ # A patch replaces the targeted row's whole config, so a profile overriding
4
+ # this row must restate every key it means to keep.
5
+
6
+ - insert:
7
+ - id: turn-undo
8
+ name: dsh-turn-undo
9
+ config: {}