dsh-edit-turn 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -22,6 +22,9 @@ window.__ModuleLoader__.load({
22
22
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
23
23
 
24
24
  const react = require('react')
25
+ // DOM injection has to land before the browser paints, or the host's own
26
+ // buttons are visible for a frame and ours pop in after them.
27
+ const useBrowserLayoutEffect = typeof react.useLayoutEffect === 'function' ? react.useLayoutEffect : react.useEffect
25
28
  const jsxRuntime = require('react/jsx-runtime')
26
29
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
27
30
  const { jsx, jsxs, Fragment } = jsxRuntime
@@ -42,7 +45,7 @@ window.__ModuleLoader__.load({
42
45
  const RERUN_PREFIX = '/dsh-rerun-turn'
43
46
 
44
47
  /** Keep in sync with package.json and lib/index.js. */
45
- const PLUGIN_VERSION = '0.3.3'
48
+ const PLUGIN_VERSION = '0.5.0'
46
49
 
47
50
  // --- copy -----------------------------------------------------------------
48
51
 
@@ -65,6 +68,37 @@ window.__ModuleLoader__.load({
65
68
  'editor.rerun': '重跑',
66
69
  'editor.save-reply': '保存',
67
70
  'editor.block': '编辑哪一块',
71
+ 'editor.block.all': '全部',
72
+ 'config.style': '编辑器样式',
73
+ 'config.placement': '编辑器位置',
74
+ 'config.height': '编辑器高度',
75
+ 'height.max': '一轮最大',
76
+ 'height.content': '随内容',
77
+ 'height.fill': '全部铺满',
78
+ 'config.summary.height.max': '一轮最大',
79
+ 'config.summary.height.content': '随内容',
80
+ 'config.summary.height.fill': '全部铺满',
81
+ 'config.hint.height.max': '取这一轮所有思维链/正文里的最大高度,切换块时框不变。',
82
+ 'config.hint.height.content': '框高跟着当前块的内容走,切换块时框会变。',
83
+ 'config.hint.height.fill': '普通块按一轮最大;选「全部」时自动铺满对话区到输入框之间的整段高度(刚好触碰上下边界),只有一个滚动条。',
84
+ 'placement.overlay': '浮层',
85
+ 'placement.inline': '页面内联',
86
+ 'placement.legacy': '整行旧版',
87
+ 'config.summary.legacy': '整行旧版',
88
+ 'config.hint.legacy': '整条 assistant 行套框、正文保持旧的内部滚动;同样向下盖住 action bar,功能键在左下。',
89
+ 'config.summary.overlay': '浮层',
90
+ 'config.summary.inline': '页面内联',
91
+ 'config.hint.overlay': '悬浮在视口内,长内容在编辑框内滚动。',
92
+ 'config.hint.inline': '按页面坐标铺在消息上,跟随页面滚动,高度按内容展开,不再截到视口。',
93
+ 'config.summary.frame': '整行描边',
94
+ 'config.summary.bubble': '贴合气泡',
95
+ 'config.summary.center': '居中面板',
96
+ 'config.hint.frame': '一圈描边套住整条消息行,正文输入框落在消息文字上,按钮在右上角。',
97
+ 'config.hint.bubble': '编辑框精确等于消息气泡的大小。',
98
+ 'config.hint.center': '大号居中面板,输入框随内容增长。',
99
+ 'style.frame': '整行',
100
+ 'style.bubble': '气泡',
101
+ 'style.center': '居中',
68
102
  'editor.block.text': '正文',
69
103
  'editor.block.reasoning': '思维链',
70
104
  'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
@@ -120,6 +154,37 @@ window.__ModuleLoader__.load({
120
154
  'editor.rerun': 'Re-run',
121
155
  'editor.save-reply': 'Save',
122
156
  'editor.block': 'Block',
157
+ 'editor.block.all': 'All blocks',
158
+ 'config.style': 'Editor style',
159
+ 'config.placement': 'Editor placement',
160
+ 'config.height': 'Editor height',
161
+ 'height.max': 'Turn max',
162
+ 'height.content': 'Content',
163
+ 'height.fill': 'Fill when all',
164
+ 'config.summary.height.max': 'Turn max',
165
+ 'config.summary.height.content': 'Content',
166
+ 'config.summary.height.fill': 'Fill when all',
167
+ 'config.hint.height.max': 'Uses the tallest reasoning/text block of the turn, so the box does not resize when switching.',
168
+ 'config.hint.height.content': 'The box follows the current block, so it resizes when switching.',
169
+ 'config.hint.height.fill': 'Single blocks use the turn max; the all-blocks page fills the whole band between the conversation top and the composer, with one scrollbar.',
170
+ 'placement.overlay': 'Overlay',
171
+ 'placement.inline': 'In-page',
172
+ 'placement.legacy': 'Legacy row',
173
+ 'config.summary.legacy': 'Legacy row',
174
+ 'config.hint.legacy': 'Frames the whole assistant row and keeps the old inner scroll; still covers the action bar, with the controls at the bottom left.',
175
+ 'config.summary.overlay': 'Overlay',
176
+ 'config.summary.inline': 'In-page',
177
+ 'config.hint.overlay': 'Floats inside the viewport; long content scrolls inside the editor.',
178
+ 'config.hint.inline': 'Lays over the message in page coordinates and scrolls with the page; it grows with the content instead of being clipped to the viewport.',
179
+ 'config.summary.frame': 'Row frame',
180
+ 'config.summary.bubble': 'Exact bubble',
181
+ 'config.summary.center': 'Centered panel',
182
+ 'config.hint.frame': 'Outline the whole message row; the textarea sits on the message text and the actions move to the top-right.',
183
+ 'config.hint.bubble': 'The editor is exactly the message bubble.',
184
+ 'config.hint.center': 'A large centred panel whose textarea grows with the content.',
185
+ 'style.frame': 'Frame',
186
+ 'style.bubble': 'Bubble',
187
+ 'style.center': 'Center',
123
188
  'editor.block.text': 'Answer',
124
189
  'editor.block.reasoning': 'Reasoning',
125
190
  'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
@@ -222,6 +287,9 @@ window.__ModuleLoader__.load({
222
287
  // positioned button inside the bubble used to land on the last line, which
223
288
  // is how the rewritten prompt got covered up.
224
289
  '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
290
+ '.dshet-revision-text{display:block}',
291
+ '.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
292
+ '.dshet-revision-file{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 6px 2px 2px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-chip);font-size:12px;line-height:18px}',
225
293
  '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
226
294
  '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
227
295
  // A sibling plugin can append its own button (dsh-delete-turn's bin) into
@@ -260,11 +328,20 @@ window.__ModuleLoader__.load({
260
328
  // composition and its clicks a piece at a time. The layer covers the
261
329
  // viewport but only the box itself takes pointer events, and the box is
262
330
  // positioned under its row by `placeEditor`.
331
+ '.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
263
332
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
264
333
  '.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
265
- '.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 16px);overflow-y:auto;overscroll-behavior:contain;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}',
334
+ '.dshet-editor{display:flex;flex-direction:column;max-height:calc(100vh - 24px);overflow-y:auto;overscroll-behavior:contain;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}',
335
+ '.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
266
336
  '.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
267
- '.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:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
337
+ '.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:12px;background:var(--dshet-panel);backdrop-filter:none;box-shadow:none}',
338
+ '.dshet-editor[data-dshet-inplace="1"] textarea{position:absolute;min-height:0;max-height:none;padding:6px 10px;border:0;border-radius:8px;background:transparent;overflow-y:auto}',
339
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-footer{position:absolute;top:auto;left:0;right:0;bottom:0;height:46px;align-items:center;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 12px 12px;gap:6px}',
340
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
341
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
342
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn,.dshet-editor[data-dshet-inplace="1"] .dshet-error{position:absolute;left:8px;bottom:6px;max-width:60%;margin:0;background:var(--dshet-panel);border-radius:8px;padding:2px 6px}',
343
+
344
+ '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:min(46vh,420px);overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
268
345
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
269
346
  '.dshet-editor textarea:disabled{opacity:.65}',
270
347
  // The blocks this message carries: one chip each, drawn from the same
@@ -280,8 +357,9 @@ window.__ModuleLoader__.load({
280
357
  '.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
281
358
  '.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
282
359
  '.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
283
- '.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
284
- '.dshet-attach{padding:3px 10px;border:1px dashed var(--dshet-line);border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s,color .12s}',
360
+ '.dshet-attach-row{position:absolute;left:12px;bottom:10px;z-index:2;display:flex;align-items:center;margin:0}',
361
+ '.dshet-attach{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:1px 6px;box-sizing:border-box;border:0;border-radius:999px;background:#f5f6f7;color:#0f1115;font:inherit;line-height:1;cursor:pointer;outline:none;box-shadow:none;-webkit-appearance:none;transition:background-color .12s,color .12s}',
362
+ '.dshet-attach svg{width:14px;height:14px;display:block;stroke:currentColor;fill:none;stroke-width:1.3;}',
285
363
  '.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
286
364
  '.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
287
365
  '.dshet-attach:disabled{cursor:default;opacity:.5}',
@@ -290,15 +368,44 @@ window.__ModuleLoader__.load({
290
368
  '.dshet-note{color:var(--dshet-ink-dim)}',
291
369
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
292
370
  '.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
293
- '.dshet-footer{position:sticky;bottom:0;z-index:1;display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2)}',
371
+ '.dshet-footer{position:absolute;left:0;right:0;bottom:0;top:auto;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:6px;height:46px;padding:6px 10px;box-sizing:border-box;background:var(--dshet-panel);border-radius:0 0 16px 16px}',
372
+ '.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
373
+ '.dshet-all-nav{position:sticky;top:0;z-index:2;display:flex;flex-wrap:nowrap;align-items:center;gap:6px;min-height:28px;padding:4px 0 6px;background:var(--dshet-panel);overflow-x:auto;scrollbar-width:none}',
374
+ '.dshet-all-nav button{flex:none;border:0;border-radius:999px;background:var(--dshet-hover);color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;padding:3px 10px;cursor:pointer;white-space:nowrap}',
375
+ '.dshet-all-nav button:hover{color:var(--dshet-ink)}',
376
+ '.dshet-all{position:absolute;left:8px;right:8px;top:6px;bottom:52px;overflow-y:auto;display:flex;flex-direction:column;padding:2px 4px 4px}',
377
+ '.dshet-all-block{display:flex;flex-direction:column;padding:6px 0 8px}',
378
+ '.dshet-all-block:first-child{padding-top:2px}',
379
+ '.dshet-all-head{display:flex;width:100%;box-sizing:border-box;align-items:center;gap:8px;font-size:11px;color:var(--dshet-ink-dim);padding:0 2px 4px}',
380
+ '.dshet-all-head>span:first-child{background:var(--dshet-hover);border-radius:6px;padding:1px 6px;white-space:nowrap}',
381
+ '.dshet-all-line{flex:1;height:1px;background:var(--dshet-line);opacity:.55}',
382
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-all .dshet-all-area{position:static;left:auto;top:auto;width:100%;height:auto;min-height:32px;max-height:none;padding:0 2px;border-radius:0;overflow:hidden}',
383
+ '.dshet-all-area{display:block;width:100%;min-height:32px;max-height:none;box-sizing:border-box;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));resize:none;outline:none;overflow:hidden;scrollbar-width:none;padding:0 2px}',
384
+ '.dshet-editor .dshet-footer .dshet-btn{height:34px;box-sizing:border-box;padding:0 14px}',
294
385
  '.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}',
295
386
  '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
296
387
  '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
297
388
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
389
+ '.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c}',
390
+ '.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover)}',
391
+ '.dshet-btn-save svg{width:15px;height:15px;display:block;stroke:currentColor;fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
392
+ '.dshet-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}',
298
393
  '.dshet-btn-primary{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent));background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));color:var(--dsw-alias-label-primary-foreground,var(--dshet-on-accent))}',
299
394
  '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
300
- '.dshet-pick{display:flex;align-items:center;gap:8px;padding:8px 12px 0;font-size:12px;color:var(--dshet-ink-dim)}',
395
+ '.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
396
+ '.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
397
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
398
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:#81858c;filter:none}',
399
+ '.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
400
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c;filter:none}',
401
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
402
+ '.dshet-pick{display:flex;align-items:center;gap:4px;padding:0;font-size:12px;color:var(--dshet-ink-dim)}',
403
+ '.dshet-config{display:flex;flex-direction:column;gap:10px}',
404
+ '.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
405
+ '.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
301
406
  '.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
407
+ '.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
408
+ '.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
302
409
  '[data-dshet-patch]{white-space:pre-wrap}',
303
410
  '.dshet-patch-note{margin:6px 0;padding:6px 10px;border:1px dashed var(--dshet-line);border-radius:10px;background:var(--dshet-panel);color:var(--dshet-ink-dim);font-size:12px;line-height:18px}',
304
411
  '.dshet-patch-note summary{cursor:pointer}',
@@ -365,6 +472,8 @@ window.__ModuleLoader__.load({
365
472
  this.editorBox = null
366
473
  this.editorRow = null
367
474
  this.editorAnchor = null
475
+ this.editorMode = 'reply'
476
+ this.editorStyle = 'frame'
368
477
  this.editorFocus = false
369
478
  this.editorDraft = ''
370
479
  // The block list being edited: what the carrier will be rebuilt from.
@@ -506,6 +615,12 @@ window.__ModuleLoader__.load({
506
615
  // replies are indexed by it too.
507
616
  if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
508
617
  }
618
+ let preferredStyle = data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
619
+ ? data.config.editorStyle
620
+ : 'frame'
621
+ const savedStyle = readStoredEditorStyle()
622
+ if (savedStyle !== null) preferredStyle = savedStyle
623
+ this.editorStyle = preferredStyle
509
624
  this.publish({
510
625
  hidden,
511
626
  revisions,
@@ -515,6 +630,9 @@ window.__ModuleLoader__.load({
515
630
  patches,
516
631
  patchBySeq,
517
632
  replyMode: data.mode === 'patch' ? 'patch' : 'replay',
633
+ editorStyle: data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
634
+ ? data.config.editorStyle
635
+ : 'frame',
518
636
  surfaceReady: true,
519
637
  loaded: true,
520
638
  loadError: false,
@@ -553,7 +671,11 @@ window.__ModuleLoader__.load({
553
671
  const current = same && typeof this.editorBlockIndex === 'number'
554
672
  ? replyBlocks.find((block) => block.index === this.editorBlockIndex)
555
673
  : undefined
556
- const chosen = current ?? requested ?? replyBlocks.find((block) => block.kind === 'text') ?? replyBlocks[0]
674
+ // A fresh open always lands on the answer text: a turn's block list is
675
+ // mostly reasoning now, and opening on a chain-of-thought is not what
676
+ // the pencil means. The picker is how the user goes to a thought.
677
+ const answerBlock = [...replyBlocks].reverse().find((block) => block.kind === 'text')
678
+ const chosen = (same ? current : undefined) ?? answerBlock ?? requested ?? replyBlocks[0]
557
679
  const normalized = replyBlocks.length > 0
558
680
  ? {
559
681
  ...target,
@@ -564,12 +686,16 @@ window.__ModuleLoader__.load({
564
686
  }
565
687
  : target
566
688
  this.editor = normalized
689
+ this.editorMode = normalized.mode === 'prompt' ? 'prompt' : 'reply'
567
690
  this.editorAnchor = target.anchor !== undefined && target.anchor !== null && target.anchor.isConnected === true
568
691
  ? target.anchor
569
692
  : null
570
693
  this.editorBlocks = replyBlocks
571
694
  this.editorBlockIndex = chosen === undefined ? null : chosen.index
572
695
  this.editorBlockKind = chosen === undefined ? null : chosen.kind
696
+ this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
697
+ this.editorAll = false
698
+ this.editorAllDrafts = new Map()
573
699
  if (!same) {
574
700
  this.editorDraft = chosen === undefined ? target.text : chosen.text
575
701
  this.editorParts = blocksToParts(normalized, this.sessionId)
@@ -596,6 +722,8 @@ window.__ModuleLoader__.load({
596
722
  blocks: replyBlocks,
597
723
  blockIndex: this.editorBlockIndex,
598
724
  blockKind: this.editorBlockKind,
725
+ targetSeq: this.editorTargetSeq,
726
+ allBlocks: false,
599
727
  confirming: false,
600
728
  failure: null,
601
729
  })
@@ -629,17 +757,31 @@ window.__ModuleLoader__.load({
629
757
  // Pick another block of the same reply. The editor is rebuilt by the
630
758
  // block index in its marker; the draft becomes that block's current
631
759
  // (already projected) text.
632
- selectBlock(index) {
760
+ selectBlock(value) {
633
761
  if (this.view.pending) return
634
762
  const target = this.editor
635
763
  if (target === null || !Array.isArray(target.blocks)) return
636
- const block = target.blocks.find((entry) => entry.index === index)
764
+ const raw = String(value)
765
+ if (raw === 'all') {
766
+ this.editorAll = true
767
+ this.editorAllDrafts = new Map(target.blocks.map((block) => [`${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`, block.text]))
768
+ this.editorElement = null
769
+ this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
770
+ return
771
+ }
772
+ this.editorAll = false
773
+ const split = raw.includes(':') ? raw.split(':') : ['', raw]
774
+ const seq = split[0] === '' ? null : Number(split[0])
775
+ const index = Number(split[1])
776
+ const block = target.blocks.find((entry) =>
777
+ entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
637
778
  if (block === undefined || block.kind === undefined) return
638
779
  this.editorBlockIndex = block.index
639
780
  this.editorBlockKind = block.kind
781
+ this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
640
782
  this.editorDraft = block.text
641
783
  this.editorElement = null
642
- this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, failure: null })
784
+ this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, failure: null })
643
785
  }
644
786
 
645
787
  close() {
@@ -649,6 +791,7 @@ window.__ModuleLoader__.load({
649
791
  this.editorBox = null
650
792
  this.editorRow = null
651
793
  this.editorAnchor = null
794
+ this.editorMode = 'reply'
652
795
  this.editorFocus = false
653
796
  this.editor = null
654
797
  this.editorDraft = ''
@@ -658,6 +801,9 @@ window.__ModuleLoader__.load({
658
801
  this.editorBlocks = []
659
802
  this.editorBlockIndex = null
660
803
  this.editorBlockKind = null
804
+ this.editorTargetSeq = null
805
+ this.editorAll = false
806
+ this.editorAllDrafts = new Map()
661
807
  this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
662
808
  }
663
809
 
@@ -665,6 +811,13 @@ window.__ModuleLoader__.load({
665
811
  this.editorDraft = value
666
812
  }
667
813
 
814
+ setEditorStyle(style) {
815
+ if (!EDITOR_STYLES.includes(style)) return
816
+ setEditorStylePreference(style)
817
+ this.editorStyle = style
818
+ this.publish({ editorStyle: style })
819
+ }
820
+
668
821
  /**
669
822
  * One chip the user removed leaves the block list, and nothing else.
670
823
  *
@@ -730,6 +883,56 @@ window.__ModuleLoader__.load({
730
883
  }
731
884
  }
732
885
 
886
+ /** The all-blocks save: one request carrying every edited block. */
887
+ async confirmAll(target) {
888
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
889
+ const drafts = this.editorAllDrafts instanceof Map ? this.editorAllDrafts : new Map()
890
+ const targets = []
891
+ for (const block of blocks) {
892
+ const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
893
+ const text = drafts.get(key)
894
+ if (typeof text !== 'string' || text.trim() === '') {
895
+ this.publish({ failure: 'empty' })
896
+ return
897
+ }
898
+ if (text !== block.text) {
899
+ targets.push({ seq: typeof block.seq === 'number' ? block.seq : target.seq, blockIndex: block.index, kind: block.kind, text })
900
+ }
901
+ }
902
+ if (targets.length === 0) {
903
+ this.publish({ failure: null })
904
+ this.editor = null
905
+ this.publish({ editing: null })
906
+ return
907
+ }
908
+ this.publish({ pending: true, failure: null })
909
+ try {
910
+ const res = await fetch(`${ROUTE_PREFIX}/apply`, {
911
+ method: 'POST',
912
+ headers: { 'content-type': 'application/json' },
913
+ body: JSON.stringify({ sessionId: this.sessionId, targets }),
914
+ })
915
+ const data = await res.json().catch(() => ({}))
916
+ if (!res.ok || !data.ok) {
917
+ this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
918
+ return
919
+ }
920
+ this.editor = null
921
+ this.editorDraft = ''
922
+ this.editorAll = false
923
+ this.editorAllDrafts = new Map()
924
+ this.editorElement = null
925
+ this.editorBox = null
926
+ this.editorRow = null
927
+ this.editorFocus = false
928
+ this.confirming = false
929
+ this.publish({ pending: false, editing: null, confirming: false, failure: null })
930
+ this.load()
931
+ } catch {
932
+ this.publish({ pending: false, failure: 'generic' })
933
+ }
934
+ }
935
+
733
936
  review() {
734
937
  this.pendingRerun = false
735
938
  this.runReview()
@@ -765,6 +968,9 @@ window.__ModuleLoader__.load({
765
968
  if (target === null || this.view.pending) return
766
969
  const rerunIntent = this.pendingRerun
767
970
  this.pendingRerun = false
971
+ if (this.editorAll === true) {
972
+ return this.confirmAll(target)
973
+ }
768
974
  const text = this.editorDraft
769
975
  if (text.trim() === '') {
770
976
  this.publish({ failure: 'empty' })
@@ -791,7 +997,11 @@ window.__ModuleLoader__.load({
791
997
  // The in-place reply path names one block; the host validates that
792
998
  // it is still that kind at that index before writing the record.
793
999
  ...(target.patch === true && typeof this.editorBlockIndex === 'number'
794
- ? { blockIndex: this.editorBlockIndex, kind: this.editorBlockKind }
1000
+ ? {
1001
+ blockIndex: this.editorBlockIndex,
1002
+ kind: this.editorBlockKind,
1003
+ ...(this.editorTargetSeq === null ? {} : { targetSeq: this.editorTargetSeq }),
1004
+ }
795
1005
  : {}),
796
1006
  }),
797
1007
  })
@@ -1266,7 +1476,8 @@ window.__ModuleLoader__.load({
1266
1476
  }
1267
1477
  const entry = view.editable.get(headRevision(view.revisions.get(revisionSeq), view.revisions))
1268
1478
  const text = entry && typeof entry.text === 'string' ? entry.text : ''
1269
- if (text === '') {
1479
+ const extraBlocks = entry && Array.isArray(entry.blocks) ? entry.blocks.filter((block) => block.type !== 'text') : []
1480
+ if (text === '' && extraBlocks.length === 0) {
1270
1481
  if (existing !== null) existing.remove()
1271
1482
  releaseRowCopy(row)
1272
1483
  return node.kind !== 'user'
@@ -1279,7 +1490,22 @@ window.__ModuleLoader__.load({
1279
1490
  }
1280
1491
  }
1281
1492
  placeRevisionBubble(row, bubble)
1282
- if (bubble.textContent !== text) bubble.textContent = text
1493
+ // A rewritten prompt may still carry images/files. The carrier keeps
1494
+ // those blocks, the model reads them, and the editor lists them - the
1495
+ // standing-in bubble must show them too, or an edited image message looks
1496
+ // like the images were deleted.
1497
+ const signature = text + '|' + extraBlocks.map((block) =>
1498
+ `${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
1499
+ if (bubble.dataset.dshetRevisionContent !== signature) {
1500
+ bubble.textContent = ''
1501
+ const textNode = document.createElement('span')
1502
+ textNode.className = 'dshet-revision-text'
1503
+ textNode.textContent = text
1504
+ bubble.appendChild(textNode)
1505
+ const files = revisionAttachments(entry, controller)
1506
+ if (files !== null) bubble.appendChild(files)
1507
+ bubble.dataset.dshetRevisionContent = signature
1508
+ }
1283
1509
  // The edit entry goes where every other action of this row lives: the
1284
1510
  // platform's own bar, which the collapse keeps standing, at its right end
1285
1511
  // behind the clock and the copy button - the row the user reads as "this
@@ -1312,6 +1538,40 @@ window.__ModuleLoader__.load({
1312
1538
  return true
1313
1539
  }
1314
1540
 
1541
+ /**
1542
+ * The non-text blocks of a rewritten prompt, drawn under its text.
1543
+ *
1544
+ * These are the same blocks the editor lists as chips; the bytes come from
1545
+ * this plugin's attachment route, so a thumbnail works without a host API.
1546
+ */
1547
+ function revisionAttachments(entry, controller) {
1548
+ if (!entry || !Array.isArray(entry.blocks)) return null
1549
+ const blocks = entry.blocks.filter((block) => block.type !== 'text')
1550
+ if (blocks.length === 0) return null
1551
+ const strip = document.createElement('div')
1552
+ strip.className = 'dshet-revision-files'
1553
+ for (const block of blocks) {
1554
+ const item = document.createElement('div')
1555
+ item.className = 'dshet-revision-file'
1556
+ if (block.preview === true && typeof block.index === 'number') {
1557
+ const image = document.createElement('img')
1558
+ image.className = 'dshet-thumb'
1559
+ image.alt = ''
1560
+ image.src = previewUrl(controller.sessionId, entry.seq, block.index)
1561
+ image.addEventListener('error', () => {
1562
+ if (typeof image.remove === 'function') image.remove()
1563
+ })
1564
+ item.appendChild(image)
1565
+ }
1566
+ const label = document.createElement('span')
1567
+ label.className = 'dshet-chip-label'
1568
+ label.textContent = typeof block.name === 'string' && block.name !== '' ? block.name : block.type
1569
+ item.appendChild(label)
1570
+ strip.appendChild(item)
1571
+ }
1572
+ return strip
1573
+ }
1574
+
1315
1575
  // A copy of a live user bubble element, classes and all.
1316
1576
  // The key of the row a bubble stands in for. It is what makes a stray
1317
1577
  // bubble recognisable after the host's DOM has shifted around it: an
@@ -1957,6 +2217,30 @@ window.__ModuleLoader__.load({
1957
2217
  // Put the box under the row it belongs to: same width, just below its lower
1958
2218
  // edge. Called on render and whenever the page scrolls or resizes, since
1959
2219
  // the layer is fixed to the viewport.
2220
+ // Some host skins mark their editor chrome `!important`; only an inline
2221
+ // `!important` outranks that. The stub DOM has no `setProperty`, so it falls
2222
+ // back to a plain assignment there.
2223
+ function forceStyle(element, property, value) {
2224
+ if (element === null || element === undefined || element.style === undefined) return
2225
+ if (typeof element.style.setProperty === 'function') {
2226
+ element.style.setProperty(property, value, 'important')
2227
+ return
2228
+ }
2229
+ element.style[property] = value
2230
+ }
2231
+
2232
+ function pageBackground() {
2233
+ try {
2234
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined') {
2235
+ const value = getComputedStyle(document.body).backgroundColor
2236
+ if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
2237
+ }
2238
+ } catch {
2239
+ /* fall through */
2240
+ }
2241
+ return '#ffffff'
2242
+ }
2243
+
1960
2244
  function viewportSize() {
1961
2245
  const win = typeof window === 'undefined' ? undefined : window
1962
2246
  const doc = typeof document === 'undefined' ? undefined : document
@@ -1973,41 +2257,429 @@ window.__ModuleLoader__.load({
1973
2257
  // past the bottom edge where nothing could reach it. Flip above the row when
1974
2258
  // there is room, otherwise pin the box to the bottom of the viewport and let
1975
2259
  // its own scrollbar carry the content; the footer stays sticky either way.
1976
- function placeEditor(row, box, anchor = null) {
1977
- const layer = ensureEditorLayer()
1978
- if (box.parentElement !== layer) layer.appendChild(box)
2260
+ /**
2261
+ * The bubble the editor stands in for.
2262
+ *
2263
+ * A prompt is a right-aligned input bubble; a reply is the output content
2264
+ * block. The editor takes that element's width and left edge and overlays its
2265
+ * position, so it reads as the message being edited instead of a floating
2266
+ * panel. Class hashes change between builds, so the suffixes are matched.
2267
+ */
2268
+ function bubbleFor(row, mode, blockKind = null) {
2269
+ let selectors
2270
+ if (mode === 'reply' && blockKind === 'reasoning') {
2271
+ selectors = ['details', '[class*="_reasoning"]', '[class*="_thinking"]', '[class*="reasoning"]']
2272
+ } else if (mode === 'reply') {
2273
+ selectors = ['[class*="_markdown"]', '[class*="_bubble"]', '[class*="messageBody"]']
2274
+ } else {
2275
+ selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]']
2276
+ }
2277
+ for (const selector of selectors) {
2278
+ let nodes = []
2279
+ try {
2280
+ nodes = [...row.querySelectorAll(selector)]
2281
+ } catch {
2282
+ nodes = []
2283
+ }
2284
+ let best = null
2285
+ for (const element of nodes) {
2286
+ if (typeof element.closest === 'function' && element.closest('.dshet-editor') !== null) continue
2287
+ const rect = element.getBoundingClientRect()
2288
+ if (rect.width < 80 || rect.height <= 0) continue
2289
+ if (best === null || rect.width * rect.height > best.rect.width * best.rect.height) best = { element, rect }
2290
+ }
2291
+ if (best !== null) return best
2292
+ }
1979
2293
  const rect = row.getBoundingClientRect()
1980
- let anchorRect = null
1981
- if (anchor !== null && anchor !== undefined && anchor.isConnected === true && typeof anchor.getBoundingClientRect === 'function') {
1982
- const candidate = anchor.getBoundingClientRect()
1983
- if (candidate.width > 0 && candidate.height > 0) anchorRect = candidate
2294
+ return { element: row, rect }
2295
+ }
2296
+
2297
+ /**
2298
+ * Which row an editor should attach to when several cover the edited seq.
2299
+ *
2300
+ * A reply's seq appears in both the assistant-step row and the turn-tail
2301
+ * row that carries its action strip; the tail also covers the reply, so a
2302
+ * last-write-wins loop handed the editor the tail's little action bar and
2303
+ * the box came out message-sized instead of bubble-sized. The message row
2304
+ * always wins.
2305
+ */
2306
+ function editorRowRank(row, mode) {
2307
+ const kind = row.getAttribute('data-chat-flow-kind')
2308
+ if (mode === 'prompt') {
2309
+ if (kind === 'user') return 4
2310
+ if (kind === 'revision' || kind === 'user-revision') return 3
2311
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2312
+ return 2
2313
+ }
2314
+ if (kind === 'assistant-step') return 4
2315
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2316
+ return 2
2317
+ }
2318
+
2319
+ /**
2320
+ * The vertical band the conversation owns: below its top, above the composer.
2321
+ *
2322
+ * This runs on every scroll frame, so the lookups are cached briefly: the
2323
+ * composer element and the conversation top do not move between frames, and
2324
+ * a global `[class*=...]` scan per frame is what made scrolling feel heavy.
2325
+ */
2326
+ let conversationBoundsCache = null
2327
+ let conversationBoundsAt = 0
2328
+ let composerElementCache = null
2329
+ function conversationBounds(row = null) {
2330
+ const now = Date.now()
2331
+ if (conversationBoundsCache !== null && now - conversationBoundsAt < 250) return conversationBoundsCache
2332
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
2333
+ const bounds = { top: 0, bottom: viewportHeight, composerLeft: 0, composerWidth: 0 }
2334
+ try {
2335
+ const conversation = document.querySelector('[data-slot="main.conversation"]')
2336
+ if (conversation !== null && typeof conversation.getBoundingClientRect === 'function') {
2337
+ bounds.top = conversation.getBoundingClientRect().top
2338
+ }
2339
+ } catch {}
2340
+ // The real scroll area is the nearest scrollable ancestor of the edited
2341
+ // row; the slot wrapper can start above the header, which let the box
2342
+ // cover the tab bar.
2343
+ try {
2344
+ let node = row !== null && row !== undefined ? row.parentElement : null
2345
+ while (node !== null && node !== undefined && node !== document.body) {
2346
+ const style = getComputedStyle(node)
2347
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.getBoundingClientRect().height > 120) {
2348
+ bounds.top = Math.max(bounds.top, node.getBoundingClientRect().top)
2349
+ break
2350
+ }
2351
+ node = node.parentElement
2352
+ }
2353
+ } catch {}
2354
+ // The composer stack has several wrappers with "composer" in the class;
2355
+ // the boundary is the LOWEST one that still sits above the viewport edge,
2356
+ // otherwise the box runs under the input box.
2357
+ try {
2358
+ // The composer CARD is the rounded surface that owns the input, not the
2359
+ // outer seat (which is wider and made the editor overhang). Walk up from
2360
+ // the input element to the first rounded, painted ancestor.
2361
+ let card = document.querySelector('textarea[placeholder], [contenteditable="true"]')
2362
+ while (card !== null && card !== undefined && card !== document.body) {
2363
+ const style = getComputedStyle(card)
2364
+ const radius = Number.parseFloat(style.borderTopLeftRadius)
2365
+ if (radius > 6 && style.backgroundColor !== 'rgba(0, 0, 0, 0)') break
2366
+ card = card.parentElement
2367
+ }
2368
+ let lowest = 0
2369
+ let lowestRect = null
2370
+ if (card !== null && card !== undefined && card !== document.body && typeof card.getBoundingClientRect === 'function') {
2371
+ const rect = card.getBoundingClientRect()
2372
+ if (rect.top > 0 && (viewportHeight === 0 || rect.top < viewportHeight)) {
2373
+ lowest = rect.top
2374
+ lowestRect = rect
2375
+ }
2376
+ }
2377
+ if (lowestRect === null) {
2378
+ const candidates = document.querySelectorAll('[data-slot="conversation.input"], [class*="composerSeat"], [class*="composer"]')
2379
+ for (const candidate of candidates) {
2380
+ if (typeof candidate.getBoundingClientRect !== 'function') continue
2381
+ const rect = candidate.getBoundingClientRect()
2382
+ if (rect.top > lowest && (viewportHeight === 0 || rect.top < viewportHeight - 40)) {
2383
+ lowest = rect.top
2384
+ lowestRect = rect
2385
+ }
2386
+ }
2387
+ }
2388
+ bounds.bottom = lowest > 0 ? lowest : Math.min(viewportHeight, viewportHeight - 120)
2389
+ if (lowestRect !== null) {
2390
+ bounds.composerLeft = lowestRect.left
2391
+ bounds.composerWidth = lowestRect.width
2392
+ }
2393
+ } catch {
2394
+ bounds.bottom = viewportHeight > 0 ? viewportHeight - 120 : viewportHeight
1984
2395
  }
1985
- const vertical = anchorRect === null ? rect : anchorRect
2396
+ conversationBoundsCache = bounds
2397
+ conversationBoundsAt = now
2398
+ return bounds
2399
+ }
2400
+
2401
+ function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null) {
2402
+ const layer = ensureEditorLayer()
2403
+ if (box.parentElement !== layer) layer.appendChild(box)
2404
+ const placement = readStoredEditorPlacement() ?? 'overlay'
2405
+ const legacy = placement === 'legacy'
2406
+ const inline = placement === 'inline'
2407
+ // Every fixed mode is clamped to the conversation band by default; the
2408
+ // in-page mode keeps its page coordinates and page scroll.
2409
+ const clamped = !inline
2410
+ if (legacy) style = 'frame'
2411
+ if (inline) layer.dataset.dshetInline = '1'
2412
+ else delete layer.dataset.dshetInline
2413
+ const rowRect = row.getBoundingClientRect()
2414
+ const bubble = bubbleFor(row, mode, blockKind)
2415
+ const storedStyle = readStoredEditorStyle()
2416
+ if (storedStyle !== null) style = storedStyle
2417
+ const chipsInBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2418
+ const minimumHeight = chipsInBox !== null && chipsInBox !== undefined ? 190 : 150
1986
2419
  const viewport = viewportSize()
1987
2420
  const margin = 8
1988
- const width = viewport.width > 0
1989
- ? Math.max(1, Math.min(Math.max(200, rect.width || 0), viewport.width - margin * 2))
1990
- : Math.max(200, rect.width || 0)
2421
+ const area = typeof box.querySelector === 'function' ? box.querySelector('textarea') : null
2422
+ const frame = typeof box.querySelector === 'function' ? box.querySelector('.dshet-frame') : null
2423
+ const allBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-all') : null
2424
+ const heightMode = readStoredEditorHeight() ?? 'fill'
2425
+ if (frame !== null && frame !== undefined) frame.style.display = style === 'frame' ? '' : 'none'
2426
+ box.style.boxShadow = 'none'
2427
+ const footerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2428
+ if (footerEl !== null && footerEl !== undefined && !(style === 'center' && !inline)) {
2429
+ footerEl.style.position = ''
2430
+ footerEl.style.height = ''
2431
+ footerEl.style.marginTop = ''
2432
+ footerEl.style.borderRadius = ''
2433
+ }
2434
+ if (style === 'center' && !inline) {
2435
+ // A large centred panel, the first shape that shipped. The textarea
2436
+ // grows with its content; the original message stays where it is.
2437
+ delete box.dataset.dshetInplace
2438
+ if (area !== null && area !== undefined) {
2439
+ area.style.position = ''
2440
+ area.style.left = ''
2441
+ area.style.top = ''
2442
+ area.style.width = ''
2443
+ area.style.height = ''
2444
+ area.style.maxHeight = ''
2445
+ area.style.background = ''
2446
+ area.style.paddingBottom = '16px'
2447
+ fitEditorHeight(area)
2448
+ const centerFooter = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2449
+ if (centerFooter !== null && centerFooter !== undefined) {
2450
+ centerFooter.style.position = 'static'
2451
+ centerFooter.style.height = '46px'
2452
+ centerFooter.style.marginTop = '0'
2453
+ centerFooter.style.borderRadius = '0 0 16px 16px'
2454
+ }
2455
+ }
2456
+ const width = viewport.width > 0 ? Math.max(320, Math.min(1400, viewport.width - margin * 2)) : Math.max(320, rowRect.width)
2457
+ box.style.boxSizing = 'border-box'
2458
+ box.style.width = `${Math.round(width)}px`
2459
+ box.style.left = `${Math.round(viewport.width > 0 ? (viewport.width - width) / 2 : rowRect.left)}px`
2460
+ const bounds = conversationBounds(row)
2461
+ const topLimit = bounds.top + margin
2462
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2463
+ const usable = bottomLimit > topLimit ? bottomLimit - topLimit : 0
2464
+ box.style.height = ''
2465
+ box.style.maxHeight = usable > 0
2466
+ ? `${Math.max(180, usable)}px`
2467
+ : viewport.height > 0
2468
+ ? `${Math.max(180, viewport.height - margin * 2)}px`
2469
+ : ''
2470
+ const measured = box.getBoundingClientRect()
2471
+ const height = (measured && measured.height) || box.offsetHeight || 0
2472
+ const top = usable > 0 && height > 0
2473
+ ? Math.round(Math.max(topLimit, Math.min(topLimit + (usable - height) / 2, bottomLimit - height)))
2474
+ : viewport.height > 0 && height > 0
2475
+ ? Math.round(Math.max(margin, Math.min((viewport.height - height) / 2, viewport.height - height - margin)))
2476
+ : Math.round((rowRect.bottom || 0) + 6)
2477
+ box.style.top = `${top}px`
2478
+ return
2479
+ }
2480
+ // `bubble` and `frame` both paint over the message, so the textarea gets
2481
+ // an opaque surface: the recorded text underneath must not show through
2482
+ // the draft. `frame` outlines the whole row; `bubble` outlines just the
2483
+ // message block.
2484
+ box.dataset.dshetInplace = '1'
2485
+ // A full opaque backdrop: the frame covers the whole row, so the host's
2486
+ // status line and action bar underneath must not show through either.
2487
+ const opaque = pageBackground()
2488
+ box.style.background = opaque
2489
+ if (area !== null && area !== undefined) {
2490
+ area.style.background = opaque
2491
+ }
2492
+ // A reply edit is scoped to the selected block (answer text or reasoning),
2493
+ // not to the whole assistant row: the other blocks stay out of the frame.
2494
+ const base = legacy ? rowRect : mode === 'reply' ? bubble.rect : style === 'frame' ? rowRect : bubble.rect
2495
+ let width = base.width
2496
+ let alignedLeft = null
2497
+ const fillAllView = allBox !== null && allBox !== undefined && heightMode !== 'content'
2498
+ if (fillAllView) {
2499
+ // The all-blocks page lines up with the composer card: same left edge,
2500
+ // same width, so the two boxes read as one column.
2501
+ const aligned = conversationBounds(row)
2502
+ if (aligned.composerWidth > 0) {
2503
+ width = aligned.composerWidth
2504
+ alignedLeft = aligned.composerLeft
2505
+ }
2506
+ }
2507
+ if (!fillAllView && mode === 'reply' && typeof rowRect.width === 'number' && rowRect.width > width) {
2508
+ // A reply block is narrower than the conversation row; the editor gets
2509
+ // the full row width so the text is not squeezed into the bubble.
2510
+ width = rowRect.width
2511
+ }
2512
+ let left = alignedLeft === null ? base.left : alignedLeft
2513
+ if (inline) {
2514
+ const pageX = typeof window !== 'undefined' && typeof window.scrollX === 'number' ? window.scrollX : 0
2515
+ left += pageX
2516
+ if (viewport.width > 0) width = Math.min(width, viewport.width - margin * 2)
2517
+ } else if (viewport.width > 0) {
2518
+ width = Math.min(width, viewport.width - margin * 2)
2519
+ left = Math.max(margin, Math.min(left, viewport.width - width - margin))
2520
+ }
2521
+ box.style.boxSizing = 'border-box'
1991
2522
  box.style.width = `${Math.round(width)}px`
1992
- const measured = box.getBoundingClientRect()
1993
- const height = (measured && measured.height) || box.offsetHeight || 0
1994
- let top = vertical.bottom + 6
1995
- if (viewport.height > 0 && height > 0) {
1996
- const maxTop = Math.max(margin, viewport.height - height - margin)
1997
- if (top + height > viewport.height - margin) {
1998
- const above = vertical.top - height - 6
1999
- top = above >= margin ? above : maxTop
2000
- }
2001
- // A row taller than the screen has its bottom above the viewport after
2002
- // the user scrolls to its action bar; without this clamp the box follows
2003
- // it off-screen and the buttons become unreachable again.
2004
- top = Math.max(margin, Math.min(top, maxTop))
2005
- }
2006
- let left = rect.left
2007
- if (viewport.width > 0 && left + width > viewport.width - margin) left = viewport.width - width - margin
2008
- if (left < margin) left = margin
2009
2523
  box.style.left = `${Math.round(left)}px`
2524
+ // The host's action strip sits under the reported bubble. Extend the
2525
+ // editor down to the row's bottom so that strip is covered, and the
2526
+ // footer controls end up where those host buttons used to be.
2527
+ const baseBottom = typeof base.bottom === 'number' ? base.bottom : base.top + base.height
2528
+ const rowBottom = typeof rowRect.bottom === 'number' ? rowRect.bottom : rowRect.top + rowRect.height
2529
+ // The action strip can be a sibling of the row (the turn-tail row), not
2530
+ // its child. Find the nearest one from our own injected action buttons
2531
+ // and include its bottom in the cover.
2532
+ let actionBottom = 0
2533
+ if (legacy) try {
2534
+ const actions = document.querySelectorAll('.dshet-action')
2535
+ for (const action of actions) {
2536
+ const bar = action.parentElement
2537
+ if (bar === null || bar === undefined || typeof bar.getBoundingClientRect !== 'function') continue
2538
+ const rect = bar.getBoundingClientRect()
2539
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 160) {
2540
+ actionBottom = Math.max(actionBottom, rect.bottom)
2541
+ }
2542
+ }
2543
+ } catch {}
2544
+ // The action strip is rendered as its own turn-tail row under the
2545
+ // message row. Use that row's bottom; if the host renders it in a shape
2546
+ // we cannot identify, reserve one action-band height so the buttons can
2547
+ // never peek out underneath the opaque editor.
2548
+ let tailBottom = 0
2549
+ if (legacy) try {
2550
+ const tails = document.querySelectorAll('[data-chat-flow-kind="turn-tail"]')
2551
+ for (const tail of tails) {
2552
+ if (typeof tail.getBoundingClientRect !== 'function') continue
2553
+ const rect = tail.getBoundingClientRect()
2554
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 200) {
2555
+ tailBottom = Math.max(tailBottom, rect.bottom)
2556
+ }
2557
+ }
2558
+ } catch {}
2559
+ const coverBottom = Math.max(rowBottom, actionBottom, tailBottom)
2560
+ const coveredHeight = Math.max(base.height, coverBottom - base.top + 48)
2561
+ // Non-legacy modes hug the content: the box ends right after the last
2562
+ // line plus the control band, instead of stretching to the row bottom.
2563
+ const FOOTER_BAND = 46
2564
+ const boxRect0 = typeof box.getBoundingClientRect === 'function' ? box.getBoundingClientRect() : { top: base.top }
2565
+ const chipStrip0 = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2566
+ const chipBottom0 = chipStrip0 === null || chipStrip0 === undefined || typeof chipStrip0.getBoundingClientRect !== 'function'
2567
+ ? 0
2568
+ : Math.max(0, chipStrip0.getBoundingClientRect().bottom - boxRect0.top + 8)
2569
+ const headerOffset = Math.max(38, chipBottom0, bubble.rect.top - base.top)
2570
+ let contentHeight = Math.round(base.height)
2571
+ if (area !== null && area !== undefined && (allBox === null || allBox === undefined)) {
2572
+ try {
2573
+ const previousValue = area.value
2574
+ const previousHeight = area.style.height
2575
+ const measure = (text) => {
2576
+ area.value = text
2577
+ area.style.height = 'auto'
2578
+ const measured = area.scrollHeight
2579
+ return typeof measured === 'number' && measured > 0 ? Math.round(measured) : 0
2580
+ }
2581
+ const own = measure(previousValue)
2582
+ if (own > 0) contentHeight = own
2583
+ // "Turn max" pins the box to the tallest block of the turn, so
2584
+ // switching between a short answer and a long thought does not resize
2585
+ // the frame under the user's hands. The measurement is cached: doing
2586
+ // it for every block on every scroll frame is what made the editor
2587
+ // drag while the conversation moved.
2588
+ if (heightMode !== 'content' && Array.isArray(blocks)) {
2589
+ const key = `${Math.round(area.clientWidth || 0)}|${blocks.map((block) => `${block.seq ?? ''}:${block.index}:${typeof block.text === 'string' ? block.text.length : 0}`).join(',')}`
2590
+ if (maxHeightCache.key === key) {
2591
+ contentHeight = Math.max(contentHeight, maxHeightCache.value)
2592
+ } else {
2593
+ let tallest = 0
2594
+ for (const block of blocks) {
2595
+ if (block === null || block === undefined || typeof block.text !== 'string') continue
2596
+ const measured = measure(block.text)
2597
+ if (measured > tallest) tallest = measured
2598
+ }
2599
+ maxHeightCache = { key, value: tallest }
2600
+ if (tallest > contentHeight) contentHeight = tallest
2601
+ }
2602
+ }
2603
+ area.value = previousValue
2604
+ area.style.height = previousHeight
2605
+ } catch {}
2606
+ }
2607
+ // Every mode hugs the measured content; `coveredHeight` is only a
2608
+ // fallback for a host whose textarea cannot be measured.
2609
+ let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND)
2610
+ let clampedTop = null
2611
+ if (clamped) {
2612
+ const bounds = conversationBounds(row)
2613
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2614
+ const topLimit = bounds.top + margin
2615
+ if (bottomLimit > topLimit) {
2616
+ // All-blocks mode shows one page of blocks behind ONE scrollbar: it
2617
+ // fills the WHOLE band (not just the message's own position), so the
2618
+ // top touches the conversation edge and the bottom the composer.
2619
+ const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
2620
+ clampedTop = fillAll ? topLimit : Math.max(base.top, topLimit)
2621
+ height = fillAll
2622
+ ? Math.max(minimumHeight, bottomLimit - clampedTop)
2623
+ : Math.min(height, Math.max(minimumHeight, bottomLimit - clampedTop))
2624
+ }
2625
+ }
2626
+ if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
2627
+ height = Math.max(minimumHeight, height)
2628
+ box.style.height = `${height}px`
2629
+ box.style.maxHeight = ''
2630
+ let top = clampedTop === null ? base.top : clampedTop
2631
+ if (inline) {
2632
+ const pageY = typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0
2633
+ top += pageY
2634
+ } else if (viewport.height > 0) {
2635
+ top = Math.max(margin, Math.min(top, viewport.height - height - margin))
2636
+ }
2010
2637
  box.style.top = `${Math.round(top)}px`
2638
+ if (allBox !== null && allBox !== undefined) {
2639
+ allBox.style.position = 'absolute'
2640
+ allBox.style.left = '8px'
2641
+ allBox.style.right = '8px'
2642
+ // The all-blocks page has its own sticky outline, so it does not need
2643
+ // the 38px control header the single-block editor reserves.
2644
+ allBox.style.top = '6px'
2645
+ allBox.style.bottom = `${FOOTER_BAND + 8}px`
2646
+ allBox.style.overflowY = 'auto'
2647
+ // The container is now laid out; re-measure each block so the document
2648
+ // is exactly as tall as its text and the ONE scrollbar appears when it
2649
+ // overflows the band.
2650
+ if (typeof allBox.querySelectorAll === 'function') {
2651
+ for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) {
2652
+ blockArea.style.height = 'auto'
2653
+ const measured = blockArea.scrollHeight
2654
+ if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
2655
+ }
2656
+ }
2657
+ } else if (area !== null && area !== undefined) {
2658
+ const offsetLeft = Math.max(0, bubble.rect.left - base.left)
2659
+ // Reserve a header band for the controls; when the prompt carries image
2660
+ // chips they sit above the text, so the text starts below them too.
2661
+ const offsetTop = headerOffset
2662
+ const available = Math.max(40, height - offsetTop - 8)
2663
+ area.style.position = 'absolute'
2664
+ area.style.boxSizing = 'border-box'
2665
+ area.style.left = `${Math.round(offsetLeft)}px`
2666
+ area.style.top = `${Math.round(offsetTop)}px`
2667
+ area.style.width = `${Math.round(mode === 'reply' ? Math.max(80, width - offsetLeft - 8) : Math.max(80, Math.min(bubble.rect.width, width - offsetLeft)))}px`
2668
+ // Never let the textarea paint outside the box: when the measured
2669
+ // content is taller than the box (viewport-capped overlay), it scrolls
2670
+ // inside instead of spilling past the border.
2671
+ area.style.height = `${Math.round(Math.max(32, Math.min(contentHeight, available)))}px`
2672
+ area.style.maxHeight = 'none'
2673
+ area.style.padding = ''
2674
+ area.style.paddingBottom = '8px'
2675
+ }
2676
+ box.style.borderRadius = ''
2677
+ try {
2678
+ const skin = getComputedStyle(bubble.element)
2679
+ if (skin.borderRadius && skin.borderRadius !== '0px') box.style.borderRadius = skin.borderRadius
2680
+ } catch {
2681
+ /* a detached node keeps the stylesheet skin */
2682
+ }
2011
2683
  }
2012
2684
 
2013
2685
  // Grow the textarea to its content, the way a normal input box behaves.
@@ -2015,7 +2687,10 @@ window.__ModuleLoader__.load({
2015
2687
  // in half an empty box. The stylesheet owns the limits (`min-height`,
2016
2688
  // `max-height`); the element only ever gets the measured height, and the
2017
2689
  // `+2` is the top and bottom border `scrollHeight` does not count.
2690
+ let maxHeightCache = { key: '', value: 0 }
2691
+
2018
2692
  function fitEditorHeight(area) {
2693
+ if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
2019
2694
  area.style.height = 'auto'
2020
2695
  const height = area.scrollHeight
2021
2696
  if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
@@ -2167,6 +2842,7 @@ window.__ModuleLoader__.load({
2167
2842
  view.parts.map((part) => part.key).join('.'),
2168
2843
  // The selected reply block: switching blocks has to rebuild the box.
2169
2844
  view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
2845
+ view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
2170
2846
  ].join('|')
2171
2847
  }
2172
2848
 
@@ -2242,6 +2918,16 @@ window.__ModuleLoader__.load({
2242
2918
  return strip
2243
2919
  }
2244
2920
 
2921
+ /** The FileList of a paste/drop payload, without assuming the event shape. */
2922
+ function filesFromData(data) {
2923
+ if (data === null || data === undefined) return []
2924
+ try {
2925
+ return data.files === null || data.files === undefined ? [] : Array.prototype.slice.call(data.files)
2926
+ } catch {
2927
+ return []
2928
+ }
2929
+ }
2930
+
2245
2931
  /**
2246
2932
  * The add/replace entry: a picker that puts a block on the list.
2247
2933
  *
@@ -2270,7 +2956,10 @@ window.__ModuleLoader__.load({
2270
2956
  const add = document.createElement('button')
2271
2957
  add.type = 'button'
2272
2958
  add.className = 'dshet-attach'
2273
- add.textContent = t('editor.attach.add')
2959
+ add.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke-width="1.3"><path d="M8 2V14" stroke="currentColor"></path><path d="M2 8H14" stroke="currentColor"></path></svg>'
2960
+ add.setAttribute('aria-haspopup', 'listbox')
2961
+ add.title = t('editor.attach.add')
2962
+ add.setAttribute('aria-label', t('editor.attach.add'))
2274
2963
  add.disabled = view.pending
2275
2964
  pressable(add, () => {
2276
2965
  if (typeof picker.click === 'function') picker.click()
@@ -2288,7 +2977,7 @@ window.__ModuleLoader__.load({
2288
2977
  ? controller.editorBox
2289
2978
  : null
2290
2979
  if (existing && existing.dataset.dshetFor === marker) {
2291
- placeEditor(row, existing, controller.editorAnchor)
2980
+ placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2292
2981
  return
2293
2982
  }
2294
2983
 
@@ -2310,30 +2999,64 @@ window.__ModuleLoader__.load({
2310
2999
  box.className = 'dshet-editor'
2311
3000
  box.dataset[EDITOR_ATTR] = '1'
2312
3001
  box.dataset.dshetFor = marker
3002
+ // The frame is a child, not the box's own border: host skins mark the
3003
+ // editor chrome `!important` and win the cascade against the box itself,
3004
+ // but they cannot name this child. It draws the blue outline of the
3005
+ // reference UI on top of whatever border the skin puts on the box.
3006
+ const frame = document.createElement('span')
3007
+ frame.className = 'dshet-frame'
3008
+ frame.style.position = 'absolute'
3009
+ frame.style.inset = '-1px'
3010
+ frame.style.border = '2px solid #4a7dff'
3011
+ frame.style.borderRadius = 'inherit'
3012
+ frame.style.boxSizing = 'border-box'
3013
+ frame.style.pointerEvents = 'none'
3014
+ frame.style.zIndex = '3'
3015
+ box.appendChild(frame)
2313
3016
 
2314
3017
  // A reply on the patch path may carry several editable blocks (answer
2315
3018
  // text and reasoning). The picker chooses which one this save targets;
2316
3019
  // every option carries the block's CURRENT projected text, so switching
2317
3020
  // never shows the recorded original over a landed patch.
2318
- if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length > 1) {
3021
+ if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length >= 1) {
2319
3022
  const picker = document.createElement('label')
2320
3023
  picker.className = 'dshet-pick'
2321
3024
  const caption = document.createElement('span')
3025
+ caption.className = 'dshet-sr-only'
2322
3026
  caption.textContent = t('editor.block')
2323
3027
  const select = document.createElement('select')
2324
3028
  select.className = 'dshet-select'
2325
3029
  select.disabled = view.pending || view.confirming
2326
3030
  select.setAttribute('aria-label', t('editor.block'))
3031
+ // A single reply can carry several answer texts and several reasoning
3032
+ // blocks; number the labels so each one is addressable in the picker.
3033
+ const totals = {}
3034
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3035
+ const seen = {}
2327
3036
  for (const block of target.blocks) {
2328
3037
  const option = document.createElement('option')
2329
- option.value = String(block.index)
3038
+ option.value = typeof block.seq === 'number' ? `${block.seq}:${block.index}` : String(block.index)
2330
3039
  const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
2331
- option.textContent = t(kind)
3040
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3041
+ option.textContent = totals[block.kind] > 1 ? `${t(kind)} ${seen[block.kind]}` : t(kind)
2332
3042
  option.selected = block.index === view.blockIndex
2333
3043
  select.appendChild(option)
2334
3044
  }
2335
- if (view.blockIndex !== null && view.blockIndex !== undefined) select.value = String(view.blockIndex)
2336
- select.addEventListener('change', () => controller.selectBlock(Number(select.value)))
3045
+ // "All blocks" is always offered for a multi-block turn, not only once
3046
+ // it is already active - otherwise there is no way to select it.
3047
+ if (target.blocks.length > 1) {
3048
+ const allOption = document.createElement('option')
3049
+ allOption.value = 'all'
3050
+ allOption.textContent = t('editor.block.all')
3051
+ select.appendChild(allOption)
3052
+ }
3053
+ if (view.allBlocks === true) {
3054
+ select.value = 'all'
3055
+ } else if (view.blockIndex !== null && view.blockIndex !== undefined) {
3056
+ const current = target.blocks.find((block) => block.index === view.blockIndex && (view.targetSeq === null || view.targetSeq === undefined || block.seq === undefined || block.seq === view.targetSeq))
3057
+ select.value = current !== undefined && typeof current.seq === 'number' ? `${current.seq}:${current.index}` : String(view.blockIndex)
3058
+ }
3059
+ select.addEventListener('change', () => controller.selectBlock(select.value))
2337
3060
  picker.appendChild(caption)
2338
3061
  picker.appendChild(select)
2339
3062
  box.appendChild(picker)
@@ -2355,13 +3078,13 @@ window.__ModuleLoader__.load({
2355
3078
  fitEditorHeight(area)
2356
3079
  const row = controller.editorRow
2357
3080
  const box = controller.editorBox
2358
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorAnchor)
3081
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2359
3082
  })
2360
3083
  area.addEventListener('compositionend', () => {
2361
3084
  fitEditorHeight(area)
2362
3085
  const row = controller.editorRow
2363
3086
  const box = controller.editorBox
2364
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorAnchor)
3087
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2365
3088
  })
2366
3089
  area.addEventListener('focus', () => {
2367
3090
  controller.editorFocus = true
@@ -2388,6 +3111,100 @@ window.__ModuleLoader__.load({
2388
3111
  })
2389
3112
  box.appendChild(area)
2390
3113
 
3114
+ // "All blocks": every reasoning and text block of the turn on one page,
3115
+ // each in its own textarea under a light separator.
3116
+ if (view.allBlocks === true && Array.isArray(target.blocks)) {
3117
+ const allBox = document.createElement('div')
3118
+ allBox.className = 'dshet-all'
3119
+ // A sticky outline over the page: one chip per block that scrolls the
3120
+ // container to it, so the document keeps its single scrollbar and still
3121
+ // has navigation.
3122
+ const nav = document.createElement('div')
3123
+ nav.className = 'dshet-all-nav'
3124
+ const totals = {}
3125
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3126
+ const seen = {}
3127
+ for (const block of target.blocks) {
3128
+ const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
3129
+ const section = document.createElement('div')
3130
+ section.className = 'dshet-all-block'
3131
+ const head = document.createElement('div')
3132
+ head.className = 'dshet-all-head'
3133
+ const label = document.createElement('span')
3134
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3135
+ const kindKey = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
3136
+ const labelText = totals[block.kind] > 1 ? `${t(kindKey)} ${seen[block.kind]}` : t(kindKey)
3137
+ label.textContent = labelText
3138
+ const navButton = document.createElement('button')
3139
+ navButton.type = 'button'
3140
+ navButton.textContent = labelText
3141
+ navButton.addEventListener('click', () => {
3142
+ if (typeof section.scrollIntoView === 'function') section.scrollIntoView({ block: 'start' })
3143
+ else allBox.scrollTop = section.offsetTop ?? 0
3144
+ })
3145
+ nav.appendChild(navButton)
3146
+ const line = document.createElement('span')
3147
+ line.className = 'dshet-all-line'
3148
+ head.appendChild(label)
3149
+ head.appendChild(line)
3150
+ const blockArea = document.createElement('textarea')
3151
+ blockArea.className = 'dshet-all-area'
3152
+ blockArea.rows = 1
3153
+ blockArea.spellcheck = false
3154
+ blockArea.style.overflow = 'hidden'
3155
+ blockArea.style.position = 'static'
3156
+ blockArea.style.left = 'auto'
3157
+ blockArea.style.top = 'auto'
3158
+ blockArea.value = controller.editorAllDrafts instanceof Map ? (controller.editorAllDrafts.get(key) ?? block.text) : block.text
3159
+ blockArea.disabled = view.pending || view.confirming
3160
+ const grow = () => {
3161
+ blockArea.style.height = 'auto'
3162
+ const measured = blockArea.scrollHeight
3163
+ if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
3164
+ }
3165
+ blockArea.addEventListener('input', () => {
3166
+ if (controller.editorAllDrafts instanceof Map) controller.editorAllDrafts.set(key, blockArea.value)
3167
+ grow()
3168
+ })
3169
+ section.appendChild(head)
3170
+ section.appendChild(blockArea)
3171
+ allBox.appendChild(section)
3172
+ // Only now is the textarea in the document, so `scrollHeight` is the
3173
+ // real content height; measuring before the append returned 0 and the
3174
+ // block stayed 32px tall with its text clipped.
3175
+ grow()
3176
+ }
3177
+ allBox.insertBefore(nav, allBox.firstChild)
3178
+ box.appendChild(allBox)
3179
+ area.style.display = 'none'
3180
+ }
3181
+
3182
+ // Input parity with the composer: a screenshot pasted from the clipboard
3183
+ // and files dropped onto the box join the same chip list as the picker.
3184
+ // Reply edits cannot carry blocks, so the handlers exist only for prompts
3185
+ // whose host proved it can keep them.
3186
+ if (target.mode === 'prompt' && view.attachmentsOk === true) {
3187
+ area.addEventListener('paste', (event) => {
3188
+ const files = filesFromData(event.clipboardData)
3189
+ if (files.length === 0) return
3190
+ event.preventDefault()
3191
+ controller.attachFiles(files)
3192
+ })
3193
+ box.addEventListener('dragover', (event) => {
3194
+ event.preventDefault()
3195
+ box.dataset.dshetDrop = '1'
3196
+ })
3197
+ box.addEventListener('dragleave', () => {
3198
+ delete box.dataset.dshetDrop
3199
+ })
3200
+ box.addEventListener('drop', (event) => {
3201
+ event.preventDefault()
3202
+ delete box.dataset.dshetDrop
3203
+ const files = filesFromData(event.dataTransfer)
3204
+ if (files.length > 0) controller.attachFiles(files)
3205
+ })
3206
+ }
3207
+
2391
3208
  // The blocks this message carries, each one a chip the user can remove,
2392
3209
  // and the entry that adds another. Drawn only when the host answered that
2393
3210
  // it can admit what a chip promises - otherwise the warning below states
@@ -2427,12 +3244,20 @@ window.__ModuleLoader__.load({
2427
3244
 
2428
3245
  const footer = document.createElement('div')
2429
3246
  footer.className = 'dshet-footer'
3247
+ const footerActions = document.createElement('div')
3248
+ footerActions.className = 'dshet-footer-actions'
2430
3249
 
2431
3250
  const cancel = document.createElement('button')
2432
3251
  cancel.type = 'button'
2433
- cancel.className = 'dshet-btn'
2434
- cancel.textContent = t('editor.cancel')
2435
- cancel.title = `${t('editor.cancel')} (Esc)`
3252
+ cancel.className = 'dshet-btn dshet-btn-icon dshet-btn-cancel'
3253
+ const cancelLabel = t('editor.cancel')
3254
+ cancel.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 3.5l9 9" stroke="currentColor"/><path d="M12.5 3.5l-9 9" stroke="currentColor"/></svg>'
3255
+ const cancelText = document.createElement('span')
3256
+ cancelText.className = 'dshet-sr-only'
3257
+ cancelText.textContent = cancelLabel
3258
+ cancel.appendChild(cancelText)
3259
+ cancel.title = `${cancelLabel} (Esc)`
3260
+ cancel.setAttribute('aria-label', cancelLabel)
2436
3261
  cancel.disabled = view.pending
2437
3262
  pressable(cancel, () => {
2438
3263
  if (view.confirming) controller.back()
@@ -2441,20 +3266,29 @@ window.__ModuleLoader__.load({
2441
3266
 
2442
3267
  const submit = document.createElement('button')
2443
3268
  submit.type = 'button'
2444
- submit.className = 'dshet-btn dshet-btn-primary'
2445
- submit.textContent = view.pending
3269
+ submit.className = 'dshet-btn dshet-btn-primary dshet-btn-save'
3270
+ const submitLabel = view.pending
2446
3271
  ? t('editor.pending')
2447
3272
  : view.confirming
2448
3273
  ? t('editor.review')
2449
3274
  : texts.save
2450
- submit.title = `${submit.textContent} (Enter)`
3275
+ // The official composer uses a bare double-check for confirm, not a black
3276
+ // pill. The label stays in the accessible name (and in the test-visible
3277
+ // textContent) through a visually hidden span.
3278
+ submit.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12.5l4.5 4.5L18 6.5"/><path d="M10 12.5l4.5 4.5L24 6.5"/></svg>'
3279
+ const submitText = document.createElement('span')
3280
+ submitText.className = 'dshet-sr-only'
3281
+ submitText.textContent = submitLabel
3282
+ submit.appendChild(submitText)
3283
+ submit.title = `${submitLabel} (Enter)`
3284
+ submit.setAttribute('aria-label', submitLabel)
2451
3285
  submit.disabled = view.pending
2452
3286
  pressable(submit, () => {
2453
3287
  if (view.confirming) controller.confirm()
2454
3288
  else controller.review()
2455
3289
  })
2456
3290
 
2457
- footer.appendChild(cancel)
3291
+ footerActions.appendChild(cancel)
2458
3292
  // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
2459
3293
  // only when that plugin is mounted, only for a prompt edit, and only
2460
3294
  // outside the confirmation step - the intent is carried through it. Save
@@ -2463,20 +3297,49 @@ window.__ModuleLoader__.load({
2463
3297
  if (target.mode === 'prompt' && view.siblingRerun === 'present' && view.confirming !== true) {
2464
3298
  const rerun = document.createElement('button')
2465
3299
  rerun.type = 'button'
2466
- rerun.className = 'dshet-btn dshet-rerun'
2467
- rerun.textContent = t('editor.rerun')
3300
+ rerun.className = 'dshet-btn dshet-btn-icon dshet-rerun'
3301
+ const rerunLabel = t('editor.rerun')
3302
+ rerun.innerHTML = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke-width="1.2"><path d="M13.65 2.6v2.9h-2.9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.3 8a5.3 5.3 0 1 1-1.55-3.75l1.9 1.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>'
3303
+ const rerunText = document.createElement('span')
3304
+ rerunText.className = 'dshet-sr-only'
3305
+ rerunText.textContent = rerunLabel
3306
+ rerun.appendChild(rerunText)
3307
+ rerun.title = rerunLabel
3308
+ rerun.setAttribute('aria-label', rerunLabel)
2468
3309
  rerun.disabled = view.pending
2469
3310
  pressable(rerun, () => controller.reviewRerun())
2470
- footer.appendChild(rerun)
3311
+ footerActions.appendChild(rerun)
3312
+ }
3313
+ footerActions.appendChild(submit)
3314
+ footer.appendChild(footerActions)
3315
+ const attachRow = typeof box.querySelector === 'function' ? box.querySelector('.dshet-attach-row') : null
3316
+ if (attachRow !== null && attachRow !== undefined) {
3317
+ attachRow.style.position = 'static'
3318
+ attachRow.style.left = ''
3319
+ attachRow.style.bottom = ''
3320
+ attachRow.style.margin = '0'
3321
+ footer.insertBefore(attachRow, footer.firstChild)
3322
+ }
3323
+ // The block picker shares the control band and sits at its far right.
3324
+ const pickerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-pick') : null
3325
+ if (pickerEl !== null && pickerEl !== undefined) {
3326
+ pickerEl.style.position = 'static'
3327
+ pickerEl.style.left = ''
3328
+ pickerEl.style.top = ''
3329
+ pickerEl.style.marginLeft = 'auto'
3330
+ pickerEl.style.marginRight = '0'
3331
+ pickerEl.style.display = 'flex'
3332
+ pickerEl.style.flex = '0 0 auto'
3333
+ pickerEl.style.visibility = ''
3334
+ footer.appendChild(pickerEl)
2471
3335
  }
2472
- footer.appendChild(submit)
2473
3336
  box.appendChild(footer)
2474
3337
 
2475
3338
  controller.editorBox = box
2476
3339
  controller.editorRow = row
2477
3340
  controller.editorElement = area
3341
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2478
3342
  fitEditorHeight(area)
2479
- placeEditor(row, box, controller.editorAnchor)
2480
3343
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
2481
3344
  try {
2482
3345
  area.focus()
@@ -2820,6 +3683,8 @@ window.__ModuleLoader__.load({
2820
3683
  const animate = controller.consumeAnimate()
2821
3684
  const rows = document.querySelectorAll('[data-chat-flow-key]')
2822
3685
  syncEditableSet(controller, rows.length)
3686
+ let editorCandidate = null
3687
+ let editorRank = -1
2823
3688
  for (const row of rows) {
2824
3689
  if (!(row instanceof HTMLElement)) continue
2825
3690
  const key = row.getAttribute('data-chat-flow-key')
@@ -2872,7 +3737,20 @@ window.__ModuleLoader__.load({
2872
3737
  (seqsFor(node).includes(editing.seq) ||
2873
3738
  seqsFor(node).some((seq) => headRevision(seq, view.revisions) === editing.seq))
2874
3739
  if (coversEditing && (!hidden || row.style.display !== 'none')) {
2875
- renderEditor(row, controller, view, t)
3740
+ const rank = editorRowRank(row, editing.mode)
3741
+ if (rank > editorRank) {
3742
+ editorRank = rank
3743
+ editorCandidate = row
3744
+ }
3745
+ }
3746
+ }
3747
+ if (view.editing !== null) {
3748
+ if (editorCandidate !== null) {
3749
+ renderEditor(editorCandidate, controller, view, t)
3750
+ } else {
3751
+ clearEditor()
3752
+ controller.editorBox = null
3753
+ controller.editorRow = null
2876
3754
  }
2877
3755
  }
2878
3756
  if (view.editing === null) {
@@ -3001,23 +3879,51 @@ window.__ModuleLoader__.load({
3001
3879
  && target.closest('.dshet-editor') !== null
3002
3880
  if (!inside) controller.editorFocus = false
3003
3881
  }
3882
+ let placementScheduled = false
3883
+ let lastPlacementKey = ''
3004
3884
  const keepPlaced = () => {
3005
- const row = controller.editorRow
3006
- const box = controller.editorBox
3007
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorAnchor)
3885
+ if (placementScheduled) return
3886
+ placementScheduled = true
3887
+ const run = () => {
3888
+ placementScheduled = false
3889
+ const row = controller.editorRow
3890
+ const box = controller.editorBox
3891
+ if (row === null || box === null || box.isConnected !== true) return
3892
+ const rect = row.getBoundingClientRect()
3893
+ const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0
3894
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
3895
+ const key = `${Math.round(rect.top)}:${Math.round(rect.height)}:${Math.round(typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0)}:${viewportWidth}x${viewportHeight}:${controller.editorBlockKind}:${controller.editorAll === true ? 'all' : ''}`
3896
+ if (key === lastPlacementKey) return
3897
+ lastPlacementKey = key
3898
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
3899
+ }
3900
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
3901
+ else window.setTimeout(run, 16)
3902
+ }
3903
+ const onResize = () => {
3904
+ // A window resize (or a fullscreen toggle / zoom) moves the composer
3905
+ // and the conversation edges; the cached band must not survive it.
3906
+ conversationBoundsCache = null
3907
+ keepPlaced()
3008
3908
  }
3009
3909
  document.addEventListener('pointerdown', onPointerDown, true)
3010
3910
  // Scroll events do not bubble; capture reaches the inner scroller too.
3011
3911
  document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
3012
- window.addEventListener('resize', keepPlaced)
3912
+ window.addEventListener('resize', onResize)
3913
+ let resizeObserver = null
3914
+ if (typeof ResizeObserver === 'function') {
3915
+ resizeObserver = new ResizeObserver(onResize)
3916
+ try { resizeObserver.observe(document.body) } catch {}
3917
+ }
3013
3918
  return () => {
3014
3919
  document.removeEventListener('pointerdown', onPointerDown, true)
3015
3920
  document.removeEventListener('scroll', keepPlaced, true)
3016
- window.removeEventListener('resize', keepPlaced)
3921
+ window.removeEventListener('resize', onResize)
3922
+ if (resizeObserver !== null) resizeObserver.disconnect()
3017
3923
  }
3018
3924
  }, [controller])
3019
3925
 
3020
- react.useEffect(() => {
3926
+ useBrowserLayoutEffect(() => {
3021
3927
  if (snapshot === undefined) return undefined
3022
3928
  let scheduled = false
3023
3929
  const run = () => {
@@ -3029,7 +3935,33 @@ window.__ModuleLoader__.load({
3029
3935
  applyDom(snapshot, live, controller, t)
3030
3936
  }
3031
3937
  run()
3032
- const observer = new MutationObserver(() => {
3938
+ const observer = new MutationObserver((records) => {
3939
+ // Our own editor writes attributes and text while the user types;
3940
+ // feeding those back into a full document pass is what made typing
3941
+ // and streaming compete for the same frames.
3942
+ let relevant = false
3943
+ let lostOurAction = false
3944
+ for (const record of records) {
3945
+ const target = record.target
3946
+ if (target !== null && target !== undefined && typeof target.closest === 'function' && target.closest('.dshet-layer') !== null) continue
3947
+ relevant = true
3948
+ if (record.removedNodes && record.removedNodes.length > 0) {
3949
+ for (const node of record.removedNodes) {
3950
+ if (node === null || node === undefined) continue
3951
+ if (typeof node.closest === 'function' && node.closest('.dshet-action') !== null) lostOurAction = true
3952
+ else if (typeof node.querySelector === 'function' && node.querySelector('.dshet-action') !== null) lostOurAction = true
3953
+ }
3954
+ }
3955
+ break
3956
+ }
3957
+ if (!relevant) return
3958
+ // The host rebuilt a row and our button went with it: put it back in
3959
+ // the same task instead of waiting a frame for the rAF pass.
3960
+ if (lostOurAction) {
3961
+ scheduled = false
3962
+ run()
3963
+ return
3964
+ }
3033
3965
  if (scheduled) return
3034
3966
  scheduled = true
3035
3967
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
@@ -3136,6 +4068,192 @@ window.__ModuleLoader__.load({
3136
4068
  editorLayer = null
3137
4069
  }
3138
4070
 
4071
+ // --- editor-style preference ----------------------------------------------
4072
+ //
4073
+ // The style is a browser preference so the manager page and the editor read
4074
+ // one value with no host round-trip. The host config's editorStyle is the
4075
+ // default when nothing has been chosen here.
4076
+ const EDITOR_STYLE_KEY = 'dsh-edit-turn/editorStyle'
4077
+ const EDITOR_STYLES = ['frame', 'bubble', 'center']
4078
+
4079
+ function readStoredEditorStyle() {
4080
+ try {
4081
+ if (typeof localStorage === 'undefined') return null
4082
+ const value = localStorage.getItem(EDITOR_STYLE_KEY)
4083
+ return EDITOR_STYLES.includes(value) ? value : null
4084
+ } catch {
4085
+ return null
4086
+ }
4087
+ }
4088
+
4089
+ let editorStylePreference = readStoredEditorStyle() ?? 'frame'
4090
+ const EDITOR_PLACEMENT_KEY = 'dsh-edit-turn/editorPlacement'
4091
+ const EDITOR_PLACEMENTS = ['overlay', 'inline', 'legacy']
4092
+
4093
+ function readStoredEditorPlacement() {
4094
+ try {
4095
+ if (typeof localStorage === 'undefined') return null
4096
+ const value = localStorage.getItem(EDITOR_PLACEMENT_KEY)
4097
+ return EDITOR_PLACEMENTS.includes(value) ? value : null
4098
+ } catch {
4099
+ return null
4100
+ }
4101
+ }
4102
+
4103
+ let editorPlacementPreference = readStoredEditorPlacement() ?? 'overlay'
4104
+ const EDITOR_HEIGHT_KEY = 'dsh-edit-turn/editorHeight'
4105
+ const EDITOR_HEIGHTS = ['max', 'content', 'fill']
4106
+
4107
+ function readStoredEditorHeight() {
4108
+ try {
4109
+ if (typeof localStorage === 'undefined') return null
4110
+ const value = localStorage.getItem(EDITOR_HEIGHT_KEY)
4111
+ return EDITOR_HEIGHTS.includes(value) ? value : null
4112
+ } catch {
4113
+ return null
4114
+ }
4115
+ }
4116
+
4117
+ let editorHeightPreference = readStoredEditorHeight() ?? 'fill'
4118
+ const editorHeightListeners = new Set()
4119
+
4120
+ function subscribeEditorHeight(listener) {
4121
+ editorHeightListeners.add(listener)
4122
+ return () => editorHeightListeners.delete(listener)
4123
+ }
4124
+
4125
+ function setEditorHeightPreference(mode) {
4126
+ if (!EDITOR_HEIGHTS.includes(mode)) return
4127
+ editorHeightPreference = mode
4128
+ try {
4129
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_HEIGHT_KEY, mode)
4130
+ } catch {}
4131
+ for (const listener of [...editorHeightListeners]) {
4132
+ try { listener() } catch {}
4133
+ }
4134
+ }
4135
+
4136
+ const editorPlacementListeners = new Set()
4137
+
4138
+ function subscribeEditorPlacement(listener) {
4139
+ editorPlacementListeners.add(listener)
4140
+ return () => editorPlacementListeners.delete(listener)
4141
+ }
4142
+
4143
+ function setEditorPlacementPreference(placement) {
4144
+ if (!EDITOR_PLACEMENTS.includes(placement)) return
4145
+ editorPlacementPreference = placement
4146
+ try {
4147
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_PLACEMENT_KEY, placement)
4148
+ } catch {
4149
+ /* private mode keeps the in-memory choice */
4150
+ }
4151
+ for (const listener of [...editorPlacementListeners]) {
4152
+ try {
4153
+ listener()
4154
+ } catch {}
4155
+ }
4156
+ }
4157
+
4158
+ const editorStyleListeners = new Set()
4159
+
4160
+ function subscribeEditorStyle(listener) {
4161
+ editorStyleListeners.add(listener)
4162
+ return () => editorStyleListeners.delete(listener)
4163
+ }
4164
+
4165
+ function setEditorStylePreference(style) {
4166
+ if (!EDITOR_STYLES.includes(style)) return
4167
+ editorStylePreference = style
4168
+ try {
4169
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_STYLE_KEY, style)
4170
+ } catch {
4171
+ /* private mode keeps the in-memory choice */
4172
+ }
4173
+ for (const listener of [...editorStyleListeners]) {
4174
+ try {
4175
+ listener()
4176
+ } catch {}
4177
+ }
4178
+ }
4179
+
4180
+ /**
4181
+ * The component row's configure page (`plugins.row.config`), the same slot
4182
+ * dsh-delete-turn uses. The manager's summary asks for a one-liner; the page
4183
+ * itself renders the three-way picker.
4184
+ */
4185
+ function EditorStyleOption({ view, t }) {
4186
+ const tr = typeof t === 'function' ? t : (key) => key
4187
+ const [style, setStyle] = react.useState(editorStylePreference)
4188
+ const [placement, setPlacement] = react.useState(editorPlacementPreference)
4189
+ const [heightMode, setHeightMode] = react.useState(editorHeightPreference)
4190
+ react.useEffect(() => subscribeEditorStyle(() => setStyle(editorStylePreference)), [])
4191
+ react.useEffect(() => subscribeEditorPlacement(() => setPlacement(editorPlacementPreference)), [])
4192
+ react.useEffect(() => subscribeEditorHeight(() => setHeightMode(editorHeightPreference)), [])
4193
+ const summaryKey = style === 'bubble' ? 'config.summary.bubble' : style === 'center' ? 'config.summary.center' : 'config.summary.frame'
4194
+ if (view === 'summary') return `${tr(summaryKey)} · ${tr(placement === 'inline' ? 'config.summary.inline' : placement === 'legacy' ? 'config.summary.legacy' : 'config.summary.overlay')} · ${tr(heightMode === 'content' ? 'config.summary.height.content' : heightMode === 'fill' ? 'config.summary.height.fill' : 'config.summary.height.max')}`
4195
+ const hintKey = style === 'bubble' ? 'config.hint.bubble' : style === 'center' ? 'config.hint.center' : 'config.hint.frame'
4196
+ const placementHint = placement === 'inline' ? 'config.hint.inline' : placement === 'legacy' ? 'config.hint.legacy' : 'config.hint.overlay'
4197
+ const heightHint = heightMode === 'content' ? 'config.hint.height.content' : heightMode === 'fill' ? 'config.hint.height.fill' : 'config.hint.height.max'
4198
+ const choices = [
4199
+ ['frame', 'style.frame'],
4200
+ ['bubble', 'style.bubble'],
4201
+ ['center', 'style.center'],
4202
+ ]
4203
+ return jsxs('div', {
4204
+ className: 'dshet-config',
4205
+ children: [
4206
+ jsxs('label', {
4207
+ className: 'dshet-config-row',
4208
+ children: [
4209
+ jsx('span', { children: tr('config.style') }),
4210
+ jsx('select', {
4211
+ className: 'dshet-select',
4212
+ value: style,
4213
+ onChange: (event) => setEditorStylePreference(event.target.value),
4214
+ children: choices.map(([value, key]) => jsx('option', { value, children: tr(key) }, value)),
4215
+ }),
4216
+ ],
4217
+ }),
4218
+ jsx('p', { className: 'dshet-config-hint', children: tr(hintKey) }),
4219
+ jsxs('label', {
4220
+ className: 'dshet-config-row',
4221
+ children: [
4222
+ jsx('span', { children: tr('config.placement') }),
4223
+ jsx('select', {
4224
+ className: 'dshet-select',
4225
+ value: placement,
4226
+ onChange: (event) => setEditorPlacementPreference(event.target.value),
4227
+ children: [
4228
+ jsx('option', { value: 'overlay', children: tr('placement.overlay') }),
4229
+ jsx('option', { value: 'inline', children: tr('placement.inline') }),
4230
+ jsx('option', { value: 'legacy', children: tr('placement.legacy') }),
4231
+ ],
4232
+ }),
4233
+ ],
4234
+ }),
4235
+ jsx('p', { className: 'dshet-config-hint', children: tr(placementHint) }),
4236
+ jsxs('label', {
4237
+ className: 'dshet-config-row',
4238
+ children: [
4239
+ jsx('span', { children: tr('config.height') }),
4240
+ jsx('select', {
4241
+ className: 'dshet-select',
4242
+ value: heightMode,
4243
+ onChange: (event) => setEditorHeightPreference(event.target.value),
4244
+ children: [
4245
+ jsx('option', { value: 'max', children: tr('height.max') }),
4246
+ jsx('option', { value: 'content', children: tr('height.content') }),
4247
+ jsx('option', { value: 'fill', children: tr('height.fill') }),
4248
+ ],
4249
+ }),
4250
+ ],
4251
+ }),
4252
+ jsx('p', { className: 'dshet-config-hint', children: tr(heightHint) }),
4253
+ ],
4254
+ })
4255
+ }
4256
+
3139
4257
  // --- plugin ---------------------------------------------------------------
3140
4258
 
3141
4259
  function apply(ctx) {
@@ -3190,6 +4308,20 @@ window.__ModuleLoader__.load({
3190
4308
  ),
3191
4309
  )
3192
4310
 
4311
+ // The row's configure arrow in the plugin manager. Without this the
4312
+ // component page is a read-only overview (the card cannot be opened); the
4313
+ // key is the manager's `<bundle package>#<row id>` contract.
4314
+ ctx.slots.inject('plugins.row.config', () =>
4315
+ ctx.slots.register(
4316
+ {
4317
+ name: 'plugins.row.config',
4318
+ key: 'dsh-edit-turn#dsh-edit-turn',
4319
+ locale: NS,
4320
+ },
4321
+ EditorStyleOption,
4322
+ ),
4323
+ )
4324
+
3193
4325
  ctx.slots.inject('conversation.input.overlay', () =>
3194
4326
  ctx.slots.register(
3195
4327
  {