dsh-edit-turn 0.2.0 → 0.2.4

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.
Files changed (4) hide show
  1. package/README.md +432 -64
  2. package/lib/client.js +709 -98
  3. package/lib/index.js +247 -60
  4. package/package.json +6 -2
package/lib/client.js CHANGED
@@ -30,27 +30,25 @@ window.__ModuleLoader__.load({
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
31
 
32
32
  /** Keep in sync with package.json and lib/index.js. */
33
- const PLUGIN_VERSION = '0.2.0'
33
+ const PLUGIN_VERSION = '0.2.4'
34
34
 
35
35
  // --- copy -----------------------------------------------------------------
36
36
 
37
37
  const zh = {
38
- 'action.edit': '编辑这条消息并重跑',
38
+ 'action.edit': '编辑这条消息',
39
39
  'action.edit-reply': '编辑这条回答',
40
40
  'editor.title': '编辑这条消息',
41
41
  'editor.title-reply': '编辑这条回答',
42
- 'editor.placeholder': '修改后保存,会话将回退到这条消息之前并重新跑这一轮。',
42
+ 'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
43
43
  'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
44
- 'editor.save': '保存并重跑',
45
- 'editor.save-reply': '保存替换',
44
+ 'editor.save': '保存',
45
+ 'editor.save-rerun': '保存并重跑',
46
+ 'editor.save-reply': '保存',
46
47
  'editor.review': '确认执行',
47
48
  'editor.cancel': '取消',
48
- 'editor.pending': '正在执行…',
49
+ 'editor.pending': '正在保存…',
49
50
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
50
51
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
51
- 'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
52
- 'editor.hint-reply': '保存后,这条回答会被替换为你写的内容,它之后的内容一并移除。模型会把你写的内容当成自己说过的话,对话可以继续下去。原始会话日志不会改写。',
53
- 'confirm.title': '确认执行?',
54
52
  'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
55
53
  'error.invalid': '请求无效,请刷新后重试。',
56
54
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
@@ -62,29 +60,27 @@ window.__ModuleLoader__.load({
62
60
  'error.forbidden': '请求来源不被允许。',
63
61
  'error.method': '请求方式不被接受,请刷新后重试。',
64
62
  'error.generic': '编辑失败,请重试。',
65
- 'error.prompt': '已回退,但重跑没有启动。请手动发送改写后的内容。',
63
+ 'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
66
64
  'error.reply': '已回退,但替换没有落地。请重试。',
67
65
  'error.empty': '改写后的内容不能为空。',
68
66
  'error.internal': '服务器内部错误,请稍后重试。',
69
67
  }
70
68
 
71
69
  const en = {
72
- 'action.edit': 'Edit this message and re-run',
70
+ 'action.edit': 'Edit this message',
73
71
  'action.edit-reply': 'Edit this reply',
74
72
  'editor.title': 'Edit this message',
75
73
  'editor.title-reply': 'Edit this reply',
76
74
  'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
77
75
  'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
78
- 'editor.save': 'Save and re-run',
79
- 'editor.save-reply': 'Save replacement',
76
+ 'editor.save': 'Save',
77
+ 'editor.save-rerun': 'Save and re-run',
78
+ 'editor.save-reply': 'Save',
80
79
  'editor.review': 'Confirm',
81
80
  'editor.cancel': 'Cancel',
82
81
  'editor.pending': 'Working...',
83
82
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
84
83
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
85
- '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.',
86
- 'editor.hint-reply': 'Saving replaces this reply with your text and removes everything after it. The model goes on treating your words as its own, so the conversation can continue from here. The original session log is never rewritten.',
87
- 'confirm.title': 'Confirm?',
88
84
  'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
89
85
  'error.invalid': 'Invalid request; refresh and try again.',
90
86
  'error.session-not-active': 'This session is not open in DSH; open it first.',
@@ -129,28 +125,77 @@ window.__ModuleLoader__.load({
129
125
  const CSS = [
130
126
  ':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
131
127
  'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
132
- '.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(--dshet-ink-dim);cursor:pointer;transition:background-color .12s,color .12s}',
133
- '.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dshet-ink)}',
128
+ // The pencil sits among the platform's own action icons, so it takes the
129
+ // platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
130
+ // read as a different, "active" icon next to the neutral grey ones. The
131
+ // variable resolves on the action bar (and everywhere above it); the
132
+ // fallback is the value it resolves to in the default theme.
133
+ '.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,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
134
+ '.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dsw-alias-label-primary,var(--dshet-ink))}',
134
135
  '.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
135
136
  '.dshet-action:disabled{cursor:default;opacity:.4}',
136
137
  '.dshet-action svg{width:15px;height:15px}',
137
- '.dshet-action-host{display:inline-flex;align-items:center;justify-content:center}',
138
+ // The reply pencil leads its strip. The host draws the copy button ahead of
139
+ // the slot this entry is rendered into, and the strip lays its children out
140
+ // in DOM order, so in DOM position the pencil can only ever come second -
141
+ // behind the copy button, which is where the user does not look for it.
142
+ // `order` reorders the flex line without touching the host's own tree.
143
+ '.dshet-reply-action{order:-1}',
144
+ // A prompt this plugin rewrote in place: the platform renders no bubble for
145
+ // a replacement message, so this one is ours. Styled from the plugin own
146
+ // namespaced variables, which follow the active theme without borrowing
147
+ // CSS-module hashes that change between builds.
148
+ '.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:16px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;line-height:inherit;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
149
+ '.dshet-revision:empty{display:none}',
150
+ '.dshet-revision{position:relative}',
151
+ // Fallback only: a row with no action bar to keep is displayed away
152
+ // entirely, and then its pencil has nowhere but the bubble. It sits in a
153
+ // gutter the bubble reserves for it, never over the text - an absolutely
154
+ // positioned button inside the bubble used to land on the last line, which
155
+ // is how the rewritten prompt got covered up.
156
+ '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
157
+ '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
158
+ '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
159
+ // A sibling plugin can append its own button (dsh-delete-turn's bin) into
160
+ // the same bar at any time, which left this pencil one slot short of the
161
+ // right end: DOM insertion order can never promise that position, so flex
162
+ // order does. The bar is a flex container (`display:flex`) and every
163
+ // platform item in it is `order:0`; the gutter and floating placements
164
+ // ignore order, being absolutely positioned or in a block box.
165
+ '.dshet-action-host{display:inline-flex;align-items:center;justify-content:center;order:9}',
138
166
  '.dshet-row{position:relative}',
139
167
  '.dshet-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
140
168
  '.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
141
169
  '.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
142
- '[data-dshet-hidden="1"]{display:none!important}',
143
- '.dshet-editor{margin:8px 0 4px;padding:12px;border:1px solid var(--dshet-line);border-radius:12px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink)}',
144
- '.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(--dshet-ink)}',
145
- '.dshet-editor textarea{width:100%;min-height:84px;max-height:46vh;resize:vertical;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:14px;line-height:22px}',
170
+ // A row this plugin rolled back loses its message, not its action strip:
171
+ // the platform's time / copy / delete buttons (and every other plugin's)
172
+ // stay reachable, so only rows with nothing worth keeping go fully away.
173
+ '[data-dshet-hidden="1"]:not([data-dshet-keep-actions]){display:none!important}',
174
+ // One rounded container, the way the platform's own composer box is
175
+ // built: the input is transparent and borderless inside it, the actions
176
+ // sit at its bottom-right, and focus is shown on the container rather
177
+ // than on a second box inside it. The input grows with its text (the
178
+ // render/input paths call `fitEditorHeight`); `display:block` removes the
179
+ // inline-block baseline gap a textarea would otherwise leave.
180
+ // The editor lives OUTSIDE the message row, on a layer of its own pinned
181
+ // to the body: the host re-renders that row whenever it likes, and an
182
+ // input inside a subtree it rebuilds loses its drag selection, its IME
183
+ // composition and its clicks a piece at a time. The layer covers the
184
+ // viewport but only the box itself takes pointer events, and the box is
185
+ // positioned under its row by `placeEditor`.
186
+ '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
187
+ '.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
188
+ '.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
189
+ '.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
190
+ '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:15px;line-height:25px;outline:none}',
146
191
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
147
- '.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
148
192
  '.dshet-editor textarea:disabled{opacity:.65}',
149
- '.dshet-note{margin:8px 0 0;color:var(--dshet-ink-dim);font-size:12px;line-height:19px}',
150
- '.dshet-warn{margin:8px 0 0;color:var(--dsw-alias-state-warning-primary,#a2650a);font-size:12px;line-height:19px}',
151
- '.dshet-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
152
- '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:12px}',
153
- '.dshet-btn{padding:6px 14px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
193
+ '.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
194
+ '.dshet-note{color:var(--dshet-ink-dim)}',
195
+ '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
196
+ '.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
197
+ '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
198
+ '.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
154
199
  '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
155
200
  '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
156
201
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
@@ -208,14 +253,25 @@ window.__ModuleLoader__.load({
208
253
  this.animateOnce = false
209
254
  this.rowCount = -1
210
255
  this.editor = null
256
+ // The live textarea and whether it currently holds the user's focus.
257
+ // Focus is only ever taken on open, or handed back to a REBUILT box
258
+ // when the user was typing in the one that was just replaced - never
259
+ // taken from somewhere else. Rebuilding used to call `focus()`
260
+ // unconditionally, so any host re-render of the row pulled the caret
261
+ // out of whatever the user was typing in (the composer included).
211
262
  this.editorElement = null
263
+ this.editorBox = null
264
+ this.editorRow = null
265
+ this.editorFocus = false
212
266
  this.editorDraft = ''
213
267
  this.confirming = false
214
268
  this.noticeTimer = null
215
269
  this.view = Object.freeze({
216
270
  hidden: new Map(),
271
+ revisions: new Map(),
217
272
  editable: new Map(),
218
273
  replies: new Map(),
274
+ repliesByMessage: new Map(),
219
275
  editing: null,
220
276
  draft: '',
221
277
  confirming: false,
@@ -228,6 +284,7 @@ window.__ModuleLoader__.load({
228
284
  // Matches the host's default: one click applies. Only an explicit
229
285
  // `confirm: true` in the profile turns the second step back on.
230
286
  confirmStep: false,
287
+ rerun: false,
231
288
  revision: 0,
232
289
  })
233
290
  }
@@ -259,7 +316,11 @@ window.__ModuleLoader__.load({
259
316
  }
260
317
 
261
318
  load(force) {
262
- if (this.inflight !== null) return this.inflight
319
+ // A refresh asked for while one is already running has to land *after*
320
+ // it: handing back the in-flight response would answer with a snapshot
321
+ // the host may have taken before a save, wiping out the very state that
322
+ // save just published.
323
+ if (this.inflight !== null) return this.inflight.then(() => this.load(force))
263
324
  if (this.view.loaded && force !== true) return Promise.resolve()
264
325
  const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
265
326
  const pending = fetch(url, { headers: { accept: 'application/json' } })
@@ -267,8 +328,14 @@ window.__ModuleLoader__.load({
267
328
  const data = await res.json().catch(() => ({}))
268
329
  if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
269
330
  const hidden = new Map()
331
+ // seq -> the seq of the message that now stands in its place. A prompt
332
+ // this plugin rewrote travels as a replacement carrier, so its revised
333
+ // text has to be shown exactly where the old message was shown.
334
+ const revisions = new Map()
270
335
  for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
271
- if (item && typeof item.seq === 'number') hidden.set(item.seq, item.turn)
336
+ if (!item || typeof item.seq !== 'number') continue
337
+ hidden.set(item.seq, item.turn)
338
+ if (typeof item.replacement === 'number') revisions.set(item.seq, item.replacement)
272
339
  }
273
340
  const editable = new Map()
274
341
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
@@ -278,17 +345,27 @@ window.__ModuleLoader__.load({
278
345
  // rewritten rather than re-run, so the editor words the consequence
279
346
  // differently.
280
347
  const replies = new Map()
348
+ const repliesByMessage = new Map()
281
349
  for (const reply of Array.isArray(data.replies) ? data.replies : []) {
282
- if (reply && typeof reply.seq === 'number') replies.set(reply.seq, reply)
350
+ if (!reply || typeof reply.seq !== 'number') continue
351
+ replies.set(reply.seq, reply)
352
+ // The official assistant-actions strip hands us a messageId, so
353
+ // replies are indexed by it too.
354
+ if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
283
355
  }
284
356
  this.publish({
285
357
  hidden,
358
+ revisions,
286
359
  editable,
287
360
  replies,
361
+ repliesByMessage,
288
362
  surfaceReady: true,
289
363
  loaded: true,
290
364
  loadError: false,
291
365
  confirmStep: !(data.config && data.config.confirm === false),
366
+ // Off by default: saving writes the revision into the context and waits
367
+ // for the next message rather than spending a model call.
368
+ rerun: Boolean(data.config && data.config.rerun === true),
292
369
  })
293
370
  })
294
371
  .catch(() => {
@@ -302,16 +379,38 @@ window.__ModuleLoader__.load({
302
379
  }
303
380
 
304
381
  open(target) {
382
+ // Pressing the pencil again for the SAME message re-focuses the open
383
+ // box instead of starting over: resetting the draft to the original
384
+ // text silently threw away whatever had been typed.
385
+ const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
386
+ const previous = this.editorElement
305
387
  this.load()
306
388
  this.editor = target
307
- this.editorDraft = target.text
389
+ if (!same) this.editorDraft = target.text
308
390
  this.confirming = false
309
391
  this.editorElement = null
310
- this.publish({ editing: target, draft: target.text, confirming: false, failure: null })
392
+ // The intent to keep the user's caret in this box. It is set when the
393
+ // editor opens and cleared only when the user presses somewhere
394
+ // outside it - never by a blur: the host rebuilding the row (or a
395
+ // temporary disable while saving) removes or disables the node, and
396
+ // treating that as "the user left" dropped the caret for good.
397
+ this.editorFocus = true
398
+ if (same && previous !== null && typeof previous.focus === 'function') {
399
+ try {
400
+ previous.focus()
401
+ } catch {
402
+ /* focus is best-effort */
403
+ }
404
+ }
405
+ this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
311
406
  }
312
407
 
313
408
  close() {
314
409
  if (this.view.pending) return
410
+ this.editorElement = null
411
+ this.editorBox = null
412
+ this.editorRow = null
413
+ this.editorFocus = false
315
414
  this.editor = null
316
415
  this.editorDraft = ''
317
416
  this.confirming = false
@@ -367,12 +466,31 @@ window.__ModuleLoader__.load({
367
466
  this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
368
467
  return
369
468
  }
469
+ const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
370
470
  const hidden = new Map(this.view.hidden)
371
- for (const seq of Array.isArray(data.shadowed) ? data.shadowed : []) hidden.set(seq, target.turn)
471
+ for (const seq of shadowed) hidden.set(seq, target.turn)
472
+ // A rewritten prompt lands as the rollback's replacement carrier, so
473
+ // its revised text has to be shown exactly where the old message was
474
+ // shown - and it has to be shown right away, because the row is hidden
475
+ // in this very pass. A rewritten reply is appended as its own new
476
+ // reply instead, so it gets no standing-in entry here: that text
477
+ // arrives with the refresh below, on a row of its own.
478
+ const standsIn = typeof data.replacementSeq === 'number' && data.kind !== 'reply'
479
+ const revisions = new Map(this.view.revisions)
480
+ if (standsIn) for (const seq of shadowed) revisions.set(seq, data.replacementSeq)
372
481
  const editable = new Map(this.view.editable)
373
482
  for (const entry of editable.values()) {
374
483
  if (hidden.has(entry.seq)) editable.delete(entry.seq)
375
484
  }
485
+ if (standsIn) {
486
+ editable.set(data.replacementSeq, {
487
+ seq: data.replacementSeq,
488
+ turn: target.turn,
489
+ messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
490
+ text,
491
+ attachments: typeof target.attachments === 'number' ? target.attachments : 0,
492
+ })
493
+ }
376
494
  // A reply edit shadows its own reply too, so it leaves the editable
377
495
  // replies here and re-enters through the next /state refresh (the
378
496
  // appended correction is itself an editable reply).
@@ -384,12 +502,16 @@ window.__ModuleLoader__.load({
384
502
  this.editor = null
385
503
  this.editorDraft = ''
386
504
  this.editorElement = null
505
+ this.editorBox = null
506
+ this.editorRow = null
507
+ this.editorFocus = false
387
508
  this.confirming = false
388
509
  this.publish({
389
510
  pending: false,
390
511
  editing: null,
391
512
  confirming: false,
392
513
  hidden,
514
+ revisions,
393
515
  editable,
394
516
  replies,
395
517
  failure: null,
@@ -397,7 +519,14 @@ window.__ModuleLoader__.load({
397
519
  // The rollback has landed, so the editor is gone with its row: a
398
520
  // failed second half has to be reported somewhere that survives that.
399
521
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
400
- if (data.kind !== 'reply' && data.promptAccepted === false) this.notify('prompt')
522
+ if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
523
+ // The view above is complete enough to render, but the host is the
524
+ // authority on what the session now looks like: only its answer knows
525
+ // how many rows the platform decided to draw, and what the appended
526
+ // correction or the re-run actually contains. Without this the client
527
+ // kept the optimistic snapshot until something else happened to move
528
+ // the row count, which for a rewritten message was never.
529
+ await this.load(true)
401
530
  } catch {
402
531
  this.publish({ pending: false, failure: 'generic' })
403
532
  }
@@ -423,6 +552,9 @@ window.__ModuleLoader__.load({
423
552
  if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
424
553
  this.noticeTimer = null
425
554
  this.editorElement = null
555
+ this.editorBox = null
556
+ this.editorRow = null
557
+ this.editorFocus = false
426
558
  }
427
559
  }
428
560
 
@@ -431,34 +563,26 @@ window.__ModuleLoader__.load({
431
563
  // The editable target of a Chat view node, or null when the row stands for
432
564
  // something that cannot be rewritten.
433
565
  //
434
- // Two kinds are offered: a human prompt (rewritten, then the turn re-runs)
435
- // and a model reply (rewritten in place, discarding everything after it).
436
- // A step row can span several surface nodes, and the reply seq sits on
437
- // `finalNode` for an assistant step while a user row carries it on `data`,
438
- // so both candidates are tried rather than assumed.
566
+ // Only human prompts come through here: a reply's entry comes from the
567
+ // official assistant-actions strip instead, which is where the host puts
568
+ // every other action for that row. Injecting one here used to put the pencil
569
+ // in a different place from every other reply action.
439
570
  function targetFor(node, view) {
440
- if (!node) return null
571
+ if (!node || node.kind !== 'user') return null
441
572
  const data = node.data || {}
442
573
  const candidates = []
443
574
  const push = (value) => {
444
575
  if (typeof value === 'number' && !candidates.includes(value)) candidates.push(value)
445
576
  }
446
- if (node.kind === 'user') {
447
- push(data.seq)
448
- push(node.anchorSeq)
449
- } else if (node.kind === 'assistant-step') {
450
- push(data.finalNode && data.finalNode.seq)
451
- push(node.anchorSeq)
452
- } else {
453
- return null
454
- }
577
+ push(data.seq)
578
+ push(node.anchorSeq)
455
579
  for (const seq of candidates) {
456
580
  if (view.hidden.has(seq)) continue
457
- const entry = view.editable.get(seq) || view.replies.get(seq)
581
+ const entry = view.editable.get(seq)
458
582
  if (!entry) continue
459
583
  return {
460
584
  seq,
461
- mode: view.editable.has(seq) ? 'prompt' : 'reply',
585
+ mode: 'prompt',
462
586
  turn: entry.turn,
463
587
  messageId: entry.messageId,
464
588
  text: typeof entry.text === 'string' ? entry.text : '',
@@ -468,6 +592,13 @@ window.__ModuleLoader__.load({
468
592
  return null
469
593
  }
470
594
 
595
+ // The element that carries the turn-tail marker: either the flow-keyed row
596
+ // itself or a wrapper inside it. Its own action strip is a direct child.
597
+ function rootOfTurnTail(row) {
598
+ if (typeof row.matches === 'function' && row.matches('[data-turn-tail]')) return row
599
+ return row.querySelector('[data-turn-tail]')
600
+ }
601
+
471
602
  // Every surface seq a row stands for, so one rollback can hide the rows it
472
603
  // shadowed however the host UI chose to group them.
473
604
  function seqsFor(node) {
@@ -513,10 +644,172 @@ window.__ModuleLoader__.load({
513
644
 
514
645
  const rowActions = new WeakMap()
515
646
 
647
+ // Show a rewritten prompt where its original row is.
648
+ //
649
+ // The bubble is cloned from a real user bubble's classes at runtime - the
650
+ // platform's class names are CSS-module hashes that change between builds, so
651
+ // copying a live one keeps skins and themes working without hardcoding them.
652
+ //
653
+ // Returns whether the row may be hidden now. `false` means this is a user
654
+ // message with a replacement recorded for it but no replacement text on
655
+ // screen - a snapshot that has not caught up yet - and hiding it would leave
656
+ // the transcript with nothing where the message was.
657
+ function renderRevision(row, node, view, hidden, controller, t) {
658
+ let revisionSeq = null
659
+ for (const seq of seqsFor(node)) {
660
+ if (view.revisions.has(seq)) {
661
+ revisionSeq = seq
662
+ break
663
+ }
664
+ }
665
+ const existing = ownRevisionBubble(row)
666
+ if (revisionSeq === null || !hidden) {
667
+ if (existing !== null) existing.remove()
668
+ return true
669
+ }
670
+ const entry = view.editable.get(view.revisions.get(revisionSeq))
671
+ const text = entry && typeof entry.text === 'string' ? entry.text : ''
672
+ if (text === '') {
673
+ if (existing !== null) existing.remove()
674
+ return node.kind !== 'user'
675
+ }
676
+ const bubble = existing === null ? cloneUserBubble() : existing
677
+ if (existing === null) bubble.dataset.dshetRevision = '1'
678
+ placeRevisionBubble(row, bubble)
679
+ if (bubble.textContent !== text) bubble.textContent = text
680
+ // The edit entry goes where every other action of this row lives: the
681
+ // platform's own bar, which the collapse keeps standing, at its right end
682
+ // behind the clock and the copy button - the row the user reads as "this
683
+ // message", so that is where its actions belong. The bubble's gutter is
684
+ // only the fallback for a row with no bar at all: that row is displayed
685
+ // away entirely and the bubble stands before it, so there is nothing to
686
+ // join. Injected after the text assignment so the host is not wiped.
687
+ if (entry) {
688
+ const editTarget = { ...entry, mode: 'prompt' }
689
+ if (row.querySelector('[class*="_actions"]') !== null) {
690
+ injectRowAction(row, editTarget, controller, t)
691
+ removeRowAction(bubble)
692
+ bubble.classList.remove('dshet-revision-action')
693
+ } else {
694
+ injectRowAction(bubble, editTarget, controller, t, { gutter: true })
695
+ removeRowAction(row)
696
+ bubble.classList.add('dshet-revision-action')
697
+ }
698
+ } else {
699
+ removeRowAction(bubble)
700
+ bubble.classList.remove('dshet-revision-action')
701
+ }
702
+ return true
703
+ }
704
+
705
+ // A copy of a live user bubble element, classes and all.
706
+ function cloneUserBubble() {
707
+ const sampleRow = document.querySelector('[data-chat-flow-kind="user"]')
708
+ const sample = sampleRow === null ? null : sampleRow.firstElementChild
709
+ const bubble = document.createElement('div')
710
+ bubble.className = 'dshet-revision'
711
+ return bubble
712
+ }
713
+
714
+ // The standing-in bubble this row was given, wherever the last pass put it.
715
+ //
716
+ // It lives inside the row - the platform's own message, not a sibling of it -
717
+ // but a row that could not keep its action bar is displayed away entirely and
718
+ // takes its children with it, so the bubble is then parked before the row.
719
+ // Both positions have to be recognised or every pass would plant a second one.
720
+ function ownRevisionBubble(row) {
721
+ for (const child of row.children) {
722
+ if (child.classList && child.classList.contains('dshet-revision')) return child
723
+ }
724
+ const previous = row.previousElementSibling
725
+ if (previous !== null && previous.classList && previous.classList.contains('dshet-revision')) {
726
+ return previous
727
+ }
728
+ return null
729
+ }
730
+
731
+ // Plant the bubble where the message it replaces used to be: inside the row,
732
+ // ahead of the node that carries the action bar, so the rewritten prompt reads
733
+ // above the timestamp and the copy button - the order the platform draws a
734
+ // message and its bar in. Planting it after the row put the bar on top of the
735
+ // text, as if the time and the copy belonged to whatever came next.
736
+ //
737
+ // Idempotent, and only ever moves the node when the position is wrong: this
738
+ // runs on every DOM pass, and rewriting the tree the observer watches would
739
+ // never settle.
740
+ function placeRevisionBubble(row, bubble) {
741
+ const strip = row.querySelector('[class*="_actions"]')
742
+ let holder = strip
743
+ while (strip !== null && holder !== null && holder.parentElement !== row) {
744
+ holder = holder.parentElement
745
+ }
746
+ const inside = strip !== null && holder !== null && holder.parentElement === row
747
+ if (!inside && row.parentElement === null) return
748
+ const parent = inside ? row : row.parentElement
749
+ const before = inside ? holder : row
750
+ if (bubble.parentElement !== parent || bubble.nextElementSibling !== before) {
751
+ parent.insertBefore(bubble, before)
752
+ }
753
+ }
754
+
755
+ // Collapse a row's message while leaving its action strip standing.
756
+ //
757
+ // The platform's own time / copy / delete bar - and every other plugin's
758
+ // buttons - live in the same row as the prompt this plugin rolls back.
759
+ // Displaying the row away took them with it, so a rewritten prompt left the
760
+ // user no way to copy, read or delete the original. The row therefore stays:
761
+ // everything that leads to the strip collapses, the strip does not, and the
762
+ // marker attribute tells the stylesheet that the row itself must survive.
763
+ //
764
+ // Structure-agnostic on purpose - the platform's row classes are CSS-module
765
+ // hashes that change between builds, so the walk starts from the strip (whose
766
+ // `_actions` suffix is stable) and hides that strip's ancestors' other
767
+ // children up to the row. Returns false when there is no strip to keep, and
768
+ // false is what tells the caller to hide the whole row instead.
769
+ function collapseRowContent(row, hide) {
770
+ const strip = row.querySelector('[class*="_actions"]')
771
+ if (strip === null) return false
772
+ let node = strip
773
+ while (node !== null && node !== row) {
774
+ const parent = node.parentElement
775
+ if (parent === null) return false
776
+ for (const child of parent.children) {
777
+ if (child === node) continue
778
+ // This plugin's own furniture lives beside the message too: the
779
+ // in-place editor, a floating pencil and the bubble standing in for
780
+ // the rewritten prompt are not message content. The bubble is what
781
+ // the transcript has left of the message once this walk is done -
782
+ // hiding it here would take the text away a second time.
783
+ if (
784
+ child.classList &&
785
+ (child.classList.contains('dshet-editor') ||
786
+ child.classList.contains('dshet-action-host') ||
787
+ child.classList.contains('dshet-revision'))
788
+ ) {
789
+ continue
790
+ }
791
+ if (hide) {
792
+ // Written only when the value changes: this runs on every DOM pass,
793
+ // and an attribute write the observer can see would never settle.
794
+ if (child.style.display !== 'none') child.style.display = 'none'
795
+ if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
796
+ } else if (child.dataset.dshetCollapsed === '1') {
797
+ child.style.display = ''
798
+ delete child.dataset.dshetCollapsed
799
+ }
800
+ }
801
+ node = parent
802
+ }
803
+ return true
804
+ }
805
+
516
806
  function setRowHidden(row, hide, animate) {
517
807
  if (hide) {
808
+ const keepsStrip = collapseRowContent(row, true)
809
+ if (keepsStrip && row.dataset.dshetKeepActions !== '1') row.dataset.dshetKeepActions = '1'
518
810
  if (row.dataset.dshetHidden === '1') return
519
811
  row.dataset.dshetHidden = '1'
812
+ if (keepsStrip) return
520
813
  if (!animate || typeof requestAnimationFrame !== 'function') {
521
814
  row.style.display = 'none'
522
815
  return
@@ -540,8 +833,10 @@ window.__ModuleLoader__.load({
540
833
  }, 240)
541
834
  return
542
835
  }
836
+ collapseRowContent(row, false)
543
837
  if (row.dataset.dshetHidden !== '1') return
544
838
  delete row.dataset.dshetHidden
839
+ if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
545
840
  row.classList.remove('dshet-collapsing')
546
841
  row.style.display = ''
547
842
  row.style.height = ''
@@ -559,7 +854,7 @@ window.__ModuleLoader__.load({
559
854
  rowActions.delete(row)
560
855
  }
561
856
 
562
- function injectRowAction(row, target, controller, t) {
857
+ function injectRowAction(row, target, controller, t, options = {}) {
563
858
  const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
564
859
  let entry = rowActions.get(row)
565
860
  if (!entry) {
@@ -569,25 +864,51 @@ window.__ModuleLoader__.load({
569
864
  button.type = 'button'
570
865
  button.className = 'dshet-action dshet-row-action'
571
866
  button.innerHTML = ICON_MARKUP
867
+ // The marker goes on the BUTTON, not only on its wrapper: the pass that
868
+ // looks for the official strip's entry queries buttons, so a marker one
869
+ // level up made this pencil look like the strip's own and it deleted
870
+ // itself - then the next pass put it back, which reads as flicker.
871
+ if (options.fallback === true) button.dataset.dshetFallback = '1'
572
872
  host.appendChild(button)
573
- entry = { host, button }
873
+ entry = { host, button, target }
874
+ // Wired once, and the action reads the CURRENT target off the entry: the
875
+ // pass runs again on every snapshot, and re-wiring would reset the
876
+ // press guard between a pointerdown and the click it swallows.
877
+ pressable(button, () => controller.open(entry.target))
574
878
  rowActions.set(row, entry)
575
879
  }
880
+ entry.target = target
576
881
  const { host, button } = entry
577
882
  if (button.getAttribute('aria-label') !== label) {
578
883
  button.setAttribute('aria-label', label)
579
884
  button.setAttribute('title', label)
580
885
  }
581
- button.onclick = (event) => {
582
- event.preventDefault()
583
- event.stopPropagation()
584
- controller.open(target)
585
- }
586
- const anchor = row.querySelector('[class*="_actions"]')
886
+ // A caller that already picked the right bar (the turn tail's own strip)
887
+ // hands it over; otherwise the first actions container in the row wins.
888
+ const anchor =
889
+ options.gutter === true ? null : options.anchor ?? row.querySelector('[class*="_actions"]')
587
890
  if (anchor) {
588
891
  host.classList.remove('dshet-floating')
589
892
  row.classList.remove('dshet-row')
590
- if (host.parentElement !== anchor) anchor.appendChild(host)
893
+ // The clock and the usage pill trail the actions in that strip, so a
894
+ // plain append parks an action behind the row's trailing info. Sit after
895
+ // the LAST platform action icon instead - that is where the host seats
896
+ // its own extra actions, and where the user row keeps its pencil.
897
+ const lastAction = lastPlatformAction(anchor)
898
+ // On the second pass this pencil is already `lastAction.nextSibling`, and
899
+ // inserting a node before itself is not a no-op in every engine - skip
900
+ // past ourselves so the position stays put instead of drifting to the end.
901
+ let next = lastAction === null ? null : lastAction.nextSibling
902
+ if (next === host) next = next.nextSibling
903
+ if (host.parentElement !== anchor || host.nextSibling !== next) anchor.insertBefore(host, next)
904
+ } else if (options.gutter === true) {
905
+ // The revision bubble is ours, not the platform's: it has no action bar
906
+ // to join, so the pencil goes into the bubble's own reserved gutter
907
+ // (`.dshet-revision-action`). Never `.dshet-floating` - that rule is
908
+ // positioned against the row, which put the button on top of the text.
909
+ host.classList.remove('dshet-floating')
910
+ row.classList.remove('dshet-row')
911
+ if (host.parentElement !== row) row.appendChild(host)
591
912
  } else {
592
913
  host.classList.add('dshet-floating')
593
914
  row.classList.add('dshet-row')
@@ -595,11 +916,89 @@ window.__ModuleLoader__.load({
595
916
  }
596
917
  }
597
918
 
919
+ // The platform's own action icons all carry a hashed class ending in
920
+ // `_action`; the clock, the usage pill and anything this plugin adds do not
921
+ // (ours are hyphenated). Matching that suffix avoids CSS-module hashes
922
+ // entirely - they change between builds.
923
+ function lastPlatformAction(bar) {
924
+ const children = Array.from(bar.children)
925
+ for (let index = children.length - 1; index >= 0; index -= 1) {
926
+ const child = children[index]
927
+ const className = typeof child.className === 'string' ? child.className : ''
928
+ if (/_action\b/.test(className)) return child
929
+ }
930
+ return null
931
+ }
932
+
598
933
  function clearEditor() {
599
934
  const elements = document.querySelectorAll('.dshet-editor')
600
935
  for (const element of elements) element.remove()
601
936
  }
602
937
 
938
+ // The layer the editor is hosted on. It is created once, hangs off the
939
+ // body - where the host's React tree cannot reach it - and survives every
940
+ // rebuild of the conversation.
941
+ let editorLayer = null
942
+ function ensureEditorLayer() {
943
+ if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
944
+ editorLayer = document.createElement('div')
945
+ editorLayer.className = 'dshet-layer'
946
+ document.body.appendChild(editorLayer)
947
+ return editorLayer
948
+ }
949
+
950
+ // Put the box under the row it belongs to: same width, just below its lower
951
+ // edge. Called on render and whenever the page scrolls or resizes, since
952
+ // the layer is fixed to the viewport.
953
+ function placeEditor(row, box) {
954
+ const layer = ensureEditorLayer()
955
+ if (box.parentElement !== layer) layer.appendChild(box)
956
+ const rect = row.getBoundingClientRect()
957
+ box.style.left = `${Math.round(rect.left)}px`
958
+ box.style.top = `${Math.round(rect.bottom + 6)}px`
959
+ box.style.width = `${Math.round(rect.width)}px`
960
+ }
961
+
962
+ // Grow the textarea to its content, the way a normal input box behaves.
963
+ // Without this it sits at a fixed `rows` height and a two-line edit floats
964
+ // in half an empty box. The stylesheet owns the limits (`min-height`,
965
+ // `max-height`); the element only ever gets the measured height, and the
966
+ // `+2` is the top and bottom border `scrollHeight` does not count.
967
+ function fitEditorHeight(area) {
968
+ area.style.height = 'auto'
969
+ const height = area.scrollHeight
970
+ if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
971
+ }
972
+
973
+ // Wire an action so a host re-render cannot eat the click.
974
+ //
975
+ // The host owns the row and rebuilds it freely - while a turn streams it
976
+ // re-renders constantly - so an injected button can be replaced between
977
+ // mousedown and mouseup, and a click only fires when both land on the same
978
+ // node. That is what "the button does nothing" looks like from outside, and
979
+ // it is unguessable from the state. The action therefore rides on
980
+ // pointerdown; the click path stays for the keyboard, de-duplicated so one
981
+ // press cannot run the action twice.
982
+ function pressable(button, action) {
983
+ let pressed = false
984
+ button.onpointerdown = (event) => {
985
+ if (typeof event.button === 'number' && event.button !== 0) return
986
+ pressed = true
987
+ event.preventDefault()
988
+ event.stopPropagation()
989
+ action()
990
+ }
991
+ button.onclick = (event) => {
992
+ event.preventDefault()
993
+ event.stopPropagation()
994
+ if (pressed) {
995
+ pressed = false
996
+ return
997
+ }
998
+ action()
999
+ }
1000
+ }
1001
+
603
1002
  // The editor is plain DOM on purpose: the host row is a React subtree and
604
1003
  // injecting a second controlled tree into it would fight its reconciler.
605
1004
  // The draft is mirrored into the controller on every input event, so a
@@ -623,49 +1022,98 @@ window.__ModuleLoader__.load({
623
1022
  // Every string the editor shows depends on what is being edited: a prompt is
624
1023
  // re-run, a reply is replaced. Keeping them in one place keeps the two modes
625
1024
  // from drifting apart in wording.
626
- function editorTexts(t, target) {
1025
+ function editorTexts(t, target, rerun) {
627
1026
  const reply = target.mode === 'reply'
628
1027
  return {
629
1028
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
630
1029
  placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
631
- hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
632
1030
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
633
- save: t(reply ? 'editor.save-reply' : 'editor.save'),
1031
+ // Saving writes the revision into the context; only the opt-in config asks
1032
+ // the model again, and the button says which one will happen.
1033
+ save: t(reply ? 'editor.save-reply' : rerun === true ? 'editor.save-rerun' : 'editor.save'),
634
1034
  }
635
1035
  }
636
1036
 
637
1037
  function renderEditor(row, controller, view, t) {
638
1038
  const target = view.editing
639
- const texts = editorTexts(t, target)
1039
+ const texts = editorTexts(t, target, view.rerun)
640
1040
  const marker = editorMarker(target, view)
641
- const existing = row.querySelector('.dshet-editor')
642
- if (existing && existing.dataset.dshetFor === marker) return
1041
+ const existing = controller.editorBox !== null && controller.editorBox.isConnected === true
1042
+ ? controller.editorBox
1043
+ : null
1044
+ if (existing && existing.dataset.dshetFor === marker) {
1045
+ placeEditor(row, existing)
1046
+ return
1047
+ }
1048
+
1049
+ // Focus policy. The host re-renders this row freely, so the box gets
1050
+ // rebuilt; taking focus at that moment is what pulled the caret out of
1051
+ // the composer (or whatever else the user was typing in). Focus is only
1052
+ // taken when the editor was just opened, or handed back to the rebuild
1053
+ // when the user was typing in the box that was just replaced - with the
1054
+ // caret where it was.
1055
+ const previous = controller.editorElement
1056
+ const fresh = previous === null
1057
+ const wasFocused = !fresh && (controller.editorFocus === true || document.activeElement === previous)
1058
+ const caret = wasFocused && typeof previous.selectionStart === 'number'
1059
+ ? [previous.selectionStart, previous.selectionEnd]
1060
+ : null
643
1061
 
644
1062
  clearEditor()
645
1063
  const box = document.createElement('div')
646
1064
  box.className = 'dshet-editor'
647
1065
  box.dataset.dshetFor = marker
648
1066
 
649
- const title = document.createElement('p')
650
- title.className = 'dshet-editor-title'
651
- title.textContent = view.confirming ? t('confirm.title') : texts.title
652
- box.appendChild(title)
653
-
654
1067
  const area = document.createElement('textarea')
655
- area.rows = 4
1068
+ area.rows = 1
656
1069
  area.value = controller.editorDraft
657
1070
  area.disabled = view.pending || view.confirming
658
1071
  area.spellcheck = false
659
1072
  area.setAttribute('aria-label', texts.title)
660
1073
  if (!view.confirming) area.placeholder = texts.placeholder
661
- area.addEventListener('input', () => controller.setDraft(area.value))
1074
+ area.addEventListener('input', (event) => {
1075
+ controller.setDraft(area.value)
1076
+ // Never relayout mid-composition: the IME owns the field until it
1077
+ // commits, and moving it under the candidate window is how a box
1078
+ // "eats" pinyin.
1079
+ if (event.isComposing === true) return
1080
+ fitEditorHeight(area)
1081
+ })
1082
+ area.addEventListener('compositionend', () => fitEditorHeight(area))
1083
+ area.addEventListener('focus', () => {
1084
+ controller.editorFocus = true
1085
+ })
1086
+ // The keyboard of every input box in this app: Enter acts (the composer
1087
+ // sends), Shift+Enter breaks the line, Escape leaves (one step back when
1088
+ // the confirmation step is showing). The guard is the platform's own
1089
+ // (`isComposing` OR keyCode 229): the Enter that commits a 拼音/かな
1090
+ // candidate can arrive with `isComposing === false` and keyCode 229 - and
1091
+ // missing that half is what saved the box on every candidate pick.
1092
+ area.addEventListener('keydown', (event) => {
1093
+ if (event.isComposing === true || event.keyCode === 229) return
1094
+ if (event.key === 'Enter' && event.shiftKey !== true) {
1095
+ event.preventDefault()
1096
+ if (view.confirming) controller.confirm()
1097
+ else controller.review()
1098
+ return
1099
+ }
1100
+ if (event.key === 'Escape') {
1101
+ event.preventDefault()
1102
+ if (view.confirming) controller.back()
1103
+ else controller.close()
1104
+ }
1105
+ })
662
1106
  box.appendChild(area)
663
1107
 
664
- const note = document.createElement('p')
665
- note.className = 'dshet-note'
666
- note.textContent = view.confirming ? t('confirm.body') : texts.hint
667
- box.appendChild(note)
668
-
1108
+ // Only what the user has to know right now: what the replacement will
1109
+ // discard, and what went wrong. The running explanation lives in the
1110
+ // README and in the buttons' tooltips, the way a normal input box does it.
1111
+ if (view.confirming) {
1112
+ const note = document.createElement('p')
1113
+ note.className = 'dshet-note'
1114
+ note.textContent = t('confirm.body')
1115
+ box.appendChild(note)
1116
+ }
669
1117
  if (target.attachments > 0) {
670
1118
  const warn = document.createElement('p')
671
1119
  warn.className = 'dshet-warn'
@@ -687,13 +1135,12 @@ window.__ModuleLoader__.load({
687
1135
  cancel.type = 'button'
688
1136
  cancel.className = 'dshet-btn'
689
1137
  cancel.textContent = t('editor.cancel')
1138
+ cancel.title = `${t('editor.cancel')} (Esc)`
690
1139
  cancel.disabled = view.pending
691
- cancel.onclick = (event) => {
692
- event.preventDefault()
693
- event.stopPropagation()
1140
+ pressable(cancel, () => {
694
1141
  if (view.confirming) controller.back()
695
1142
  else controller.close()
696
- }
1143
+ })
697
1144
 
698
1145
  const submit = document.createElement('button')
699
1146
  submit.type = 'button'
@@ -703,23 +1150,28 @@ window.__ModuleLoader__.load({
703
1150
  : view.confirming
704
1151
  ? t('editor.review')
705
1152
  : texts.save
1153
+ submit.title = `${submit.textContent} (Enter)`
706
1154
  submit.disabled = view.pending
707
- submit.onclick = (event) => {
708
- event.preventDefault()
709
- event.stopPropagation()
1155
+ pressable(submit, () => {
710
1156
  if (view.confirming) controller.confirm()
711
1157
  else controller.review()
712
- }
1158
+ })
713
1159
 
714
1160
  footer.appendChild(cancel)
715
1161
  footer.appendChild(submit)
716
1162
  box.appendChild(footer)
717
1163
 
718
- row.appendChild(box)
719
- if (!view.confirming && !view.pending) {
1164
+ placeEditor(row, box)
1165
+ controller.editorBox = box
1166
+ controller.editorRow = row
1167
+ controller.editorElement = area
1168
+ fitEditorHeight(area)
1169
+ if (!view.confirming && !view.pending && (fresh || wasFocused)) {
720
1170
  try {
721
1171
  area.focus()
722
- area.setSelectionRange(area.value.length, area.value.length)
1172
+ const end = typeof area.value.length === 'number' ? area.value.length : 0
1173
+ const [start, stop] = caret === null ? [end, end] : caret
1174
+ area.setSelectionRange(Math.min(start, end), Math.min(stop, end))
723
1175
  } catch {
724
1176
  /* focus is best-effort */
725
1177
  }
@@ -752,21 +1204,131 @@ window.__ModuleLoader__.load({
752
1204
  const node = snapshot.nodes.get(key)
753
1205
  if (!node) continue
754
1206
  const hidden = isRowHidden(view.hidden, seqsFor(node))
755
- setRowHidden(row, hidden, animate)
756
- const target = hidden ? null : targetFor(node, view)
757
- if (target !== null) injectRowAction(row, target, controller, t)
758
- else removeRowAction(row)
1207
+ // A prompt this plugin rewrote in place is not a discard: the platform keeps
1208
+ // the old row (it keeps transcript rows for messages that left the surface),
1209
+ // and the revision belongs exactly there - not at the tail the carrier's
1210
+ // sequence number would sort to. Our own bubble stands in for it.
1211
+ // Never let a rendering nicety take the whole browser half down: without
1212
+ // this plugin there are no edit entries at all. What it must not do is
1213
+ // take the message down with it either - a failed draw leaves the row
1214
+ // up, so the worst case is the old text showing for another refresh.
1215
+ let canHide = true
1216
+ try {
1217
+ canHide = renderRevision(row, node, view, hidden, controller, t)
1218
+ } catch {
1219
+ canHide = node.kind !== 'user'
1220
+ }
1221
+ const visible = hidden && canHide
1222
+ setRowHidden(row, visible, animate)
1223
+ // A collapsed row's entry is placed by `renderRevision`: the rewritten
1224
+ // prompt stands in for the message and the pencil goes into the bar that
1225
+ // survives alongside it. Tearing that down here would take back out what
1226
+ // that call has only just put there - so the row keeps its entry only
1227
+ // while something of ours is standing in for it. A row that collapsed
1228
+ // with no replacement on screen (a rollback this plugin knows nothing
1229
+ // about) is not editable any more and loses the pencil it had.
1230
+ if (visible) {
1231
+ if (ownRevisionBubble(row) === null) removeRowAction(row)
1232
+ } else {
1233
+ const target = targetFor(node, view)
1234
+ if (target !== null) injectRowAction(row, target, controller, t)
1235
+ else removeRowAction(row)
1236
+ }
1237
+ // The editor anchors to whichever row covers the seq being edited, not to
1238
+ // the row that owns the entry: a reply's entry now lives in the turn-tail
1239
+ // action strip, while its editor still belongs under the reply text.
759
1240
  if (
760
1241
  !hidden &&
761
1242
  view.editing !== null &&
762
1243
  typeof view.editing.seq === 'number' &&
763
- target !== null &&
764
- target.seq === view.editing.seq
1244
+ seqsFor(node).includes(view.editing.seq)
765
1245
  ) {
766
1246
  renderEditor(row, controller, view, t)
767
1247
  }
768
1248
  }
769
- if (view.editing === null) clearEditor()
1249
+ if (view.editing === null) {
1250
+ clearEditor()
1251
+ controller.editorBox = null
1252
+ controller.editorRow = null
1253
+ }
1254
+ }
1255
+
1256
+ // A snapshot this entry can read, or null when the controller cannot be
1257
+ // trusted to have one.
1258
+ //
1259
+ // The slot runtime abdicates an entry that throws during render - permanently
1260
+ // for the life of the page: the cell renders an empty placeholder and this
1261
+ // pencil never comes back, with no error surfaced to the user. One missing
1262
+ // prop therefore has to cost this button alone, so every read below is
1263
+ // guarded and nothing here can throw.
1264
+ function readReplyView(controller) {
1265
+ if (controller === null || controller === undefined || typeof controller.getSnapshot !== 'function') return null
1266
+ const view = controller.getSnapshot()
1267
+ if (view === null || typeof view !== 'object') return null
1268
+ if (!view.repliesByMessage || typeof view.repliesByMessage.get !== 'function') return null
1269
+ if (!view.hidden || typeof view.hidden.has !== 'function') return null
1270
+ return view
1271
+ }
1272
+
1273
+ // The reply edit entry, registered in the host's own assistant-actions strip.
1274
+ //
1275
+ // It has to live there rather than be injected next to the message: the host
1276
+ // renders that strip per assistant message, hands the entry the messageId, and
1277
+ // keeps the platform's own reveal behaviour (always for the latest turn, hover
1278
+ // for older ones). Injecting instead put the pencil in a different place from
1279
+ // where every other action for that row lives, which is what users noticed.
1280
+ function ReplyActionEntry(input) {
1281
+ const props = input !== null && typeof input === 'object' ? input : {}
1282
+ const messageId = props.messageId
1283
+ const controller = props.controller
1284
+ const t = typeof props.t === 'function' ? props.t : null
1285
+ const [view, setView] = react.useState(() => {
1286
+ try {
1287
+ return readReplyView(controller)
1288
+ } catch {
1289
+ return null
1290
+ }
1291
+ })
1292
+ react.useEffect(() => {
1293
+ let unsubscribe = null
1294
+ try {
1295
+ setView(readReplyView(controller))
1296
+ if (controller !== null && controller !== undefined && typeof controller.subscribe === 'function') {
1297
+ unsubscribe = controller.subscribe(() => setView(readReplyView(controller)))
1298
+ }
1299
+ } catch {
1300
+ setView(null)
1301
+ }
1302
+ return () => {
1303
+ if (typeof unsubscribe === 'function') unsubscribe()
1304
+ }
1305
+ }, [controller])
1306
+ const entry = view === null ? undefined : view.repliesByMessage.get(messageId)
1307
+ if (entry === undefined || entry === null || view.hidden.has(entry.seq)) return null
1308
+ const label = t === null ? '' : t('action.edit-reply')
1309
+ const open = (event) => {
1310
+ event.preventDefault()
1311
+ event.stopPropagation()
1312
+ controller.open({
1313
+ seq: entry.seq,
1314
+ mode: 'reply',
1315
+ turn: entry.turn,
1316
+ messageId: entry.messageId,
1317
+ text: typeof entry.text === 'string' ? entry.text : '',
1318
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
1319
+ })
1320
+ }
1321
+ // The config object is always an object: this runtime's `jsx` reads
1322
+ // `config.key` without a null check, so `jsx(PencilIcon, null)` threw and
1323
+ // the boundary retired the entry for the whole page.
1324
+ return jsx('button', {
1325
+ type: 'button',
1326
+ className: 'dshet-action dshet-row-action dshet-reply-action',
1327
+ 'aria-label': label,
1328
+ title: label,
1329
+ onClick: open,
1330
+ children: jsx(PencilIcon, {}),
1331
+ })
770
1332
  }
771
1333
 
772
1334
  // --- react entries --------------------------------------------------------
@@ -779,6 +1341,36 @@ window.__ModuleLoader__.load({
779
1341
  controller.load()
780
1342
  }, [controller])
781
1343
 
1344
+ // Pressing outside the editor is the one signal that means "the user
1345
+ // left". Blur cannot be that signal: a host re-render replaces the node
1346
+ // (blur without a related target) and a save temporarily disables it -
1347
+ // both used to drop the caret for good.
1348
+ react.useEffect(() => {
1349
+ if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
1350
+ return undefined
1351
+ }
1352
+ const onPointerDown = (event) => {
1353
+ const target = event.target
1354
+ const inside = target !== null && target !== undefined && typeof target.closest === 'function'
1355
+ && target.closest('.dshet-editor') !== null
1356
+ if (!inside) controller.editorFocus = false
1357
+ }
1358
+ const keepPlaced = () => {
1359
+ const row = controller.editorRow
1360
+ const box = controller.editorBox
1361
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
1362
+ }
1363
+ document.addEventListener('pointerdown', onPointerDown, true)
1364
+ // Scroll events do not bubble; capture reaches the inner scroller too.
1365
+ document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
1366
+ window.addEventListener('resize', keepPlaced)
1367
+ return () => {
1368
+ document.removeEventListener('pointerdown', onPointerDown, true)
1369
+ document.removeEventListener('scroll', keepPlaced, true)
1370
+ window.removeEventListener('resize', keepPlaced)
1371
+ }
1372
+ }, [controller])
1373
+
782
1374
  react.useEffect(() => {
783
1375
  if (snapshot === undefined) return undefined
784
1376
  let scheduled = false
@@ -835,6 +1427,25 @@ window.__ModuleLoader__.load({
835
1427
  'dsh-edit-turn: per-session controllers',
836
1428
  )
837
1429
 
1430
+ // A reply's edit entry lives in the official assistant-actions strip, one
1431
+ // entry per assistant message. order 5 puts it ahead of the feedback
1432
+ // entries (order 10), so the pencil sits with the leading icons.
1433
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
1434
+ ctx.slots.register(
1435
+ {
1436
+ name: 'conversation.chat.assistant-actions',
1437
+ id: 'edit-turn-reply',
1438
+ order: 5,
1439
+ locale: NS,
1440
+ inject: (sessionId) => ({
1441
+ hooks: { editTurn: controllerFor(sessionId) },
1442
+ controller: controllerFor(sessionId),
1443
+ }),
1444
+ },
1445
+ ReplyActionEntry,
1446
+ ),
1447
+ )
1448
+
838
1449
  ctx.slots.inject('conversation.input.overlay', () =>
839
1450
  ctx.slots.register(
840
1451
  {