dsh-edit-turn 0.2.1 → 0.2.5

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 +418 -62
  2. package/lib/client.js +641 -129
  3. package/lib/index.js +194 -64
  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.1'
33
+ const PLUGIN_VERSION = '0.2.5'
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,12 +253,22 @@ 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(),
219
274
  repliesByMessage: new Map(),
@@ -229,6 +284,7 @@ window.__ModuleLoader__.load({
229
284
  // Matches the host's default: one click applies. Only an explicit
230
285
  // `confirm: true` in the profile turns the second step back on.
231
286
  confirmStep: false,
287
+ rerun: false,
232
288
  revision: 0,
233
289
  })
234
290
  }
@@ -260,7 +316,11 @@ window.__ModuleLoader__.load({
260
316
  }
261
317
 
262
318
  load(force) {
263
- 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))
264
324
  if (this.view.loaded && force !== true) return Promise.resolve()
265
325
  const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
266
326
  const pending = fetch(url, { headers: { accept: 'application/json' } })
@@ -268,8 +328,14 @@ window.__ModuleLoader__.load({
268
328
  const data = await res.json().catch(() => ({}))
269
329
  if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
270
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()
271
335
  for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
272
- 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)
273
339
  }
274
340
  const editable = new Map()
275
341
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
@@ -289,6 +355,7 @@ window.__ModuleLoader__.load({
289
355
  }
290
356
  this.publish({
291
357
  hidden,
358
+ revisions,
292
359
  editable,
293
360
  replies,
294
361
  repliesByMessage,
@@ -296,6 +363,9 @@ window.__ModuleLoader__.load({
296
363
  loaded: true,
297
364
  loadError: false,
298
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),
299
369
  })
300
370
  })
301
371
  .catch(() => {
@@ -309,16 +379,38 @@ window.__ModuleLoader__.load({
309
379
  }
310
380
 
311
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
312
387
  this.load()
313
388
  this.editor = target
314
- this.editorDraft = target.text
389
+ if (!same) this.editorDraft = target.text
315
390
  this.confirming = false
316
391
  this.editorElement = null
317
- 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 })
318
406
  }
319
407
 
320
408
  close() {
321
409
  if (this.view.pending) return
410
+ this.editorElement = null
411
+ this.editorBox = null
412
+ this.editorRow = null
413
+ this.editorFocus = false
322
414
  this.editor = null
323
415
  this.editorDraft = ''
324
416
  this.confirming = false
@@ -374,12 +466,31 @@ window.__ModuleLoader__.load({
374
466
  this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
375
467
  return
376
468
  }
469
+ const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
377
470
  const hidden = new Map(this.view.hidden)
378
- 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)
379
481
  const editable = new Map(this.view.editable)
380
482
  for (const entry of editable.values()) {
381
483
  if (hidden.has(entry.seq)) editable.delete(entry.seq)
382
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
+ }
383
494
  // A reply edit shadows its own reply too, so it leaves the editable
384
495
  // replies here and re-enters through the next /state refresh (the
385
496
  // appended correction is itself an editable reply).
@@ -391,12 +502,16 @@ window.__ModuleLoader__.load({
391
502
  this.editor = null
392
503
  this.editorDraft = ''
393
504
  this.editorElement = null
505
+ this.editorBox = null
506
+ this.editorRow = null
507
+ this.editorFocus = false
394
508
  this.confirming = false
395
509
  this.publish({
396
510
  pending: false,
397
511
  editing: null,
398
512
  confirming: false,
399
513
  hidden,
514
+ revisions,
400
515
  editable,
401
516
  replies,
402
517
  failure: null,
@@ -404,7 +519,14 @@ window.__ModuleLoader__.load({
404
519
  // The rollback has landed, so the editor is gone with its row: a
405
520
  // failed second half has to be reported somewhere that survives that.
406
521
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
407
- 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)
408
530
  } catch {
409
531
  this.publish({ pending: false, failure: 'generic' })
410
532
  }
@@ -430,6 +552,9 @@ window.__ModuleLoader__.load({
430
552
  if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
431
553
  this.noticeTimer = null
432
554
  this.editorElement = null
555
+ this.editorBox = null
556
+ this.editorRow = null
557
+ this.editorFocus = false
433
558
  }
434
559
  }
435
560
 
@@ -519,10 +644,187 @@ window.__ModuleLoader__.load({
519
644
 
520
645
  const rowActions = new WeakMap()
521
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
+
522
806
  function setRowHidden(row, hide, animate) {
807
+ // A turn tail is not a message row. It is the strip a turn's end renders
808
+ // (duration, usage, actions); when the turn behind it is rolled back the
809
+ // strip is an orphan with nothing left to act on. Keeping it, which is
810
+ // right for a message row, stacked one empty strip per edit and pushed
811
+ // the conversation down the page - the report was "every save moves the
812
+ // model's answer further down".
813
+ const isTail = row.getAttribute('data-chat-flow-kind') === 'turn-tail'
523
814
  if (hide) {
524
- if (row.dataset.dshetHidden === '1') return
815
+ const keepsStrip = isTail ? false : collapseRowContent(row, true)
816
+ if (keepsStrip) {
817
+ if (row.dataset.dshetKeepActions !== '1') row.dataset.dshetKeepActions = '1'
818
+ } else if (row.dataset.dshetKeepActions === '1') {
819
+ // Older passes marked this tail as keep-actions; with the rule fixed
820
+ // the attribute has to go, or the row stays visible through it.
821
+ delete row.dataset.dshetKeepActions
822
+ }
823
+ if (row.dataset.dshetHidden === '1') {
824
+ if (keepsStrip || row.style.display === 'none') return
825
+ }
525
826
  row.dataset.dshetHidden = '1'
827
+ if (keepsStrip) return
526
828
  if (!animate || typeof requestAnimationFrame !== 'function') {
527
829
  row.style.display = 'none'
528
830
  return
@@ -546,8 +848,10 @@ window.__ModuleLoader__.load({
546
848
  }, 240)
547
849
  return
548
850
  }
851
+ collapseRowContent(row, false)
549
852
  if (row.dataset.dshetHidden !== '1') return
550
853
  delete row.dataset.dshetHidden
854
+ if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
551
855
  row.classList.remove('dshet-collapsing')
552
856
  row.style.display = ''
553
857
  row.style.height = ''
@@ -581,26 +885,45 @@ window.__ModuleLoader__.load({
581
885
  // itself - then the next pass put it back, which reads as flicker.
582
886
  if (options.fallback === true) button.dataset.dshetFallback = '1'
583
887
  host.appendChild(button)
584
- entry = { host, button }
888
+ entry = { host, button, target }
889
+ // Wired once, and the action reads the CURRENT target off the entry: the
890
+ // pass runs again on every snapshot, and re-wiring would reset the
891
+ // press guard between a pointerdown and the click it swallows.
892
+ pressable(button, () => controller.open(entry.target))
585
893
  rowActions.set(row, entry)
586
894
  }
895
+ entry.target = target
587
896
  const { host, button } = entry
588
897
  if (button.getAttribute('aria-label') !== label) {
589
898
  button.setAttribute('aria-label', label)
590
899
  button.setAttribute('title', label)
591
900
  }
592
- button.onclick = (event) => {
593
- event.preventDefault()
594
- event.stopPropagation()
595
- controller.open(target)
596
- }
597
901
  // A caller that already picked the right bar (the turn tail's own strip)
598
902
  // hands it over; otherwise the first actions container in the row wins.
599
- const anchor = options.anchor ?? row.querySelector('[class*="_actions"]')
903
+ const anchor =
904
+ options.gutter === true ? null : options.anchor ?? row.querySelector('[class*="_actions"]')
600
905
  if (anchor) {
601
906
  host.classList.remove('dshet-floating')
602
907
  row.classList.remove('dshet-row')
603
- if (host.parentElement !== anchor) anchor.appendChild(host)
908
+ // The clock and the usage pill trail the actions in that strip, so a
909
+ // plain append parks an action behind the row's trailing info. Sit after
910
+ // the LAST platform action icon instead - that is where the host seats
911
+ // its own extra actions, and where the user row keeps its pencil.
912
+ const lastAction = lastPlatformAction(anchor)
913
+ // On the second pass this pencil is already `lastAction.nextSibling`, and
914
+ // inserting a node before itself is not a no-op in every engine - skip
915
+ // past ourselves so the position stays put instead of drifting to the end.
916
+ let next = lastAction === null ? null : lastAction.nextSibling
917
+ if (next === host) next = next.nextSibling
918
+ if (host.parentElement !== anchor || host.nextSibling !== next) anchor.insertBefore(host, next)
919
+ } else if (options.gutter === true) {
920
+ // The revision bubble is ours, not the platform's: it has no action bar
921
+ // to join, so the pencil goes into the bubble's own reserved gutter
922
+ // (`.dshet-revision-action`). Never `.dshet-floating` - that rule is
923
+ // positioned against the row, which put the button on top of the text.
924
+ host.classList.remove('dshet-floating')
925
+ row.classList.remove('dshet-row')
926
+ if (host.parentElement !== row) row.appendChild(host)
604
927
  } else {
605
928
  host.classList.add('dshet-floating')
606
929
  row.classList.add('dshet-row')
@@ -608,11 +931,89 @@ window.__ModuleLoader__.load({
608
931
  }
609
932
  }
610
933
 
934
+ // The platform's own action icons all carry a hashed class ending in
935
+ // `_action`; the clock, the usage pill and anything this plugin adds do not
936
+ // (ours are hyphenated). Matching that suffix avoids CSS-module hashes
937
+ // entirely - they change between builds.
938
+ function lastPlatformAction(bar) {
939
+ const children = Array.from(bar.children)
940
+ for (let index = children.length - 1; index >= 0; index -= 1) {
941
+ const child = children[index]
942
+ const className = typeof child.className === 'string' ? child.className : ''
943
+ if (/_action\b/.test(className)) return child
944
+ }
945
+ return null
946
+ }
947
+
611
948
  function clearEditor() {
612
949
  const elements = document.querySelectorAll('.dshet-editor')
613
950
  for (const element of elements) element.remove()
614
951
  }
615
952
 
953
+ // The layer the editor is hosted on. It is created once, hangs off the
954
+ // body - where the host's React tree cannot reach it - and survives every
955
+ // rebuild of the conversation.
956
+ let editorLayer = null
957
+ function ensureEditorLayer() {
958
+ if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
959
+ editorLayer = document.createElement('div')
960
+ editorLayer.className = 'dshet-layer'
961
+ document.body.appendChild(editorLayer)
962
+ return editorLayer
963
+ }
964
+
965
+ // Put the box under the row it belongs to: same width, just below its lower
966
+ // edge. Called on render and whenever the page scrolls or resizes, since
967
+ // the layer is fixed to the viewport.
968
+ function placeEditor(row, box) {
969
+ const layer = ensureEditorLayer()
970
+ if (box.parentElement !== layer) layer.appendChild(box)
971
+ const rect = row.getBoundingClientRect()
972
+ box.style.left = `${Math.round(rect.left)}px`
973
+ box.style.top = `${Math.round(rect.bottom + 6)}px`
974
+ box.style.width = `${Math.round(rect.width)}px`
975
+ }
976
+
977
+ // Grow the textarea to its content, the way a normal input box behaves.
978
+ // Without this it sits at a fixed `rows` height and a two-line edit floats
979
+ // in half an empty box. The stylesheet owns the limits (`min-height`,
980
+ // `max-height`); the element only ever gets the measured height, and the
981
+ // `+2` is the top and bottom border `scrollHeight` does not count.
982
+ function fitEditorHeight(area) {
983
+ area.style.height = 'auto'
984
+ const height = area.scrollHeight
985
+ if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
986
+ }
987
+
988
+ // Wire an action so a host re-render cannot eat the click.
989
+ //
990
+ // The host owns the row and rebuilds it freely - while a turn streams it
991
+ // re-renders constantly - so an injected button can be replaced between
992
+ // mousedown and mouseup, and a click only fires when both land on the same
993
+ // node. That is what "the button does nothing" looks like from outside, and
994
+ // it is unguessable from the state. The action therefore rides on
995
+ // pointerdown; the click path stays for the keyboard, de-duplicated so one
996
+ // press cannot run the action twice.
997
+ function pressable(button, action) {
998
+ let pressed = false
999
+ button.onpointerdown = (event) => {
1000
+ if (typeof event.button === 'number' && event.button !== 0) return
1001
+ pressed = true
1002
+ event.preventDefault()
1003
+ event.stopPropagation()
1004
+ action()
1005
+ }
1006
+ button.onclick = (event) => {
1007
+ event.preventDefault()
1008
+ event.stopPropagation()
1009
+ if (pressed) {
1010
+ pressed = false
1011
+ return
1012
+ }
1013
+ action()
1014
+ }
1015
+ }
1016
+
616
1017
  // The editor is plain DOM on purpose: the host row is a React subtree and
617
1018
  // injecting a second controlled tree into it would fight its reconciler.
618
1019
  // The draft is mirrored into the controller on every input event, so a
@@ -636,49 +1037,98 @@ window.__ModuleLoader__.load({
636
1037
  // Every string the editor shows depends on what is being edited: a prompt is
637
1038
  // re-run, a reply is replaced. Keeping them in one place keeps the two modes
638
1039
  // from drifting apart in wording.
639
- function editorTexts(t, target) {
1040
+ function editorTexts(t, target, rerun) {
640
1041
  const reply = target.mode === 'reply'
641
1042
  return {
642
1043
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
643
1044
  placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
644
- hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
645
1045
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
646
- save: t(reply ? 'editor.save-reply' : 'editor.save'),
1046
+ // Saving writes the revision into the context; only the opt-in config asks
1047
+ // the model again, and the button says which one will happen.
1048
+ save: t(reply ? 'editor.save-reply' : rerun === true ? 'editor.save-rerun' : 'editor.save'),
647
1049
  }
648
1050
  }
649
1051
 
650
1052
  function renderEditor(row, controller, view, t) {
651
1053
  const target = view.editing
652
- const texts = editorTexts(t, target)
1054
+ const texts = editorTexts(t, target, view.rerun)
653
1055
  const marker = editorMarker(target, view)
654
- const existing = row.querySelector('.dshet-editor')
655
- if (existing && existing.dataset.dshetFor === marker) return
1056
+ const existing = controller.editorBox !== null && controller.editorBox.isConnected === true
1057
+ ? controller.editorBox
1058
+ : null
1059
+ if (existing && existing.dataset.dshetFor === marker) {
1060
+ placeEditor(row, existing)
1061
+ return
1062
+ }
1063
+
1064
+ // Focus policy. The host re-renders this row freely, so the box gets
1065
+ // rebuilt; taking focus at that moment is what pulled the caret out of
1066
+ // the composer (or whatever else the user was typing in). Focus is only
1067
+ // taken when the editor was just opened, or handed back to the rebuild
1068
+ // when the user was typing in the box that was just replaced - with the
1069
+ // caret where it was.
1070
+ const previous = controller.editorElement
1071
+ const fresh = previous === null
1072
+ const wasFocused = !fresh && (controller.editorFocus === true || document.activeElement === previous)
1073
+ const caret = wasFocused && typeof previous.selectionStart === 'number'
1074
+ ? [previous.selectionStart, previous.selectionEnd]
1075
+ : null
656
1076
 
657
1077
  clearEditor()
658
1078
  const box = document.createElement('div')
659
1079
  box.className = 'dshet-editor'
660
1080
  box.dataset.dshetFor = marker
661
1081
 
662
- const title = document.createElement('p')
663
- title.className = 'dshet-editor-title'
664
- title.textContent = view.confirming ? t('confirm.title') : texts.title
665
- box.appendChild(title)
666
-
667
1082
  const area = document.createElement('textarea')
668
- area.rows = 4
1083
+ area.rows = 1
669
1084
  area.value = controller.editorDraft
670
1085
  area.disabled = view.pending || view.confirming
671
1086
  area.spellcheck = false
672
1087
  area.setAttribute('aria-label', texts.title)
673
1088
  if (!view.confirming) area.placeholder = texts.placeholder
674
- area.addEventListener('input', () => controller.setDraft(area.value))
1089
+ area.addEventListener('input', (event) => {
1090
+ controller.setDraft(area.value)
1091
+ // Never relayout mid-composition: the IME owns the field until it
1092
+ // commits, and moving it under the candidate window is how a box
1093
+ // "eats" pinyin.
1094
+ if (event.isComposing === true) return
1095
+ fitEditorHeight(area)
1096
+ })
1097
+ area.addEventListener('compositionend', () => fitEditorHeight(area))
1098
+ area.addEventListener('focus', () => {
1099
+ controller.editorFocus = true
1100
+ })
1101
+ // The keyboard of every input box in this app: Enter acts (the composer
1102
+ // sends), Shift+Enter breaks the line, Escape leaves (one step back when
1103
+ // the confirmation step is showing). The guard is the platform's own
1104
+ // (`isComposing` OR keyCode 229): the Enter that commits a 拼音/かな
1105
+ // candidate can arrive with `isComposing === false` and keyCode 229 - and
1106
+ // missing that half is what saved the box on every candidate pick.
1107
+ area.addEventListener('keydown', (event) => {
1108
+ if (event.isComposing === true || event.keyCode === 229) return
1109
+ if (event.key === 'Enter' && event.shiftKey !== true) {
1110
+ event.preventDefault()
1111
+ if (view.confirming) controller.confirm()
1112
+ else controller.review()
1113
+ return
1114
+ }
1115
+ if (event.key === 'Escape') {
1116
+ event.preventDefault()
1117
+ if (view.confirming) controller.back()
1118
+ else controller.close()
1119
+ }
1120
+ })
675
1121
  box.appendChild(area)
676
1122
 
677
- const note = document.createElement('p')
678
- note.className = 'dshet-note'
679
- note.textContent = view.confirming ? t('confirm.body') : texts.hint
680
- box.appendChild(note)
681
-
1123
+ // Only what the user has to know right now: what the replacement will
1124
+ // discard, and what went wrong. The running explanation lives in the
1125
+ // README and in the buttons' tooltips, the way a normal input box does it.
1126
+ if (view.confirming) {
1127
+ const note = document.createElement('p')
1128
+ note.className = 'dshet-note'
1129
+ note.textContent = t('confirm.body')
1130
+ box.appendChild(note)
1131
+ }
682
1132
  if (target.attachments > 0) {
683
1133
  const warn = document.createElement('p')
684
1134
  warn.className = 'dshet-warn'
@@ -700,13 +1150,12 @@ window.__ModuleLoader__.load({
700
1150
  cancel.type = 'button'
701
1151
  cancel.className = 'dshet-btn'
702
1152
  cancel.textContent = t('editor.cancel')
1153
+ cancel.title = `${t('editor.cancel')} (Esc)`
703
1154
  cancel.disabled = view.pending
704
- cancel.onclick = (event) => {
705
- event.preventDefault()
706
- event.stopPropagation()
1155
+ pressable(cancel, () => {
707
1156
  if (view.confirming) controller.back()
708
1157
  else controller.close()
709
- }
1158
+ })
710
1159
 
711
1160
  const submit = document.createElement('button')
712
1161
  submit.type = 'button'
@@ -716,23 +1165,28 @@ window.__ModuleLoader__.load({
716
1165
  : view.confirming
717
1166
  ? t('editor.review')
718
1167
  : texts.save
1168
+ submit.title = `${submit.textContent} (Enter)`
719
1169
  submit.disabled = view.pending
720
- submit.onclick = (event) => {
721
- event.preventDefault()
722
- event.stopPropagation()
1170
+ pressable(submit, () => {
723
1171
  if (view.confirming) controller.confirm()
724
1172
  else controller.review()
725
- }
1173
+ })
726
1174
 
727
1175
  footer.appendChild(cancel)
728
1176
  footer.appendChild(submit)
729
1177
  box.appendChild(footer)
730
1178
 
731
- row.appendChild(box)
732
- if (!view.confirming && !view.pending) {
1179
+ placeEditor(row, box)
1180
+ controller.editorBox = box
1181
+ controller.editorRow = row
1182
+ controller.editorElement = area
1183
+ fitEditorHeight(area)
1184
+ if (!view.confirming && !view.pending && (fresh || wasFocused)) {
733
1185
  try {
734
1186
  area.focus()
735
- area.setSelectionRange(area.value.length, area.value.length)
1187
+ const end = typeof area.value.length === 'number' ? area.value.length : 0
1188
+ const [start, stop] = caret === null ? [end, end] : caret
1189
+ area.setSelectionRange(Math.min(start, end), Math.min(stop, end))
736
1190
  } catch {
737
1191
  /* focus is best-effort */
738
1192
  }
@@ -765,55 +1219,35 @@ window.__ModuleLoader__.load({
765
1219
  const node = snapshot.nodes.get(key)
766
1220
  if (!node) continue
767
1221
  const hidden = isRowHidden(view.hidden, seqsFor(node))
768
- setRowHidden(row, hidden, animate)
769
- const target = hidden ? null : targetFor(node, view)
770
- if (target !== null) {
771
- injectRowAction(row, target, controller, t)
772
- } else if (!hidden && node.kind === 'turn-tail') {
773
- // A reply's entry belongs in the host's own action strip, and that is
774
- // where it is registered. A slot entry that never renders leaves no
775
- // trace at all, though - no error, no gap - so the same pencil is also
776
- // offered through the turn tail's own strip, and only while that strip
777
- // does not already carry one. Both land in the platform's own bar, so
778
- // the position is identical either way.
779
- const covered = seqsFor(node).find((seq) => view.replies.has(seq) && !view.hidden.has(seq))
780
- // The strip lives inside the turn-tail root (`data-turn-tail`) as its
781
- // LAST direct child, while the row carrying the flow key may be a
782
- // wrapper around that root. Bounding the search by the tail root keeps
783
- // the tool-call bars inside the tail out of reach: those unmount as a
784
- // call expands, so a pencil parked there blinks, and it is not where
785
- // the reply's actions live anyway.
786
- const tailRoot = rootOfTurnTail(row)
787
- const bar =
788
- covered === undefined || tailRoot === null
789
- ? undefined
790
- : Array.from(tailRoot.children)
791
- .reverse()
792
- .find((child) => typeof child.className === 'string' && child.className.includes('_actions'))
793
- const slotPencil = bar === undefined ? null : bar.querySelector('.dshet-row-action:not([data-dshet-fallback])')
794
- if (covered !== undefined && bar !== undefined && slotPencil === null) {
795
- const entry = view.replies.get(covered)
796
- injectRowAction(
797
- row,
798
- {
799
- seq: covered,
800
- mode: 'reply',
801
- turn: entry.turn,
802
- messageId: entry.messageId,
803
- text: typeof entry.text === 'string' ? entry.text : '',
804
- attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
805
- },
806
- controller,
807
- t,
808
- { fallback: true, anchor: bar },
809
- )
810
- } else {
811
- // Either the strip's own entry is already there, or this row holds
812
- // nothing editable: any spare pencil of ours has to go.
813
- removeRowAction(row)
814
- }
1222
+ // A prompt this plugin rewrote in place is not a discard: the platform keeps
1223
+ // the old row (it keeps transcript rows for messages that left the surface),
1224
+ // and the revision belongs exactly there - not at the tail the carrier's
1225
+ // sequence number would sort to. Our own bubble stands in for it.
1226
+ // Never let a rendering nicety take the whole browser half down: without
1227
+ // this plugin there are no edit entries at all. What it must not do is
1228
+ // take the message down with it either - a failed draw leaves the row
1229
+ // up, so the worst case is the old text showing for another refresh.
1230
+ let canHide = true
1231
+ try {
1232
+ canHide = renderRevision(row, node, view, hidden, controller, t)
1233
+ } catch {
1234
+ canHide = node.kind !== 'user'
1235
+ }
1236
+ const visible = hidden && canHide
1237
+ setRowHidden(row, visible, animate)
1238
+ // A collapsed row's entry is placed by `renderRevision`: the rewritten
1239
+ // prompt stands in for the message and the pencil goes into the bar that
1240
+ // survives alongside it. Tearing that down here would take back out what
1241
+ // that call has only just put there - so the row keeps its entry only
1242
+ // while something of ours is standing in for it. A row that collapsed
1243
+ // with no replacement on screen (a rollback this plugin knows nothing
1244
+ // about) is not editable any more and loses the pencil it had.
1245
+ if (visible) {
1246
+ if (ownRevisionBubble(row) === null) removeRowAction(row)
815
1247
  } else {
816
- removeRowAction(row)
1248
+ const target = targetFor(node, view)
1249
+ if (target !== null) injectRowAction(row, target, controller, t)
1250
+ else removeRowAction(row)
817
1251
  }
818
1252
  // The editor anchors to whichever row covers the seq being edited, not to
819
1253
  // the row that owns the entry: a reply's entry now lives in the turn-tail
@@ -827,7 +1261,28 @@ window.__ModuleLoader__.load({
827
1261
  renderEditor(row, controller, view, t)
828
1262
  }
829
1263
  }
830
- if (view.editing === null) clearEditor()
1264
+ if (view.editing === null) {
1265
+ clearEditor()
1266
+ controller.editorBox = null
1267
+ controller.editorRow = null
1268
+ }
1269
+ }
1270
+
1271
+ // A snapshot this entry can read, or null when the controller cannot be
1272
+ // trusted to have one.
1273
+ //
1274
+ // The slot runtime abdicates an entry that throws during render - permanently
1275
+ // for the life of the page: the cell renders an empty placeholder and this
1276
+ // pencil never comes back, with no error surfaced to the user. One missing
1277
+ // prop therefore has to cost this button alone, so every read below is
1278
+ // guarded and nothing here can throw.
1279
+ function readReplyView(controller) {
1280
+ if (controller === null || controller === undefined || typeof controller.getSnapshot !== 'function') return null
1281
+ const view = controller.getSnapshot()
1282
+ if (view === null || typeof view !== 'object') return null
1283
+ if (!view.repliesByMessage || typeof view.repliesByMessage.get !== 'function') return null
1284
+ if (!view.hidden || typeof view.hidden.has !== 'function') return null
1285
+ return view
831
1286
  }
832
1287
 
833
1288
  // The reply edit entry, registered in the host's own assistant-actions strip.
@@ -837,11 +1292,35 @@ window.__ModuleLoader__.load({
837
1292
  // keeps the platform's own reveal behaviour (always for the latest turn, hover
838
1293
  // for older ones). Injecting instead put the pencil in a different place from
839
1294
  // where every other action for that row lives, which is what users noticed.
840
- function ReplyActionEntry({ messageId, controller, useEditTurn, t }) {
841
- const view = useEditTurn((state) => state)
842
- const entry = view.repliesByMessage.get(messageId)
843
- if (entry === undefined || view.hidden.has(entry.seq)) return null
844
- const label = t('action.edit-reply')
1295
+ function ReplyActionEntry(input) {
1296
+ const props = input !== null && typeof input === 'object' ? input : {}
1297
+ const messageId = props.messageId
1298
+ const controller = props.controller
1299
+ const t = typeof props.t === 'function' ? props.t : null
1300
+ const [view, setView] = react.useState(() => {
1301
+ try {
1302
+ return readReplyView(controller)
1303
+ } catch {
1304
+ return null
1305
+ }
1306
+ })
1307
+ react.useEffect(() => {
1308
+ let unsubscribe = null
1309
+ try {
1310
+ setView(readReplyView(controller))
1311
+ if (controller !== null && controller !== undefined && typeof controller.subscribe === 'function') {
1312
+ unsubscribe = controller.subscribe(() => setView(readReplyView(controller)))
1313
+ }
1314
+ } catch {
1315
+ setView(null)
1316
+ }
1317
+ return () => {
1318
+ if (typeof unsubscribe === 'function') unsubscribe()
1319
+ }
1320
+ }, [controller])
1321
+ const entry = view === null ? undefined : view.repliesByMessage.get(messageId)
1322
+ if (entry === undefined || entry === null || view.hidden.has(entry.seq)) return null
1323
+ const label = t === null ? '' : t('action.edit-reply')
845
1324
  const open = (event) => {
846
1325
  event.preventDefault()
847
1326
  event.stopPropagation()
@@ -854,13 +1333,16 @@ window.__ModuleLoader__.load({
854
1333
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
855
1334
  })
856
1335
  }
1336
+ // The config object is always an object: this runtime's `jsx` reads
1337
+ // `config.key` without a null check, so `jsx(PencilIcon, null)` threw and
1338
+ // the boundary retired the entry for the whole page.
857
1339
  return jsx('button', {
858
1340
  type: 'button',
859
- className: 'dshet-action dshet-row-action',
1341
+ className: 'dshet-action dshet-row-action dshet-reply-action',
860
1342
  'aria-label': label,
861
1343
  title: label,
862
1344
  onClick: open,
863
- children: jsx(PencilIcon, null),
1345
+ children: jsx(PencilIcon, {}),
864
1346
  })
865
1347
  }
866
1348
 
@@ -874,6 +1356,36 @@ window.__ModuleLoader__.load({
874
1356
  controller.load()
875
1357
  }, [controller])
876
1358
 
1359
+ // Pressing outside the editor is the one signal that means "the user
1360
+ // left". Blur cannot be that signal: a host re-render replaces the node
1361
+ // (blur without a related target) and a save temporarily disables it -
1362
+ // both used to drop the caret for good.
1363
+ react.useEffect(() => {
1364
+ if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
1365
+ return undefined
1366
+ }
1367
+ const onPointerDown = (event) => {
1368
+ const target = event.target
1369
+ const inside = target !== null && target !== undefined && typeof target.closest === 'function'
1370
+ && target.closest('.dshet-editor') !== null
1371
+ if (!inside) controller.editorFocus = false
1372
+ }
1373
+ const keepPlaced = () => {
1374
+ const row = controller.editorRow
1375
+ const box = controller.editorBox
1376
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
1377
+ }
1378
+ document.addEventListener('pointerdown', onPointerDown, true)
1379
+ // Scroll events do not bubble; capture reaches the inner scroller too.
1380
+ document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
1381
+ window.addEventListener('resize', keepPlaced)
1382
+ return () => {
1383
+ document.removeEventListener('pointerdown', onPointerDown, true)
1384
+ document.removeEventListener('scroll', keepPlaced, true)
1385
+ window.removeEventListener('resize', keepPlaced)
1386
+ }
1387
+ }, [controller])
1388
+
877
1389
  react.useEffect(() => {
878
1390
  if (snapshot === undefined) return undefined
879
1391
  let scheduled = false