dsh-edit-turn 0.2.1 → 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 +413 -62
  2. package/lib/client.js +625 -128
  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.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,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,172 @@ 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) {
523
807
  if (hide) {
808
+ const keepsStrip = collapseRowContent(row, true)
809
+ if (keepsStrip && row.dataset.dshetKeepActions !== '1') row.dataset.dshetKeepActions = '1'
524
810
  if (row.dataset.dshetHidden === '1') return
525
811
  row.dataset.dshetHidden = '1'
812
+ if (keepsStrip) return
526
813
  if (!animate || typeof requestAnimationFrame !== 'function') {
527
814
  row.style.display = 'none'
528
815
  return
@@ -546,8 +833,10 @@ window.__ModuleLoader__.load({
546
833
  }, 240)
547
834
  return
548
835
  }
836
+ collapseRowContent(row, false)
549
837
  if (row.dataset.dshetHidden !== '1') return
550
838
  delete row.dataset.dshetHidden
839
+ if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
551
840
  row.classList.remove('dshet-collapsing')
552
841
  row.style.display = ''
553
842
  row.style.height = ''
@@ -581,26 +870,45 @@ window.__ModuleLoader__.load({
581
870
  // itself - then the next pass put it back, which reads as flicker.
582
871
  if (options.fallback === true) button.dataset.dshetFallback = '1'
583
872
  host.appendChild(button)
584
- 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))
585
878
  rowActions.set(row, entry)
586
879
  }
880
+ entry.target = target
587
881
  const { host, button } = entry
588
882
  if (button.getAttribute('aria-label') !== label) {
589
883
  button.setAttribute('aria-label', label)
590
884
  button.setAttribute('title', label)
591
885
  }
592
- button.onclick = (event) => {
593
- event.preventDefault()
594
- event.stopPropagation()
595
- controller.open(target)
596
- }
597
886
  // A caller that already picked the right bar (the turn tail's own strip)
598
887
  // hands it over; otherwise the first actions container in the row wins.
599
- const anchor = options.anchor ?? row.querySelector('[class*="_actions"]')
888
+ const anchor =
889
+ options.gutter === true ? null : options.anchor ?? row.querySelector('[class*="_actions"]')
600
890
  if (anchor) {
601
891
  host.classList.remove('dshet-floating')
602
892
  row.classList.remove('dshet-row')
603
- 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)
604
912
  } else {
605
913
  host.classList.add('dshet-floating')
606
914
  row.classList.add('dshet-row')
@@ -608,11 +916,89 @@ window.__ModuleLoader__.load({
608
916
  }
609
917
  }
610
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
+
611
933
  function clearEditor() {
612
934
  const elements = document.querySelectorAll('.dshet-editor')
613
935
  for (const element of elements) element.remove()
614
936
  }
615
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
+
616
1002
  // The editor is plain DOM on purpose: the host row is a React subtree and
617
1003
  // injecting a second controlled tree into it would fight its reconciler.
618
1004
  // The draft is mirrored into the controller on every input event, so a
@@ -636,49 +1022,98 @@ window.__ModuleLoader__.load({
636
1022
  // Every string the editor shows depends on what is being edited: a prompt is
637
1023
  // re-run, a reply is replaced. Keeping them in one place keeps the two modes
638
1024
  // from drifting apart in wording.
639
- function editorTexts(t, target) {
1025
+ function editorTexts(t, target, rerun) {
640
1026
  const reply = target.mode === 'reply'
641
1027
  return {
642
1028
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
643
1029
  placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
644
- hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
645
1030
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
646
- 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'),
647
1034
  }
648
1035
  }
649
1036
 
650
1037
  function renderEditor(row, controller, view, t) {
651
1038
  const target = view.editing
652
- const texts = editorTexts(t, target)
1039
+ const texts = editorTexts(t, target, view.rerun)
653
1040
  const marker = editorMarker(target, view)
654
- const existing = row.querySelector('.dshet-editor')
655
- 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
656
1061
 
657
1062
  clearEditor()
658
1063
  const box = document.createElement('div')
659
1064
  box.className = 'dshet-editor'
660
1065
  box.dataset.dshetFor = marker
661
1066
 
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
1067
  const area = document.createElement('textarea')
668
- area.rows = 4
1068
+ area.rows = 1
669
1069
  area.value = controller.editorDraft
670
1070
  area.disabled = view.pending || view.confirming
671
1071
  area.spellcheck = false
672
1072
  area.setAttribute('aria-label', texts.title)
673
1073
  if (!view.confirming) area.placeholder = texts.placeholder
674
- 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
+ })
675
1106
  box.appendChild(area)
676
1107
 
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
-
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
+ }
682
1117
  if (target.attachments > 0) {
683
1118
  const warn = document.createElement('p')
684
1119
  warn.className = 'dshet-warn'
@@ -700,13 +1135,12 @@ window.__ModuleLoader__.load({
700
1135
  cancel.type = 'button'
701
1136
  cancel.className = 'dshet-btn'
702
1137
  cancel.textContent = t('editor.cancel')
1138
+ cancel.title = `${t('editor.cancel')} (Esc)`
703
1139
  cancel.disabled = view.pending
704
- cancel.onclick = (event) => {
705
- event.preventDefault()
706
- event.stopPropagation()
1140
+ pressable(cancel, () => {
707
1141
  if (view.confirming) controller.back()
708
1142
  else controller.close()
709
- }
1143
+ })
710
1144
 
711
1145
  const submit = document.createElement('button')
712
1146
  submit.type = 'button'
@@ -716,23 +1150,28 @@ window.__ModuleLoader__.load({
716
1150
  : view.confirming
717
1151
  ? t('editor.review')
718
1152
  : texts.save
1153
+ submit.title = `${submit.textContent} (Enter)`
719
1154
  submit.disabled = view.pending
720
- submit.onclick = (event) => {
721
- event.preventDefault()
722
- event.stopPropagation()
1155
+ pressable(submit, () => {
723
1156
  if (view.confirming) controller.confirm()
724
1157
  else controller.review()
725
- }
1158
+ })
726
1159
 
727
1160
  footer.appendChild(cancel)
728
1161
  footer.appendChild(submit)
729
1162
  box.appendChild(footer)
730
1163
 
731
- row.appendChild(box)
732
- 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)) {
733
1170
  try {
734
1171
  area.focus()
735
- 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))
736
1175
  } catch {
737
1176
  /* focus is best-effort */
738
1177
  }
@@ -765,55 +1204,35 @@ window.__ModuleLoader__.load({
765
1204
  const node = snapshot.nodes.get(key)
766
1205
  if (!node) continue
767
1206
  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
- }
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)
815
1232
  } else {
816
- removeRowAction(row)
1233
+ const target = targetFor(node, view)
1234
+ if (target !== null) injectRowAction(row, target, controller, t)
1235
+ else removeRowAction(row)
817
1236
  }
818
1237
  // The editor anchors to whichever row covers the seq being edited, not to
819
1238
  // the row that owns the entry: a reply's entry now lives in the turn-tail
@@ -827,7 +1246,28 @@ window.__ModuleLoader__.load({
827
1246
  renderEditor(row, controller, view, t)
828
1247
  }
829
1248
  }
830
- 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
831
1271
  }
832
1272
 
833
1273
  // The reply edit entry, registered in the host's own assistant-actions strip.
@@ -837,11 +1277,35 @@ window.__ModuleLoader__.load({
837
1277
  // keeps the platform's own reveal behaviour (always for the latest turn, hover
838
1278
  // for older ones). Injecting instead put the pencil in a different place from
839
1279
  // 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')
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')
845
1309
  const open = (event) => {
846
1310
  event.preventDefault()
847
1311
  event.stopPropagation()
@@ -854,13 +1318,16 @@ window.__ModuleLoader__.load({
854
1318
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
855
1319
  })
856
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.
857
1324
  return jsx('button', {
858
1325
  type: 'button',
859
- className: 'dshet-action dshet-row-action',
1326
+ className: 'dshet-action dshet-row-action dshet-reply-action',
860
1327
  'aria-label': label,
861
1328
  title: label,
862
1329
  onClick: open,
863
- children: jsx(PencilIcon, null),
1330
+ children: jsx(PencilIcon, {}),
864
1331
  })
865
1332
  }
866
1333
 
@@ -874,6 +1341,36 @@ window.__ModuleLoader__.load({
874
1341
  controller.load()
875
1342
  }, [controller])
876
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
+
877
1374
  react.useEffect(() => {
878
1375
  if (snapshot === undefined) return undefined
879
1376
  let scheduled = false