dsh-message-rewind 0.0.0-stage → 1.1.2

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/client.js ADDED
@@ -0,0 +1,518 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-message-rewind',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const NS = 'message-rewind';
7
+ const CHANNEL = '/message-rewind';
8
+
9
+ const zh = {
10
+ edit: '编辑并重发', remove: '删除这条消息及其后的对话', copy: '复制', copied: '已复制',
11
+ 'clock.md': '{m}月{d}日', 'clock.ymd': '{y}年{m}月{d}日',
12
+ cancel: '取消', close: '关闭', loading: '正在检查文件改动…', save: '保存并重发', deleteConfirm: '删除', scope: '回滚范围', body: '消息内容',
13
+ editTitle: '编辑并重新发送', deleteTitle: '删除这条消息?',
14
+ editHint: '将删除这条消息及其后的所有对话,并用修改后的内容重新发送。删除前的记录会被永久删除,无法恢复。',
15
+ deleteHint: '将删除这条消息及其后的所有对话。删除前的记录会被永久删除,无法恢复。',
16
+ hasFiles: '这条消息之后记录到以下文件改动:',
17
+ messagesOnly: '只回滚消息', withFiles: '同时回滚消息和文件',
18
+ fileWarning: '文件会恢复到这条消息之前的内容;若文件之后又被修改,将停止而不会覆盖。',
19
+ incomplete: '部分改动快照已不可用,无法完整回滚文件;仍可以只回滚消息。',
20
+ running: '当前有未结束的轮次,请先停止运行',
21
+ queued: '还有排队中的消息,会随原记录一起作废。',
22
+ noGit: '当前工作区不在 Git 仓库内:只能记录 DSH 文件工具(写/改)动过的文件,shell 命令删除或修改的文件无法回滚。在这个目录执行 git init 后即可完整记录。',
23
+ attachments: '含附件的消息暂时只能删除,不能编辑',
24
+ empty: '消息不能为空', failed: '操作失败,请重试。', dialog: '消息回滚',
25
+ 'error.invalid-message': '消息位置无效。',
26
+ 'error.stale-message': '这条消息已不在当前对话记录里,请刷新后重试。',
27
+ 'error.not-user-message': '只能编辑或删除你发送的消息。',
28
+ 'error.file-changed': '文件在记录之后又被修改过,已停止回滚,未覆盖任何文件。',
29
+ 'error.unsupported-diff': '这份文件差异暂不支持回滚。',
30
+ 'error.file-unreadable': '有文件无法读取,未回滚任何文件。',
31
+ 'error.file-too-large': '有文件超过文本回滚上限,未回滚任何文件。',
32
+ 'error.file-binary': '有文件不是 UTF-8 文本,未回滚任何文件。',
33
+ 'error.file-rewind': '文件回滚失败,未写入任何文件。',
34
+ 'error.path-escape': '有文件不在该轮工作区内,未回滚任何文件。',
35
+ 'error.file-restore-failed': '文件回滚中断,部分文件需人工检查。',
36
+ 'error.snapshot-missing': '文件改动快照已不可用,未回滚任何文件。',
37
+ 'error.binary-file': '只能回滚文本文件。',
38
+ 'error.coarse-diff': '改动过大,无法安全回滚。',
39
+ 'error.bad-action': '操作类型无效。',
40
+ 'error.open-turn': '请先停止当前运行,再编辑或删除消息。',
41
+ 'error.attachments': '含附件的消息暂时只能删除,不能编辑。',
42
+ 'error.empty-message': '消息不能为空。',
43
+ 'error.incomplete-snapshot': '部分文件改动快照已不可用,只能只回滚消息。',
44
+ 'error.no-workspace': '这个会话没有工作区,只能只回滚消息。',
45
+ 'error.invalid-session': '会话标识无效。',
46
+ 'error.unknown-endpoint': '未知接口。',
47
+ 'error.request-too-large': '请求体过大。',
48
+ 'error.bad-request': '请求格式无效。',
49
+ 'error.unknown': '操作失败,请重试。',
50
+ };
51
+ const en = {
52
+ edit: 'Edit and resend', remove: 'Delete this message and every following turn', copy: 'Copy', copied: 'Copied',
53
+ 'clock.md': '{m}/{d}', 'clock.ymd': '{y}-{m}-{d}',
54
+ cancel: 'Cancel', close: 'Close', loading: 'Checking file changes…', save: 'Save and resend', deleteConfirm: 'Delete', scope: 'Rollback scope', body: 'Message text',
55
+ editTitle: 'Edit and resend', deleteTitle: 'Delete this message?',
56
+ editHint: 'Deletes this message and every following turn, then sends the edited text. The prior record is deleted for good and cannot be restored.',
57
+ deleteHint: 'Deletes this message and every following turn. The prior record is deleted for good and cannot be restored.',
58
+ hasFiles: 'File changes were recorded after this message:',
59
+ messagesOnly: 'Roll back messages only', withFiles: 'Roll back messages and files',
60
+ fileWarning: 'Files are restored to their state before this message. Later edits stop the operation instead of being overwritten.',
61
+ incomplete: 'Some snapshots are unavailable, so files cannot be fully rolled back. Rolling back messages only still works.',
62
+ running: 'A turn is still running — stop it first',
63
+ queued: 'Queued prompts are discarded along with the deleted record.',
64
+ noGit: 'This workspace is not inside a Git repository, so only files touched by DSH write/edit tools are recorded; files changed by shell commands cannot be rolled back. Run git init in this directory to record everything.',
65
+ attachments: 'Messages with attachments can only be deleted, not edited',
66
+ empty: 'Message cannot be empty', failed: 'Operation failed. Please try again.', dialog: 'Message rewind',
67
+ 'error.invalid-message': 'Invalid message position.',
68
+ 'error.stale-message': 'This message is no longer in the conversation. Refresh and try again.',
69
+ 'error.not-user-message': 'Only messages you sent can be edited or deleted.',
70
+ 'error.file-changed': 'A file changed after it was recorded, so nothing was rolled back.',
71
+ 'error.unsupported-diff': 'This file diff cannot be rolled back.',
72
+ 'error.file-unreadable': 'A file could not be read, so nothing was rolled back.',
73
+ 'error.file-too-large': 'A file exceeds the text-rollback size limit, so nothing was rolled back.',
74
+ 'error.file-binary': 'A file is not UTF-8 text, so nothing was rolled back.',
75
+ 'error.file-rewind': 'File rollback failed; nothing was written.',
76
+ 'error.path-escape': 'A file lies outside that turn\u2019s workspace, so nothing was rolled back.',
77
+ 'error.file-restore-failed': 'File rollback was interrupted; some files need manual checking.',
78
+ 'error.snapshot-missing': 'The recorded file snapshot is unavailable, so nothing was rolled back.',
79
+ 'error.binary-file': 'Only text files can be rolled back.',
80
+ 'error.coarse-diff': 'The change is too large to roll back safely.',
81
+ 'error.bad-action': 'Invalid operation.',
82
+ 'error.open-turn': 'Stop the running turn before editing or deleting messages.',
83
+ 'error.attachments': 'Messages with attachments can only be deleted.',
84
+ 'error.empty-message': 'The message cannot be empty.',
85
+ 'error.incomplete-snapshot': 'Some file snapshots are unavailable; roll back messages only.',
86
+ 'error.no-workspace': 'This conversation has no workspace; roll back messages only.',
87
+ 'error.invalid-session': 'Invalid session id.',
88
+ 'error.unknown-endpoint': 'Unknown endpoint.',
89
+ 'error.request-too-large': 'The request body is too large.',
90
+ 'error.bad-request': 'Invalid request.',
91
+ 'error.unknown': 'Operation failed. Please try again.',
92
+ };
93
+
94
+ const css = `
95
+ .dmr-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}
96
+ .dmr-actions{height:calc(28px + var(--dsh-content-font-delta,0px));align-items:center;gap:8px;display:flex}
97
+ .dmr-time{font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px}
98
+ .dmr-action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:6px;display:inline-flex}
99
+ .dmr-action svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}
100
+ .dmr-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
101
+ .dmr-action:disabled,.dmr-action[data-unavailable]{cursor:default;opacity:.4}
102
+ .dmr-action:disabled:hover{background:0 0;color:var(--dsw-alias-label-tertiary)}
103
+ .dmr-danger:not(:disabled):hover{color:var(--dsw-alias-state-error-primary)}
104
+ .dmr-mask{position:fixed;inset:0;z-index:1000;pointer-events:auto;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);display:flex;align-items:center;justify-content:center;padding:24px}
105
+ .dmr-modal{box-sizing:border-box;width:min(460px,100%);max-height:calc(100vh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg);box-shadow:var(--dsw-elevation-prominent);padding:20px}
106
+ .dmr-title{font-size:16px;font-weight:600;line-height:24px;margin:0 0 10px}
107
+ .dmr-hint{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:0 0 12px}
108
+ .dmr-text{box-sizing:border-box;width:100%;min-height:140px;resize:vertical;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;padding:10px 12px}
109
+ .dmr-text:focus{outline:2px solid var(--dsw-focus-ring-color);outline-offset:1px}
110
+ .dmr-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
111
+ .dmr-button{border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:7px 12px;cursor:pointer}
112
+ .dmr-button:not(:disabled):hover{background:var(--dsw-alias-interactive-bg-hover)}
113
+ .dmr-button:focus-visible{outline:2px solid var(--dsw-focus-ring-color);outline-offset:2px}
114
+ .dmr-button:disabled{opacity:.5;cursor:not-allowed}
115
+ .dmr-primary{background:var(--dsw-alias-button-primary-fill);border-color:transparent;color:var(--dsw-alias-label-primary-foreground);font-weight:500}
116
+ .dmr-primary:not(:disabled):hover{background:var(--dsw-alias-button-primary-hover)}
117
+ .dmr-files{margin:0 0 12px;padding:10px 12px;max-height:150px;overflow:auto;background:var(--dsw-alias-bg-layer-2);border-radius:var(--dsw-radius-md);font:12px/20px var(--ds-font-family-code);color:var(--dsw-alias-label-secondary);list-style:none;overflow-wrap:anywhere}
118
+ .dmr-choice{margin:0 0 12px;display:flex;flex-direction:column;gap:4px}
119
+ .dmr-choice-title{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);margin:0 0 2px}
120
+ .dmr-option{display:flex;align-items:center;gap:8px;font-size:13px;line-height:22px;color:var(--dsw-alias-label-primary);cursor:pointer}
121
+ .dmr-option input{margin:0;accent-color:var(--dsw-alias-button-primary-fill);cursor:pointer}
122
+ .dmr-option-disabled{color:var(--dsw-alias-label-tertiary);cursor:not-allowed}
123
+ .dmr-option-disabled input{cursor:not-allowed}
124
+ .dmr-warning{color:var(--dsw-alias-state-warn-primary);font-size:12px;line-height:18px;margin:10px 0}
125
+ .dmr-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;margin:12px 0;overflow-wrap:anywhere}
126
+ @media (max-width:480px){.dmr-mask{padding:12px}.dmr-modal{padding:16px}.dmr-footer{flex-direction:column}.dmr-button{width:100%}}
127
+ `;
128
+
129
+ const pad2 = (value) => String(value).padStart(2, '0');
130
+ /** Match the shipped `formatMessageClock`: 24h clock plus a localized date prefix. */
131
+ function formatMessageClock(time, t, now = Date.now()) {
132
+ const date = new Date(time);
133
+ const current = new Date(now);
134
+ const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
135
+ if (date.getFullYear() === current.getFullYear() && date.getMonth() === current.getMonth() && date.getDate() === current.getDate()) return clock;
136
+ const params = { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() };
137
+ return `${date.getFullYear() === current.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)} ${clock}`;
138
+ }
139
+
140
+ // Own controls instead of requiring a Harness Client package: primitives are
141
+ // not a plugin contract, and a missing export would blank the Chat row.
142
+ const iconProps = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: 'false' };
143
+ function IconEdit() { return h('svg', iconProps, h('path', { d: 'M10.4 2.8l2.8 2.8L5 13.8H2.2v-2.8l8.2-8.2z' }), h('path', { d: 'M9 4.2l2.8 2.8' })); }
144
+ function IconTrash() { return h('svg', iconProps, h('path', { d: 'M2.8 4.4h10.4' }), h('path', { d: 'M6.2 4.4V2.8h3.6v1.6' }), h('path', { d: 'M4.4 4.4l.6 8.8h6l.6-8.8' })); }
145
+ function IconCopy() { return h('svg', iconProps, h('rect', { x: '5.4', y: '5.4', width: '7.8', height: '7.8', rx: '1.4' }), h('path', { d: 'M3.4 10.6V4.2a1.4 1.4 0 0 1 1.4-1.4h6.4' })); }
146
+ function IconCheck() { return h('svg', iconProps, h('path', { d: 'M2.8 8.6l3.4 3.2 7-7.4' })); }
147
+
148
+ /** Copy text with the same clipboard fallback the host uses; never throws. */
149
+ async function writeClipboard(text) {
150
+ try {
151
+ if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
152
+ } catch { /* fall through to execCommand */ }
153
+ const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined;
154
+ if (exec === undefined) return false;
155
+ const area = document.createElement('textarea');
156
+ area.value = text;
157
+ area.setAttribute('readonly', '');
158
+ area.style.cssText = 'position:fixed;left:-9999px;top:0';
159
+ document.body.appendChild(area);
160
+ area.select();
161
+ try { return exec('copy'); } catch { return false; } finally { area.remove(); }
162
+ }
163
+
164
+ function contentParts(content) {
165
+ const texts = [];
166
+ const attachments = [];
167
+ const rest = [];
168
+ for (const block of content) {
169
+ if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text);
170
+ else if (block.type === 'image' && block.attachment !== undefined) attachments.push({ type: 'image', image: { attachment: block.attachment } });
171
+ else if (block.type === 'file' && block.attachment !== undefined) attachments.push({ type: 'file', file: block.attachment });
172
+ else rest.push(block);
173
+ }
174
+ return { text: texts.join(''), attachments, rest };
175
+ }
176
+
177
+ function Modal({ title, locked, onClose, children }) {
178
+ const ref = React.useRef(null);
179
+ const lockedRef = React.useRef(locked);
180
+ lockedRef.current = locked;
181
+ React.useEffect(() => {
182
+ const previous = document.activeElement;
183
+ const target = ref.current?.querySelector('textarea, button');
184
+ target?.focus();
185
+ if (target?.tagName === 'TEXTAREA' && typeof target.setSelectionRange === 'function') target.setSelectionRange(target.value.length, target.value.length);
186
+ const onKey = (event) => {
187
+ if (event.key === 'Escape' && !lockedRef.current) { event.preventDefault(); onClose(); return; }
188
+ if (event.key !== 'Tab') return;
189
+ const items = [...(ref.current?.querySelectorAll('textarea, input:not(:disabled), button:not(:disabled)') ?? [])];
190
+ if (items.length === 0) { event.preventDefault(); return; }
191
+ const first = items[0];
192
+ const last = items[items.length - 1];
193
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
194
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
195
+ };
196
+ document.addEventListener('keydown', onKey);
197
+ return () => {
198
+ document.removeEventListener('keydown', onKey);
199
+ if (previous?.isConnected !== false) previous?.focus?.();
200
+ };
201
+ }, [onClose]);
202
+ return h('div', { className: 'dmr-mask', onMouseDown: (event) => { if (event.target === event.currentTarget && !lockedRef.current) onClose(); } },
203
+ h('section', { className: 'dmr-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': title, ref },
204
+ h('h2', { className: 'dmr-title' }, title), children));
205
+ }
206
+
207
+ return {
208
+ name: 'dsh-message-rewind',
209
+ inject: ['slots', 'uiWorkspace', 'locale', 'connection'],
210
+ apply(ctx) {
211
+ try {
212
+ install(ctx);
213
+ } catch (error) {
214
+ console.error('[message-rewind] client half failed to install', error);
215
+ }
216
+ },
217
+ };
218
+
219
+ function install(ctx) {
220
+ {
221
+ ctx.effect(() => {
222
+ const style = document.createElement('style');
223
+ style.dataset.plugin = 'dsh-message-rewind';
224
+ style.dataset.pluginCss = `${NS}.module.css`;
225
+ style.textContent = css;
226
+ document.head.appendChild(style);
227
+ return () => style.remove();
228
+ }, `${NS}: styles`);
229
+ ctx.effect(() => {
230
+ const disposeZh = ctx.locale.register(NS, 'zh', zh);
231
+ const disposeEn = ctx.locale.register(NS, 'en', en);
232
+ return () => { disposeZh(); disposeEn(); };
233
+ }, `${NS}: dictionaries`);
234
+ const t = (key, params) => ctx.locale.bind(NS)(key, params);
235
+
236
+ // Host failures carry a stable code; translate it and fall back to the
237
+ // Host's own message only when the dictionary has no entry.
238
+ const errorText = (error) => {
239
+ const code = error?.rewindCode;
240
+ if (typeof code === 'string') {
241
+ const key = `error.${code}`;
242
+ const translated = t(key);
243
+ if (translated !== key) return translated;
244
+ }
245
+ return error instanceof Error ? error.message : String(error);
246
+ };
247
+
248
+ const REQUEST_TIMEOUT = 30_000;
249
+ const remote = (endpoint, payload, signal) => ctx.connection.rpc.call(CHANNEL, endpoint, payload, signal).then((result) => {
250
+ if (!result || result.ok !== true) {
251
+ const error = new Error(result?.error?.message ?? t('failed'));
252
+ if (typeof result?.error?.details?.rewindCode === 'string') error.rewindCode = result.error.details.rewindCode;
253
+ throw error;
254
+ }
255
+ return result.value;
256
+ });
257
+
258
+ // A dialog's own AbortController cancels its read; the timeout bounds a
259
+ // hung Host. Both are passed to the official RPC call.
260
+ const signalFor = (request) => {
261
+ const timeout = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(REQUEST_TIMEOUT) : undefined;
262
+ const abort = request?.abort?.signal;
263
+ if (abort === undefined) return timeout;
264
+ if (timeout === undefined) return abort;
265
+ return typeof AbortSignal.any === 'function' ? AbortSignal.any([abort, timeout]) : timeout;
266
+ };
267
+
268
+ let current = null;
269
+ const listeners = new Set();
270
+ const publish = (next) => { current = next; for (const listener of listeners) listener(); };
271
+ const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); };
272
+ const getSnapshot = () => current;
273
+ // Loading is the only cancellable busy phase: an execute that already
274
+ // reached the Host cannot be safely abandoned from the Client.
275
+ const close = () => {
276
+ if (!current) return;
277
+ if (current.busy && current.phase !== 'loading') return;
278
+ current.abort?.abort();
279
+ publish(null);
280
+ };
281
+ // Every open mints a token; a late response patches only the dialog it
282
+ // belongs to, so a closed or replaced dialog is never resurrected.
283
+ const patchIf = (token, fields) => { if (current !== null && current.token === token) publish({ ...current, ...fields }); };
284
+
285
+ async function execute(rewindFiles) {
286
+ const request = current;
287
+ if (!request || request.busy) return;
288
+ const token = request.token;
289
+ patchIf(token, { busy: true, error: null });
290
+ try {
291
+ const result = await remote('execute', {
292
+ sessionId: request.sessionId,
293
+ seq: request.seq,
294
+ action: request.kind,
295
+ text: request.text,
296
+ rewindFiles,
297
+ }, signalFor(request));
298
+ if (current?.token !== token) return;
299
+ if (!result || typeof result.sessionId !== 'string') throw new Error(t('failed'));
300
+ publish(null);
301
+ // Navigate first so the source's client reference is released, then let
302
+ // the host retire its log: the sidebar keeps exactly one conversation.
303
+ ctx.uiWorkspace.openSession(result.sessionId);
304
+ const source = result.sourceSessionId;
305
+ if (typeof source === 'string' && source !== result.sessionId) {
306
+ Promise.resolve()
307
+ .then(() => new Promise((resolveDelay) => window.setTimeout(resolveDelay, 200)))
308
+ .then(() => remote('purge', { sessionId: source }, typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(REQUEST_TIMEOUT) : undefined))
309
+ .then((outcome) => {
310
+ if (!outcome || outcome.removed !== true) console.warn('[message-rewind] source session kept in archive:', outcome?.message ?? 'log not deleted');
311
+ })
312
+ .catch((error) => { console.warn('[message-rewind] purge failed:', error instanceof Error ? error.message : String(error)); });
313
+ }
314
+ } catch (error) {
315
+ patchIf(token, { busy: false, error: errorText(error) });
316
+ }
317
+ }
318
+
319
+ async function check() {
320
+ const request = current;
321
+ if (!request) return;
322
+ const token = request.token;
323
+ patchIf(token, { phase: 'loading', busy: true, error: null });
324
+ try {
325
+ const details = await remote('inspect', { sessionId: request.sessionId, seq: request.seq }, signalFor(request));
326
+ if (current?.token !== token) return;
327
+ patchIf(token, { busy: false, details, phase: 'confirm' });
328
+ } catch (error) {
329
+ if (current?.token !== token) return;
330
+ patchIf(token, { busy: false, phase: 'confirm', error: errorText(error) });
331
+ }
332
+ }
333
+
334
+ // The edit dialog reads the recorded changes while the user types, so the
335
+ // rollback scope is visible before the first save. `checking` gates the
336
+ // actions without hiding the textarea or locking the dialog.
337
+ async function inspectEdit(request) {
338
+ const token = request.token;
339
+ patchIf(token, { checking: true, error: null });
340
+ try {
341
+ const details = await remote('inspect', { sessionId: request.sessionId, seq: request.seq }, signalFor(request));
342
+ if (current?.token !== token) return;
343
+ patchIf(token, { checking: false, details });
344
+ } catch (error) {
345
+ if (current?.token !== token) return;
346
+ patchIf(token, { checking: false, error: errorText(error) });
347
+ }
348
+ }
349
+
350
+ function openEdit(sessionId, seq, text) {
351
+ const dialog = { token: {}, kind: 'edit', sessionId, seq, text, phase: 'edit', busy: false, checking: true, error: null, details: null, abort: new AbortController() };
352
+ publish(dialog);
353
+ void inspectEdit(dialog);
354
+ }
355
+
356
+ function openDelete(sessionId, seq) {
357
+ publish({ token: {}, kind: 'delete', sessionId, seq, text: '', phase: 'loading', busy: true, error: null, details: null, abort: new AbortController() });
358
+ void check();
359
+ }
360
+
361
+ function RewindDialog() {
362
+ const dialog = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
363
+ const [text, setText] = React.useState('');
364
+ const key = dialog ? `${dialog.sessionId}:${dialog.seq}:${dialog.kind}` : null;
365
+ React.useEffect(() => { setText(dialog?.text ?? ''); }, [key]);
366
+ if (!dialog) return null;
367
+ const editing = dialog.kind === 'edit';
368
+ const phase = dialog.phase;
369
+ const details = dialog.details;
370
+ const files = details?.files ?? [];
371
+ const blocked = Boolean(details?.unfinished);
372
+ const selected = dialog.withFiles === true;
373
+ const canRollback = details !== null && files.length > 0 && !details.incomplete;
374
+ // Both dialogs ask the same question — how far the rewind reaches — and
375
+ // differ only in the action that follows it, so the choice renders once.
376
+ const fileSection = details !== null && (files.length > 0 || details.incomplete) ? h(React.Fragment, null,
377
+ files.length > 0 && h('p', { className: 'dmr-hint' }, t('hasFiles')),
378
+ files.length > 0 && h('ul', { className: 'dmr-files' }, files.map((file, index) => h('li', { key: `${index}:${file}` }, file))),
379
+ h('div', { className: 'dmr-choice', role: 'radiogroup', 'aria-label': t('scope') },
380
+ h('p', { className: 'dmr-choice-title' }, t('scope')),
381
+ h('label', { className: 'dmr-option' },
382
+ h('input', {
383
+ type: 'radio',
384
+ name: 'dmr-rollback-scope',
385
+ checked: !selected,
386
+ onChange: () => patchIf(dialog.token, { withFiles: false }),
387
+ }),
388
+ t('messagesOnly')),
389
+ h('label', { className: canRollback ? 'dmr-option' : 'dmr-option dmr-option-disabled' },
390
+ h('input', {
391
+ type: 'radio',
392
+ name: 'dmr-rollback-scope',
393
+ checked: selected,
394
+ disabled: !canRollback,
395
+ onChange: () => patchIf(dialog.token, { withFiles: true }),
396
+ }),
397
+ t('withFiles'))),
398
+ h('p', { className: 'dmr-warning' }, details.incomplete ? t('incomplete') : t('fileWarning'))) : null;
399
+ const cancelButton = h('button', { className: 'dmr-button', type: 'button', onClick: close, disabled: dialog.busy }, t('cancel'));
400
+ return h(Modal, {
401
+ title: editing ? t('editTitle') : t('deleteTitle'),
402
+ locked: dialog.busy && phase !== 'loading',
403
+ onClose: close,
404
+ },
405
+ phase === 'edit' && h(React.Fragment, null,
406
+ h('p', { className: 'dmr-hint' }, t('editHint')),
407
+ h('textarea', {
408
+ className: 'dmr-text',
409
+ value: text,
410
+ disabled: dialog.busy,
411
+ 'aria-label': t('body'),
412
+ onChange: (event) => setText(event.target.value),
413
+ }),
414
+ dialog.checking && h('p', { className: 'dmr-hint' }, t('loading')),
415
+ fileSection),
416
+ phase === 'loading' && h(React.Fragment, null,
417
+ h('p', { className: 'dmr-hint' }, t('loading')),
418
+ h('div', { className: 'dmr-footer' },
419
+ h('button', { className: 'dmr-button', type: 'button', onClick: close }, t('cancel')))),
420
+ phase === 'confirm' && h(React.Fragment, null,
421
+ h('p', { className: 'dmr-hint' }, t('deleteHint')),
422
+ fileSection),
423
+ blocked && h('p', { className: 'dmr-warning', role: 'status' }, t('running')),
424
+ (details?.queued ?? 0) > 0 && h('p', { className: 'dmr-warning', role: 'status' }, t('queued')),
425
+ details?.git === false && h('p', { className: 'dmr-warning', role: 'status' }, t('noGit')),
426
+ dialog.error && h('p', { className: 'dmr-error', role: 'alert' }, dialog.error),
427
+ phase === 'edit' && h('div', { className: 'dmr-footer' },
428
+ cancelButton,
429
+ h('button', {
430
+ className: 'dmr-button dmr-primary',
431
+ type: 'button',
432
+ disabled: dialog.busy || dialog.checking || text.trim() === '' || blocked,
433
+ onClick: () => {
434
+ patchIf(dialog.token, { text });
435
+ if (details === null) { void inspectEdit(current); return; }
436
+ void execute(canRollback && selected);
437
+ },
438
+ }, t('save'))),
439
+ phase === 'confirm' && h('div', { className: 'dmr-footer' },
440
+ cancelButton,
441
+ h('button', {
442
+ className: 'dmr-button dmr-primary',
443
+ type: 'button',
444
+ disabled: dialog.busy || blocked,
445
+ onClick: () => void execute(canRollback && selected),
446
+ }, t('deleteConfirm'))));
447
+ }
448
+
449
+ function UserActions({ node, sessionId, renderFactorySlot, loadImage, useSession }) {
450
+ const data = node.data;
451
+ const running = typeof useSession === 'function' ? useSession((snapshot) => snapshot.running === true) : false;
452
+ const [copied, setCopied] = React.useState(false);
453
+ const copyPending = React.useRef(false);
454
+ React.useEffect(() => {
455
+ if (!copied) return undefined;
456
+ const timer = window.setTimeout(() => setCopied(false), 1000);
457
+ return () => window.clearTimeout(timer);
458
+ }, [copied]);
459
+ const parts = React.useMemo(() => contentParts(data?.content ?? []), [data?.content]);
460
+ const content = React.useMemo(() => ({
461
+ text: parts.text,
462
+ attachments: parts.attachments,
463
+ extraBlocks: parts.rest,
464
+ ...(data?.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }),
465
+ }), [parts, data?.referenceLabels]);
466
+ const facts = React.useMemo(() => ({
467
+ ...(data?.referenceLabels === undefined ? {} : { sessionLabels: data.referenceLabels }),
468
+ ...(data?.skillNames === undefined ? {} : { skillNames: data.skillNames }),
469
+ }), [data?.referenceLabels, data?.skillNames]);
470
+ if (typeof data?.seq !== 'number') return null;
471
+ if (typeof renderFactorySlot !== 'function') return h('div', { className: 'dmr-row' }, h('span', null, parts.text));
472
+ const attachmentBlocked = parts.attachments.length > 0 || parts.rest.length > 0;
473
+ const copyText = () => {
474
+ if (copied || copyPending.current) return;
475
+ copyPending.current = true;
476
+ void writeClipboard(parts.text).then((ok) => {
477
+ copyPending.current = false;
478
+ if (ok) setCopied(true);
479
+ });
480
+ };
481
+ // The disabled reason rides `aria-label`/`title`; a disabled action is
482
+ // never reachable, so it cannot be mis-clicked while a turn runs.
483
+ const button = (Icon, label, onClick, options = {}) => h('button', {
484
+ type: 'button',
485
+ className: `dmr-action${options.danger ? ' dmr-danger' : ''}`,
486
+ 'aria-label': label,
487
+ 'aria-disabled': options.disabled ? true : undefined,
488
+ 'data-unavailable': options.disabled ? true : undefined,
489
+ disabled: options.disabled ? true : undefined,
490
+ title: label,
491
+ onClick,
492
+ }, h(Icon, {}));
493
+ return h('div', { className: 'dmr-row' },
494
+ renderFactorySlot('conversation.chat.user.content', { content, loadImage, facts, interactive: true }),
495
+ h('div', { className: 'dmr-actions' },
496
+ data.time === undefined ? null : h('span', { className: 'dmr-time' }, formatMessageClock(data.time, t)),
497
+ button(IconEdit,
498
+ attachmentBlocked ? t('attachments') : running ? t('running') : t('edit'),
499
+ () => openEdit(sessionId, data.seq, parts.text),
500
+ { disabled: running || attachmentBlocked }),
501
+ button(IconTrash,
502
+ running ? t('running') : t('remove'),
503
+ () => openDelete(sessionId, data.seq),
504
+ { danger: true, disabled: running }),
505
+ button(copied ? IconCheck : IconCopy,
506
+ copied ? t('copied') : t('copy'),
507
+ () => copyText())));
508
+ }
509
+
510
+ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'user', locale: NS }, UserActions));
511
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'message-rewind.dialog', locale: NS }, RewindDialog));
512
+ }
513
+ };
514
+ },
515
+ });
516
+
517
+
518
+