dsh-edit-turn 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,773 @@
1
+ // dsh-edit-turn - browser half.
2
+ //
3
+ // One entry point: a per-session controller mounted through the official
4
+ // `conversation.input.overlay` list slot. It renders the in-place editor and
5
+ // enhances the message rows the host UI exposes no action slot for - user
6
+ // messages have no official action slot, which is exactly why the edit entry
7
+ // lives on the DOM row.
8
+ //
9
+ // Row targeting reads the official `useChat` standard hook (the ChatSnapshot
10
+ // keyed by the same `data-chat-flow-key` the DOM publishes) plus the official
11
+ // `data-chat-flow-*` anchors. No React fiber introspection and no CSS-module
12
+ // class hashing are involved, so a host UI refactor cannot silently detach the
13
+ // action.
14
+ //
15
+ // The module is a classic client bundle (client-modules protocol): it registers
16
+ // a factory with window.__ModuleLoader__ and returns apply().
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-edit-turn',
19
+ factory: (require) => {
20
+ var module = { exports: {} }
21
+ var exports = module.exports
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
23
+
24
+ const react = require('react')
25
+ const jsxRuntime = require('react/jsx-runtime')
26
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
27
+ const { jsx, jsxs, Fragment } = jsxRuntime
28
+
29
+ const NS = 'dsh-edit-turn'
30
+ const ROUTE_PREFIX = '/dsh-edit-turn'
31
+
32
+ /** Keep in sync with package.json and lib/index.js. */
33
+ const PLUGIN_VERSION = '0.1.0'
34
+
35
+ // --- copy -----------------------------------------------------------------
36
+
37
+ const zh = {
38
+ 'action.edit': '编辑这条消息并重跑',
39
+ 'editor.title': '编辑这条消息',
40
+ 'editor.placeholder': '修改后确认,会话将回退到这条消息之前并重新跑这一轮。',
41
+ 'editor.save': '保存并重跑',
42
+ 'editor.review': '确认回退',
43
+ 'editor.cancel': '取消',
44
+ 'editor.pending': '正在回退并重跑…',
45
+ 'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
46
+ 'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
47
+ 'confirm.title': '确认回退并重跑?',
48
+ 'confirm.body': '这条消息之后的全部内容会从模型上下文中移除,然后用你改写后的内容重新跑这一轮。原始会话日志保持不变。',
49
+ 'error.invalid': '请求无效,请刷新后重试。',
50
+ 'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
51
+ 'error.session-not-found': '找不到该会话的日志。',
52
+ 'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
53
+ 'error.not-editable': '这条消息不支持编辑。',
54
+ 'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
55
+ 'error.stale': '会话刚刚发生了变化,请重试。',
56
+ 'error.forbidden': '请求来源不被允许。',
57
+ 'error.method': '请求方式不被接受,请刷新后重试。',
58
+ 'error.generic': '编辑失败,请重试。',
59
+ 'error.prompt': '已回退,但重跑没有启动。请手动发送改写后的内容。',
60
+ 'error.empty': '改写后的内容不能为空。',
61
+ 'error.internal': '服务器内部错误,请稍后重试。',
62
+ }
63
+
64
+ const en = {
65
+ 'action.edit': 'Edit this message and re-run',
66
+ 'editor.title': 'Edit this message',
67
+ 'editor.placeholder': 'Confirm after editing: the conversation rolls back to just before this message and this turn runs again.',
68
+ 'editor.save': 'Save and re-run',
69
+ 'editor.review': 'Confirm rollback',
70
+ 'editor.cancel': 'Cancel',
71
+ 'editor.pending': 'Rolling back and re-running...',
72
+ 'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
73
+ 'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
74
+ 'confirm.title': 'Roll back and re-run?',
75
+ 'confirm.body': 'Everything after this message leaves the model context, then this turn runs again from your revised text. The original session log stays untouched.',
76
+ 'error.invalid': 'Invalid request; refresh and try again.',
77
+ 'error.session-not-active': 'This session is not open in DSH; open it first.',
78
+ 'error.session-not-found': 'No session log was found for this id.',
79
+ 'error.busy': 'This session is still replying; wait for it to finish.',
80
+ 'error.not-editable': 'This message cannot be edited.',
81
+ 'error.already-rolled-back': 'This message is no longer in the current context.',
82
+ 'error.stale': 'The session just changed; try again.',
83
+ 'error.forbidden': 'The request origin is not allowed.',
84
+ 'error.method': 'The request method is not accepted; refresh and try again.',
85
+ 'error.generic': 'The edit failed; try again.',
86
+ 'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
87
+ 'error.empty': 'The revised message cannot be empty.',
88
+ 'error.internal': 'The host hit an internal error; try again shortly.',
89
+ }
90
+
91
+ // Every error code the host can return travels through here. A code is
92
+ // validated against the dictionary before it becomes a lookup key, so a
93
+ // missing or unknown code can never reach `t()` as a constructed key - the
94
+ // locale would return the key itself and the UI would print
95
+ // "error.undefined" at the user.
96
+ const ERROR_KEYS = new Set(Object.keys(en).filter((key) => key.startsWith('error.')))
97
+ function errorText(t, code) {
98
+ return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
99
+ }
100
+
101
+ // --- style ----------------------------------------------------------------
102
+
103
+ const CSS = [
104
+ '.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;transition:background-color .12s,color .12s}',
105
+ '.dshet-action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit)}',
106
+ '.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
107
+ '.dshet-action:disabled{cursor:default;opacity:.4}',
108
+ '.dshet-action svg{width:15px;height:15px}',
109
+ '.dshet-action-host{display:inline-flex;align-items:center;justify-content:center}',
110
+ '.dshet-row{position:relative}',
111
+ '.dshet-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
112
+ '.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
113
+ '.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
114
+ '[data-dshet-hidden="1"]{display:none!important}',
115
+ '.dshet-editor{margin:8px 0 4px;padding:12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.28));border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 2px 10px rgba(0,0,0,.06)}',
116
+ '.dshet-editor-title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}',
117
+ '.dshet-editor textarea{width:100%;min-height:84px;max-height:46vh;resize:vertical;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:14px;line-height:22px}',
118
+ '.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
119
+ '.dshet-editor textarea:disabled{opacity:.6}',
120
+ '.dshet-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:19px}',
121
+ '.dshet-warn{margin:8px 0 0;color:var(--dsw-alias-state-warning-primary,#c47b12);font-size:12px;line-height:19px}',
122
+ '.dshet-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
123
+ '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:12px}',
124
+ '.dshet-btn{padding:6px 14px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
125
+ '.dshet-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}',
126
+ '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
127
+ '.dshet-btn:disabled{cursor:default;opacity:.5}',
128
+ '.dshet-btn-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff)}',
129
+ '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,#4d6bfe);filter:brightness(1.08)}',
130
+ '.dshet-notice{margin:4px 0;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.08));color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
131
+ '.dshet-spacer{flex:1 1 auto}',
132
+ '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
133
+ ].join('')
134
+
135
+ const TAG_ID = 'dsh-edit-turn/edit-turn.css'
136
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(TAG_ID) + ']') === null) {
137
+ const tag = document.createElement('style')
138
+ tag.dataset.plugin = NS
139
+ tag.dataset.pluginCss = TAG_ID
140
+ tag.textContent = CSS
141
+ document.head.appendChild(tag)
142
+ }
143
+
144
+ // --- icon -----------------------------------------------------------------
145
+
146
+ // Hand-drawn pencil glyph: body, tip, baseline.
147
+ const ICON_PATHS = [
148
+ 'M11.2 2.4l2.4 2.4',
149
+ 'M3.1 10.5l7.1-7.1 2.4 2.4-7.1 7.1-3.1.7z',
150
+ 'M2.6 13.6h10.8',
151
+ ]
152
+ const ICON_MARKUP =
153
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
154
+ ICON_PATHS.map(
155
+ (d) =>
156
+ '<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
157
+ ).join('') +
158
+ '</svg>'
159
+
160
+ function PencilIcon() {
161
+ return jsx('svg', {
162
+ width: 16,
163
+ height: 16,
164
+ viewBox: '0 0 16 16',
165
+ fill: 'none',
166
+ 'aria-hidden': true,
167
+ children: ICON_PATHS.map((d, index) =>
168
+ jsx('path', { d, stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }, index),
169
+ ),
170
+ })
171
+ }
172
+
173
+ // --- controller -----------------------------------------------------------
174
+
175
+ class EditController {
176
+ constructor(sessionId) {
177
+ this.sessionId = sessionId
178
+ this.listeners = new Set()
179
+ this.inflight = null
180
+ this.animateOnce = false
181
+ this.rowCount = -1
182
+ this.editor = null
183
+ this.editorElement = null
184
+ this.editorDraft = ''
185
+ this.confirming = false
186
+ this.noticeTimer = null
187
+ this.view = Object.freeze({
188
+ hidden: new Map(),
189
+ editable: new Map(),
190
+ editing: null,
191
+ draft: '',
192
+ confirming: false,
193
+ pending: false,
194
+ failure: null,
195
+ notice: null,
196
+ surfaceReady: false,
197
+ loaded: false,
198
+ loadError: false,
199
+ confirmStep: true,
200
+ revision: 0,
201
+ })
202
+ }
203
+
204
+ getSnapshot = () => this.view
205
+
206
+ subscribe = (listener) => {
207
+ this.listeners.add(listener)
208
+ return () => {
209
+ this.listeners.delete(listener)
210
+ }
211
+ }
212
+
213
+ publish(patch) {
214
+ this.view = Object.freeze({ ...this.view, ...patch, revision: this.view.revision + 1 })
215
+ for (const listener of this.listeners) {
216
+ try {
217
+ listener()
218
+ } catch (error) {
219
+ console.error('[dsh-edit-turn] subscriber threw:', error)
220
+ }
221
+ }
222
+ }
223
+
224
+ consumeAnimate() {
225
+ const value = this.animateOnce === true
226
+ this.animateOnce = false
227
+ return value
228
+ }
229
+
230
+ load(force) {
231
+ if (this.inflight !== null) return this.inflight
232
+ if (this.view.loaded && force !== true) return Promise.resolve()
233
+ const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
234
+ const pending = fetch(url, { headers: { accept: 'application/json' } })
235
+ .then(async (res) => {
236
+ const data = await res.json().catch(() => ({}))
237
+ if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
238
+ const hidden = new Map()
239
+ for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
240
+ if (item && typeof item.seq === 'number') hidden.set(item.seq, item.turn)
241
+ }
242
+ const editable = new Map()
243
+ for (const turn of Array.isArray(data.turns) ? data.turns : []) {
244
+ if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
245
+ }
246
+ this.publish({
247
+ hidden,
248
+ editable,
249
+ surfaceReady: true,
250
+ loaded: true,
251
+ loadError: false,
252
+ confirmStep: !(data.config && data.config.confirm === false),
253
+ })
254
+ })
255
+ .catch(() => {
256
+ this.publish({ loadError: true })
257
+ })
258
+ .finally(() => {
259
+ this.inflight = null
260
+ })
261
+ this.inflight = pending
262
+ return pending
263
+ }
264
+
265
+ open(target) {
266
+ this.load()
267
+ this.editor = target
268
+ this.editorDraft = target.text
269
+ this.confirming = false
270
+ this.editorElement = null
271
+ this.publish({ editing: target, draft: target.text, confirming: false, failure: null })
272
+ }
273
+
274
+ close() {
275
+ if (this.view.pending) return
276
+ this.editor = null
277
+ this.editorDraft = ''
278
+ this.confirming = false
279
+ this.editorElement = null
280
+ this.publish({ editing: null, draft: '', confirming: false, failure: null })
281
+ }
282
+
283
+ setDraft(value) {
284
+ this.editorDraft = value
285
+ }
286
+
287
+ review() {
288
+ if (this.editorDraft.trim() === '') {
289
+ this.publish({ failure: 'empty' })
290
+ return
291
+ }
292
+ if (this.view.confirmStep === false) {
293
+ this.confirm()
294
+ return
295
+ }
296
+ this.confirming = true
297
+ this.publish({ confirming: true, draft: this.editorDraft, failure: null })
298
+ }
299
+
300
+ back() {
301
+ if (this.view.pending) return
302
+ this.confirming = false
303
+ this.publish({ confirming: false, failure: null })
304
+ }
305
+
306
+ async confirm() {
307
+ const target = this.editor
308
+ if (target === null || this.view.pending) return
309
+ const text = this.editorDraft
310
+ if (text.trim() === '') {
311
+ this.publish({ failure: 'empty' })
312
+ return
313
+ }
314
+ this.publish({ pending: true, failure: null })
315
+ try {
316
+ const res = await fetch(`${ROUTE_PREFIX}/apply`, {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({
320
+ sessionId: this.sessionId,
321
+ seq: target.seq,
322
+ messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
323
+ text,
324
+ }),
325
+ })
326
+ const data = await res.json().catch(() => ({}))
327
+ if (!res.ok || !data.ok) {
328
+ this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
329
+ return
330
+ }
331
+ const hidden = new Map(this.view.hidden)
332
+ for (const seq of Array.isArray(data.shadowed) ? data.shadowed : []) hidden.set(seq, target.turn)
333
+ const editable = new Map(this.view.editable)
334
+ for (const entry of editable.values()) {
335
+ if (hidden.has(entry.seq)) editable.delete(entry.seq)
336
+ }
337
+ this.animateOnce = true
338
+ this.editor = null
339
+ this.editorDraft = ''
340
+ this.editorElement = null
341
+ this.confirming = false
342
+ this.publish({
343
+ pending: false,
344
+ editing: null,
345
+ confirming: false,
346
+ hidden,
347
+ editable,
348
+ failure: null,
349
+ })
350
+ // The rollback has landed, so the editor is gone with its row: a
351
+ // failed re-run has to be reported somewhere that survives that.
352
+ if (data.promptAccepted === false) this.notify('prompt')
353
+ } catch {
354
+ this.publish({ pending: false, failure: 'generic' })
355
+ }
356
+ }
357
+
358
+ notify(code) {
359
+ if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
360
+ this.publish({ notice: code })
361
+ this.noticeTimer = window.setTimeout(() => {
362
+ this.noticeTimer = null
363
+ this.publish({ notice: null })
364
+ }, 12_000)
365
+ }
366
+
367
+ dismissNotice() {
368
+ if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
369
+ this.noticeTimer = null
370
+ this.publish({ notice: null })
371
+ }
372
+
373
+ dispose() {
374
+ this.listeners.clear()
375
+ if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
376
+ this.noticeTimer = null
377
+ this.editorElement = null
378
+ }
379
+ }
380
+
381
+ // --- row targets ----------------------------------------------------------
382
+
383
+ // The editable target of a Chat view node, or null when the row is not a
384
+ // human prompt the user can rewrite.
385
+ function targetFor(node, editable, hidden) {
386
+ if (!node || node.kind !== 'user') return null
387
+ const data = node.data || {}
388
+ const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
389
+ if (typeof seq !== 'number') return null
390
+ if (hidden.has(seq)) return null
391
+ const entry = editable.get(seq)
392
+ if (!entry) return null
393
+ return {
394
+ seq,
395
+ turn: entry.turn,
396
+ messageId: entry.messageId,
397
+ text: typeof entry.text === 'string' ? entry.text : '',
398
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
399
+ }
400
+ }
401
+
402
+ // Every surface seq a row stands for, so one rollback can hide the rows it
403
+ // shadowed however the host UI chose to group them.
404
+ function seqsFor(node) {
405
+ const data = node.data || {}
406
+ const out = []
407
+ const push = (value) => {
408
+ if (typeof value === 'number' && !out.includes(value)) out.push(value)
409
+ }
410
+ switch (node.kind) {
411
+ case 'user':
412
+ case 'context':
413
+ case 'steering':
414
+ push(data.seq)
415
+ break
416
+ case 'assistant-step':
417
+ push(node.anchorSeq)
418
+ if (data.finalNode) push(data.finalNode.seq)
419
+ break
420
+ case 'tool-call':
421
+ if (data.root) push(data.root.seq)
422
+ break
423
+ case 'turn-tail':
424
+ push(node.anchorSeq)
425
+ if (data.closing && data.closing.finalNode) push(data.closing.finalNode.seq)
426
+ break
427
+ case 'turn-process':
428
+ push(data.answerAnchorSeq)
429
+ break
430
+ default:
431
+ push(node.anchorSeq)
432
+ }
433
+ return out
434
+ }
435
+
436
+ function isRowHidden(hidden, seqs) {
437
+ for (const seq of seqs) {
438
+ if (hidden.has(seq)) return true
439
+ }
440
+ return false
441
+ }
442
+
443
+ // --- dom enhancement ------------------------------------------------------
444
+
445
+ const rowActions = new WeakMap()
446
+
447
+ function setRowHidden(row, hide, animate) {
448
+ if (hide) {
449
+ if (row.dataset.dshetHidden === '1') return
450
+ row.dataset.dshetHidden = '1'
451
+ if (!animate || typeof requestAnimationFrame !== 'function') {
452
+ row.style.display = 'none'
453
+ return
454
+ }
455
+ const height = row.getBoundingClientRect().height
456
+ row.classList.add('dshet-collapsing')
457
+ row.style.height = `${height}px`
458
+ row.style.opacity = '1'
459
+ requestAnimationFrame(() => {
460
+ row.style.height = '0px'
461
+ row.style.opacity = '0'
462
+ row.style.marginTop = '0px'
463
+ row.style.marginBottom = '0px'
464
+ row.style.paddingTop = '0px'
465
+ row.style.paddingBottom = '0px'
466
+ })
467
+ window.setTimeout(() => {
468
+ if (row.dataset.dshetHidden !== '1') return
469
+ row.classList.remove('dshet-collapsing')
470
+ row.style.display = 'none'
471
+ }, 240)
472
+ return
473
+ }
474
+ if (row.dataset.dshetHidden !== '1') return
475
+ delete row.dataset.dshetHidden
476
+ row.classList.remove('dshet-collapsing')
477
+ row.style.display = ''
478
+ row.style.height = ''
479
+ row.style.opacity = ''
480
+ row.style.marginTop = ''
481
+ row.style.marginBottom = ''
482
+ row.style.paddingTop = ''
483
+ row.style.paddingBottom = ''
484
+ }
485
+
486
+ function removeRowAction(row) {
487
+ const entry = rowActions.get(row)
488
+ if (!entry) return
489
+ entry.host.remove()
490
+ rowActions.delete(row)
491
+ }
492
+
493
+ function injectRowAction(row, target, controller, t) {
494
+ const label = t('action.edit')
495
+ let entry = rowActions.get(row)
496
+ if (!entry) {
497
+ const host = document.createElement('span')
498
+ host.className = 'dshet-action-host'
499
+ const button = document.createElement('button')
500
+ button.type = 'button'
501
+ button.className = 'dshet-action dshet-row-action'
502
+ button.innerHTML = ICON_MARKUP
503
+ host.appendChild(button)
504
+ entry = { host, button }
505
+ rowActions.set(row, entry)
506
+ }
507
+ const { host, button } = entry
508
+ if (button.getAttribute('aria-label') !== label) {
509
+ button.setAttribute('aria-label', label)
510
+ button.setAttribute('title', label)
511
+ }
512
+ button.onclick = (event) => {
513
+ event.preventDefault()
514
+ event.stopPropagation()
515
+ controller.open(target)
516
+ }
517
+ const anchor = row.querySelector('[class*="_actions"]')
518
+ if (anchor) {
519
+ host.classList.remove('dshet-floating')
520
+ row.classList.remove('dshet-row')
521
+ if (host.parentElement !== anchor) anchor.appendChild(host)
522
+ } else {
523
+ host.classList.add('dshet-floating')
524
+ row.classList.add('dshet-row')
525
+ if (host.parentElement !== row) row.appendChild(host)
526
+ }
527
+ }
528
+
529
+ function clearEditor() {
530
+ const elements = document.querySelectorAll('.dshet-editor')
531
+ for (const element of elements) element.remove()
532
+ }
533
+
534
+ // The editor is plain DOM on purpose: the host row is a React subtree and
535
+ // injecting a second controlled tree into it would fight its reconciler.
536
+ // The draft is mirrored into the controller on every input event, so a
537
+ // host re-render that wipes the textarea cannot lose what was typed.
538
+ // The re-render marker for the in-place editor.
539
+ //
540
+ // Every field `renderEditor` reads belongs here. A marker that only tracks
541
+ // the target makes the editor a dead end: the confirmation step publishes new
542
+ // state, the marker stays equal, the node is never rebuilt, and the button
543
+ // keeps offering "save" instead of advancing to "confirm" - the editor
544
+ // becomes unclickable from the user's side.
545
+ function editorMarker(target, view) {
546
+ return [
547
+ target.seq,
548
+ view.confirming ? 1 : 0,
549
+ view.pending ? 1 : 0,
550
+ view.failure === null ? '' : view.failure,
551
+ ].join('|')
552
+ }
553
+
554
+ function renderEditor(row, controller, view, t) {
555
+ const target = view.editing
556
+ const marker = editorMarker(target, view)
557
+ const existing = row.querySelector('.dshet-editor')
558
+ if (existing && existing.dataset.dshetFor === marker) return
559
+
560
+ clearEditor()
561
+ const box = document.createElement('div')
562
+ box.className = 'dshet-editor'
563
+ box.dataset.dshetFor = marker
564
+
565
+ const title = document.createElement('p')
566
+ title.className = 'dshet-editor-title'
567
+ title.textContent = view.confirming ? t('confirm.title') : t('editor.title')
568
+ box.appendChild(title)
569
+
570
+ const area = document.createElement('textarea')
571
+ area.rows = 4
572
+ area.value = controller.editorDraft
573
+ area.disabled = view.pending || view.confirming
574
+ area.spellcheck = false
575
+ area.setAttribute('aria-label', t('editor.title'))
576
+ if (!view.confirming) area.placeholder = t('editor.placeholder')
577
+ area.addEventListener('input', () => controller.setDraft(area.value))
578
+ box.appendChild(area)
579
+
580
+ const note = document.createElement('p')
581
+ note.className = 'dshet-note'
582
+ note.textContent = view.confirming ? t('confirm.body') : t('editor.hint')
583
+ box.appendChild(note)
584
+
585
+ if (target.attachments > 0) {
586
+ const warn = document.createElement('p')
587
+ warn.className = 'dshet-warn'
588
+ warn.textContent = t('editor.warn.attachments')
589
+ box.appendChild(warn)
590
+ }
591
+ if (typeof view.failure === 'string' && view.failure !== '') {
592
+ const error = document.createElement('p')
593
+ error.className = 'dshet-error'
594
+ error.setAttribute('role', 'status')
595
+ error.textContent = errorText(t, view.failure)
596
+ box.appendChild(error)
597
+ }
598
+
599
+ const footer = document.createElement('div')
600
+ footer.className = 'dshet-footer'
601
+
602
+ const cancel = document.createElement('button')
603
+ cancel.type = 'button'
604
+ cancel.className = 'dshet-btn'
605
+ cancel.textContent = t('editor.cancel')
606
+ cancel.disabled = view.pending
607
+ cancel.onclick = (event) => {
608
+ event.preventDefault()
609
+ event.stopPropagation()
610
+ if (view.confirming) controller.back()
611
+ else controller.close()
612
+ }
613
+
614
+ const submit = document.createElement('button')
615
+ submit.type = 'button'
616
+ submit.className = 'dshet-btn dshet-btn-primary'
617
+ submit.textContent = view.pending
618
+ ? t('editor.pending')
619
+ : view.confirming
620
+ ? t('editor.review')
621
+ : t('editor.save')
622
+ submit.disabled = view.pending
623
+ submit.onclick = (event) => {
624
+ event.preventDefault()
625
+ event.stopPropagation()
626
+ if (view.confirming) controller.confirm()
627
+ else controller.review()
628
+ }
629
+
630
+ footer.appendChild(cancel)
631
+ footer.appendChild(submit)
632
+ box.appendChild(footer)
633
+
634
+ row.appendChild(box)
635
+ if (!view.confirming && !view.pending) {
636
+ try {
637
+ area.focus()
638
+ area.setSelectionRange(area.value.length, area.value.length)
639
+ } catch {
640
+ /* focus is best-effort */
641
+ }
642
+ }
643
+ }
644
+
645
+ // Ask the host again whenever the transcript gains or loses rows.
646
+ //
647
+ // A change in the number of transcript rows is the signal that the host's
648
+ // editable set may have moved on: a re-run appends a new prompt, which the
649
+ // host will happily let the user edit but this half has never asked about.
650
+ // Without this the plugin only ever worked once per page load - the second
651
+ // edit had no action to click because the editable set was still the one
652
+ // fetched at mount time.
653
+ function syncEditableSet(controller, rowCount) {
654
+ if (rowCount === controller.rowCount) return
655
+ controller.rowCount = rowCount
656
+ controller.load(true)
657
+ }
658
+
659
+ function applyDom(snapshot, view, controller, t) {
660
+ if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
661
+ const animate = controller.consumeAnimate()
662
+ const rows = document.querySelectorAll('[data-chat-flow-key]')
663
+ syncEditableSet(controller, rows.length)
664
+ for (const row of rows) {
665
+ if (!(row instanceof HTMLElement)) continue
666
+ const key = row.getAttribute('data-chat-flow-key')
667
+ if (!key) continue
668
+ const node = snapshot.nodes.get(key)
669
+ if (!node) continue
670
+ const hidden = isRowHidden(view.hidden, seqsFor(node))
671
+ setRowHidden(row, hidden, animate)
672
+ const target = hidden ? null : targetFor(node, view.editable, view.hidden)
673
+ if (target !== null) injectRowAction(row, target, controller, t)
674
+ else removeRowAction(row)
675
+ if (
676
+ !hidden &&
677
+ view.editing !== null &&
678
+ typeof view.editing.seq === 'number' &&
679
+ target !== null &&
680
+ target.seq === view.editing.seq
681
+ ) {
682
+ renderEditor(row, controller, view, t)
683
+ }
684
+ }
685
+ if (view.editing === null) clearEditor()
686
+ }
687
+
688
+ // --- react entries --------------------------------------------------------
689
+
690
+ function OverlayEntry({ useChat, useEditTurn, controller, t }) {
691
+ const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
692
+ const view = useEditTurn((state) => state)
693
+
694
+ react.useEffect(() => {
695
+ controller.load()
696
+ }, [controller])
697
+
698
+ react.useEffect(() => {
699
+ if (snapshot === undefined) return undefined
700
+ let scheduled = false
701
+ const run = () => {
702
+ scheduled = false
703
+ applyDom(snapshot, view, controller, t)
704
+ }
705
+ run()
706
+ const observer = new MutationObserver(() => {
707
+ if (scheduled) return
708
+ scheduled = true
709
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
710
+ else window.setTimeout(run, 16)
711
+ })
712
+ observer.observe(document.body, { childList: true, subtree: true })
713
+ return () => {
714
+ observer.disconnect()
715
+ }
716
+ }, [snapshot, view, controller, t])
717
+
718
+ return jsxs(Fragment, {
719
+ children: [
720
+ typeof view.notice !== 'string' || view.notice === ''
721
+ ? null
722
+ : jsx('div', {
723
+ className: 'dshet-notice',
724
+ role: 'status',
725
+ onClick: () => controller.dismissNotice(),
726
+ children: errorText(t, view.notice),
727
+ }),
728
+ ],
729
+ })
730
+ }
731
+
732
+ // --- plugin ---------------------------------------------------------------
733
+
734
+ function apply(ctx) {
735
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
736
+
737
+ const controllers = new Map()
738
+ const controllerFor = (sessionId) => {
739
+ let controller = controllers.get(sessionId)
740
+ if (controller === undefined) {
741
+ controller = new EditController(sessionId)
742
+ controllers.set(sessionId, controller)
743
+ }
744
+ return controller
745
+ }
746
+ ctx.effect(
747
+ () => () => {
748
+ for (const controller of controllers.values()) controller.dispose()
749
+ controllers.clear()
750
+ },
751
+ 'dsh-edit-turn: per-session controllers',
752
+ )
753
+
754
+ ctx.slots.inject('conversation.input.overlay', () =>
755
+ ctx.slots.register(
756
+ {
757
+ name: 'conversation.input.overlay',
758
+ id: 'edit-turn',
759
+ order: 9,
760
+ locale: NS,
761
+ inject: (sessionId) => ({ hooks: { editTurn: controllerFor(sessionId) }, controller: controllerFor(sessionId) }),
762
+ },
763
+ OverlayEntry,
764
+ ),
765
+ )
766
+ }
767
+
768
+ exports.apply = apply
769
+ exports.inject = ['slots', 'locale']
770
+ exports.PLUGIN_VERSION = PLUGIN_VERSION
771
+ return module.exports
772
+ },
773
+ })