dsh-edit-turn 0.3.3 → 0.5.2

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.2'
46
49
 
47
50
  // --- copy -----------------------------------------------------------------
48
51
 
@@ -65,11 +68,41 @@ 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': '只改这一块;工具调用、其余内容和后续对话保持不变。',
71
105
  'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
72
- 'editor.patch-note': '思维链已改写(模型可见)',
73
106
  'editor.review': '确认执行',
74
107
  'editor.cancel': '取消',
75
108
  'editor.pending': '正在保存…',
@@ -98,6 +131,7 @@ window.__ModuleLoader__.load({
98
131
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
99
132
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
100
133
  'error.internal': '服务器内部错误,请稍后重试。',
134
+ 'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
101
135
  'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
102
136
  'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
103
137
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
@@ -120,11 +154,41 @@ 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.',
126
191
  'confirm.body-block': 'Confirming replaces only this block; tool calls, the rest of the reply and later turns stay. The original session log is never rewritten.',
127
- 'editor.patch-note': 'Reasoning patched (model-visible)',
128
192
  'editor.review': 'Confirm',
129
193
  'editor.cancel': 'Cancel',
130
194
  'editor.pending': 'Working...',
@@ -153,6 +217,7 @@ window.__ModuleLoader__.load({
153
217
  'error.already-retired': 'This turn is no longer part of the current context.',
154
218
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
155
219
  'error.internal': 'The host hit an internal error; try again shortly.',
220
+ 'error.projection-missing': 'The edit projection is not registered; restart DSH and retry.',
156
221
  'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
157
222
  'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
158
223
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
@@ -172,6 +237,92 @@ window.__ModuleLoader__.load({
172
237
  }
173
238
 
174
239
  // --- style ----------------------------------------------------------------
240
+ /**
241
+ * Take the active skin's own surfaces when they are actually opaque.
242
+ *
243
+ * A skin may paint its surfaces translucent so artwork shows through, and it
244
+ * compensates with its own text halo - which a plugin element does not have.
245
+ * So the theme's surfaces are adopted only after resolving them to a real
246
+ * colour and finding them opaque; a translucent skin leaves this editor on
247
+ * its own opaque palette. Colours, radii and shadows need no such guard:
248
+ * those are hue and geometry, and they survive any skin.
249
+ *
250
+ * The result is written as inline variables on `body`, which beats both the
251
+ * `:root` defaults and the dark-theme rule below them.
252
+ *
253
+ * @returns nothing; the stylesheet defaults stand when no probe is possible.
254
+ */
255
+ function resolveSkinPalette() {
256
+ if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
257
+ if (typeof getComputedStyle !== 'function') return
258
+ const probe = document.createElement('span')
259
+ probe.setAttribute('aria-hidden', 'true')
260
+ probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
261
+ document.body.appendChild(probe)
262
+ const read = (name) => {
263
+ try {
264
+ probe.style.color = `var(${name})`
265
+ const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
266
+ if (match === null) return null
267
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
268
+ if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
269
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+ const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
275
+ const style = document.body.style
276
+ const setVar = (name, value) => {
277
+ if (style === undefined || style === null) return
278
+ if (typeof style.setProperty === 'function') style.setProperty(name, value)
279
+ else style[name] = value
280
+ }
281
+ const clearVar = (name) => {
282
+ if (style === undefined || style === null) return
283
+ if (typeof style.removeProperty === 'function') style.removeProperty(name)
284
+ else delete style[name]
285
+ }
286
+ try {
287
+ const panel = read('--dsw-alias-bg-layer-1')
288
+ const layer2 = read('--dsw-alias-bg-layer-2')
289
+ const accent = read('--dsw-alias-button-primary-fill')
290
+ // The skin's surface HUE is taken; its transparency is not. A skin paints
291
+ // its own elements translucent over artwork and gives them a text halo -
292
+ // a plugin element has none, so the same colour is adopted at full
293
+ // opacity. That keeps a dark artwork skin dark (instead of falling back
294
+ // to this plugin's light palette, which is what a skin that never sets
295
+ // `data-ds-dark-theme` used to get) without ever painting text on art.
296
+ const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
297
+ const panelSolid = solid(panel)
298
+ if (panelSolid === null) {
299
+ clearVar('--dshet-panel')
300
+ clearVar('--dshet-chip')
301
+ clearVar('--dshet-field')
302
+ } else {
303
+ const fieldSolid = solid(layer2) ?? panelSolid
304
+ setVar('--dshet-panel', css(panelSolid))
305
+ setVar('--dshet-chip', css(panelSolid))
306
+ setVar('--dshet-field', css(fieldSolid))
307
+ }
308
+ if (accent === null) {
309
+ clearVar('--dshet-on-accent')
310
+ } else {
311
+ // The label that sits ON the accent fill: the platform exposes no
312
+ // inverse token here, so it is picked from the fill's own luminance.
313
+ const channel = (value) => {
314
+ const v = value / 255
315
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
316
+ }
317
+ const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
318
+ setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
319
+ }
320
+ } finally {
321
+ if (typeof probe.remove === 'function') probe.remove()
322
+ }
323
+ }
324
+
325
+
175
326
 
176
327
  // A skin is free to make the theme's surface colours translucent - that is
177
328
  // the point of one: the artwork shows through, and the skin compensates by
@@ -186,8 +337,21 @@ window.__ModuleLoader__.load({
186
337
  // UI packages use. Theme variables are still used for *colours* (hue,
187
338
  // semantic tones) - those survive a skin; it is alpha that does not.
188
339
  const CSS = [
189
- ':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}',
190
- '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)}',
340
+ // Colours, radii and shadows come from the active theme's own tokens, so a
341
+ // skin restyles this editor the way it restyles the rest of the app; every
342
+ // one of them falls back to the value this plugin shipped with. The palette
343
+ // hangs off `body`, not `:root`: a custom property substitutes against the
344
+ // values in scope where it is DEFINED, and the theme declares its tokens on
345
+ // body - on `:root` every reference would silently fall back. Only the
346
+ // SURFACES stay namespaced: a skin is free to make its own surfaces
347
+ // translucent (that is the point of one), and a plugin's element has no
348
+ // artwork-aware halo to stay readable behind. The surfaces are taken from
349
+ // the theme too, but only when a runtime probe finds them opaque (see
350
+ // resolveSkinPalette).
351
+ 'body{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:var(--dsw-alias-border-l2,rgba(16,24,40,.16));--dshet-ink:var(--dsw-alias-label-primary,#0f1524);--dshet-ink-dim:var(--dsw-alias-label-secondary,#4b5872);--dshet-ink-soft:var(--dsw-alias-label-tertiary,#81858c);--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(9,18,40,.22));--dshet-accent:var(--dsw-alias-button-primary-fill,#4d6bfe);--dshet-on-accent:#fff;--dshet-bubble:var(--dsw-specific-bubble,var(--dshet-field));--dshet-radius-panel:var(--dsw-radius-panel,16px);--dshet-radius-xl:var(--dsw-radius-xl,16px);--dshet-radius-lg:var(--dsw-radius-lg,16px);--dshet-radius-md:var(--dsw-radius-md,12px);--dshet-radius-sm:var(--dsw-radius-sm,8px)}',
352
+ '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-hover:rgba(255,255,255,.11);--dshet-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.5))}',
353
+ // A hover wash that follows the skin's ink instead of a fixed grey.
354
+ '@supports (color:color-mix(in srgb,red,blue)){body{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent)}body[data-ds-dark-theme]{--dshet-hover:color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent)}}',
191
355
  // The pencil sits among the platform's own action icons, so it takes the
192
356
  // platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
193
357
  // read as a different, "active" icon next to the neutral grey ones. The
@@ -213,7 +377,7 @@ window.__ModuleLoader__.load({
213
377
  // inherit picks up the row's 16px, fixed ignores the reader's content
214
378
  // font setting, and either way the rewritten text changed size against
215
379
  // the bubble it replaces.
216
- '.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;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
380
+ '.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:var(--dshet-radius-xl);background:var(--dshet-bubble);color:var(--dshet-ink);font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
217
381
  '.dshet-revision:empty{display:none}',
218
382
  '.dshet-revision{position:relative}',
219
383
  // Fallback only: a row with no action bar to keep is displayed away
@@ -222,6 +386,9 @@ window.__ModuleLoader__.load({
222
386
  // positioned button inside the bubble used to land on the last line, which
223
387
  // is how the rewritten prompt got covered up.
224
388
  '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
389
+ '.dshet-revision-text{display:block}',
390
+ '.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
391
+ '.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:var(--dshet-radius-sm);background:var(--dshet-chip);font-size:12px;line-height:18px}',
225
392
  '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
226
393
  '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
227
394
  // A sibling plugin can append its own button (dsh-delete-turn's bin) into
@@ -260,11 +427,21 @@ window.__ModuleLoader__.load({
260
427
  // composition and its clicks a piece at a time. The layer covers the
261
428
  // viewport but only the box itself takes pointer events, and the box is
262
429
  // positioned under its row by `placeEditor`.
430
+ '.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
263
431
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
264
432
  '.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}',
433
+ '.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:var(--dshet-radius-panel);background:var(--dshet-panel);backdrop-filter:blur(var(--dsh-input-card-blur,18px)) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
434
+ '.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
266
435
  '.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}',
436
+ '.dshet-editor[data-dshet-inplace="1"]{overflow:hidden;border:1.5px solid var(--dsw-alias-button-primary-fill,#4a7dff);border-radius:var(--dshet-radius-md);background:var(--dshet-panel);backdrop-filter:none;box-shadow:none}',
437
+ '.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}',
438
+ '.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 var(--dshet-radius-md) var(--dshet-radius-md);gap:6px}',
439
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
440
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
441
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-note,.dshet-editor[data-dshet-inplace="1"] .dshet-warn{position:absolute;left:8px;bottom:6px;max-width:60%;margin:0;background:var(--dshet-panel);border-radius:8px;padding:2px 6px}',
442
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-error{position:absolute;left:8px;right:8px;bottom:52px;max-width:none;margin:0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);box-shadow:var(--dshet-shadow);font-weight:500}',
443
+
444
+ '.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
445
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
269
446
  '.dshet-editor textarea:disabled{opacity:.65}',
270
447
  // The blocks this message carries: one chip each, drawn from the same
@@ -280,8 +457,9 @@ window.__ModuleLoader__.load({
280
457
  '.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
281
458
  '.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
282
459
  '.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}',
460
+ '.dshet-attach-row{position:absolute;left:12px;bottom:10px;z-index:2;display:flex;align-items:center;margin:0}',
461
+ '.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}',
462
+ '.dshet-attach svg{width:14px;height:14px;display:block;stroke:currentColor;fill:none;stroke-width:1.3;}',
285
463
  '.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
286
464
  '.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
287
465
  '.dshet-attach:disabled{cursor:default;opacity:.5}',
@@ -290,19 +468,46 @@ window.__ModuleLoader__.load({
290
468
  '.dshet-note{color:var(--dshet-ink-dim)}',
291
469
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
292
470
  '.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)}',
471
+ '.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 var(--dshet-radius-panel) var(--dshet-radius-panel)}',
472
+ '.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
473
+ '.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}',
474
+ '.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}',
475
+ '.dshet-all-nav button:hover{color:var(--dshet-ink)}',
476
+ '.dshet-all{position:absolute;left:8px;right:8px;top:6px;bottom:52px;overflow-y:auto;display:flex;flex-direction:column;padding:2px 4px 4px}',
477
+ '.dshet-all-block{display:flex;flex-direction:column;padding:6px 0 8px}',
478
+ '.dshet-all-block:first-child{padding-top:2px}',
479
+ '.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}',
480
+ '.dshet-all-head>span:first-child{background:var(--dshet-hover);border-radius:6px;padding:1px 6px;white-space:nowrap}',
481
+ '.dshet-all-line{flex:1;height:1px;background:var(--dshet-line);opacity:.55}',
482
+ '.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}',
483
+ '.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}',
484
+ '.dshet-editor .dshet-footer .dshet-btn{height:34px;box-sizing:border-box;padding:0 14px}',
294
485
  '.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
486
  '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
296
487
  '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
297
488
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
489
+ '.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c}',
490
+ '.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover)}',
491
+ '.dshet-btn-save svg{width:15px;height:15px;display:block;stroke:currentColor;fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
492
+ '.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
493
  '.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
494
  '.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)}',
301
- '.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}',
495
+ '.dshet-btn.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dshet-ink-soft);filter:none}',
496
+ '.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
497
+ '.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
498
+ '.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}',
499
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
500
+ '.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
501
+ '.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}',
502
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
503
+ '.dshet-pick{display:flex;align-items:center;gap:4px;padding:0;font-size:12px;color:var(--dshet-ink-dim)}',
504
+ '.dshet-config{display:flex;flex-direction:column;gap:10px}',
505
+ '.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
506
+ '.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
507
+ '.dshet-select{padding:3px 8px;border:1px solid var(--dshet-line);border-radius:var(--dshet-radius-sm);background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:12px;line-height:18px}',
508
+ '.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
509
+ '.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
302
510
  '[data-dshet-patch]{white-space:pre-wrap}',
303
- '.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
- '.dshet-patch-note summary{cursor:pointer}',
305
- '.dshet-patch-note-body{margin:6px 0 0;white-space:pre-wrap;color:var(--dshet-ink);font-size:12px;line-height:18px}',
306
511
  '.dshet-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dshet-line);border-radius:8px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dsw-alias-state-error-primary,#c93a31);font-size:12px;line-height:19px}',
307
512
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
308
513
  ].join('')
@@ -365,6 +570,8 @@ window.__ModuleLoader__.load({
365
570
  this.editorBox = null
366
571
  this.editorRow = null
367
572
  this.editorAnchor = null
573
+ this.editorMode = 'reply'
574
+ this.editorStyle = 'frame'
368
575
  this.editorFocus = false
369
576
  this.editorDraft = ''
370
577
  // The block list being edited: what the carrier will be rebuilt from.
@@ -506,6 +713,12 @@ window.__ModuleLoader__.load({
506
713
  // replies are indexed by it too.
507
714
  if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
508
715
  }
716
+ let preferredStyle = data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
717
+ ? data.config.editorStyle
718
+ : 'frame'
719
+ const savedStyle = readStoredEditorStyle()
720
+ if (savedStyle !== null) preferredStyle = savedStyle
721
+ this.editorStyle = preferredStyle
509
722
  this.publish({
510
723
  hidden,
511
724
  revisions,
@@ -515,7 +728,11 @@ window.__ModuleLoader__.load({
515
728
  patches,
516
729
  patchBySeq,
517
730
  replyMode: data.mode === 'patch' ? 'patch' : 'replay',
731
+ editorStyle: data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
732
+ ? data.config.editorStyle
733
+ : 'frame',
518
734
  surfaceReady: true,
735
+ busy: data.busy === true,
519
736
  loaded: true,
520
737
  loadError: false,
521
738
  confirmStep: !(data.config && data.config.confirm === false),
@@ -530,6 +747,14 @@ window.__ModuleLoader__.load({
530
747
  })
531
748
  .finally(() => {
532
749
  this.inflight = null
750
+ // While the session is replying, the editor stays open but cannot
751
+ // save; re-check so the banner clears itself when the turn ends.
752
+ if (this.view.busy === true && this.editor !== null) {
753
+ const token = (this.busyToken = (this.busyToken ?? 0) + 1)
754
+ window.setTimeout(() => {
755
+ if (this.busyToken === token && this.editor !== null) this.load()
756
+ }, 3000)
757
+ }
533
758
  })
534
759
  this.inflight = pending
535
760
  return pending
@@ -553,7 +778,11 @@ window.__ModuleLoader__.load({
553
778
  const current = same && typeof this.editorBlockIndex === 'number'
554
779
  ? replyBlocks.find((block) => block.index === this.editorBlockIndex)
555
780
  : undefined
556
- const chosen = current ?? requested ?? replyBlocks.find((block) => block.kind === 'text') ?? replyBlocks[0]
781
+ // A fresh open always lands on the answer text: a turn's block list is
782
+ // mostly reasoning now, and opening on a chain-of-thought is not what
783
+ // the pencil means. The picker is how the user goes to a thought.
784
+ const answerBlock = [...replyBlocks].reverse().find((block) => block.kind === 'text')
785
+ const chosen = (same ? current : undefined) ?? answerBlock ?? requested ?? replyBlocks[0]
557
786
  const normalized = replyBlocks.length > 0
558
787
  ? {
559
788
  ...target,
@@ -564,12 +793,16 @@ window.__ModuleLoader__.load({
564
793
  }
565
794
  : target
566
795
  this.editor = normalized
796
+ this.editorMode = normalized.mode === 'prompt' ? 'prompt' : 'reply'
567
797
  this.editorAnchor = target.anchor !== undefined && target.anchor !== null && target.anchor.isConnected === true
568
798
  ? target.anchor
569
799
  : null
570
800
  this.editorBlocks = replyBlocks
571
801
  this.editorBlockIndex = chosen === undefined ? null : chosen.index
572
802
  this.editorBlockKind = chosen === undefined ? null : chosen.kind
803
+ this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
804
+ this.editorAll = false
805
+ this.editorAllDrafts = new Map()
573
806
  if (!same) {
574
807
  this.editorDraft = chosen === undefined ? target.text : chosen.text
575
808
  this.editorParts = blocksToParts(normalized, this.sessionId)
@@ -596,6 +829,8 @@ window.__ModuleLoader__.load({
596
829
  blocks: replyBlocks,
597
830
  blockIndex: this.editorBlockIndex,
598
831
  blockKind: this.editorBlockKind,
832
+ targetSeq: this.editorTargetSeq,
833
+ allBlocks: false,
599
834
  confirming: false,
600
835
  failure: null,
601
836
  })
@@ -629,17 +864,39 @@ window.__ModuleLoader__.load({
629
864
  // Pick another block of the same reply. The editor is rebuilt by the
630
865
  // block index in its marker; the draft becomes that block's current
631
866
  // (already projected) text.
632
- selectBlock(index) {
867
+ selectBlock(value) {
633
868
  if (this.view.pending) return
634
869
  const target = this.editor
635
870
  if (target === null || !Array.isArray(target.blocks)) return
636
- const block = target.blocks.find((entry) => entry.index === index)
637
- if (block === undefined || block.kind === undefined) return
871
+ const raw = String(value)
872
+ if (raw === 'all') {
873
+ this.editorAll = true
874
+ this.editorAllDrafts = new Map(target.blocks.map((block) => [`${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`, block.text]))
875
+ this.editorElement = null
876
+ this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
877
+ return
878
+ }
879
+ const split = raw.includes(':') ? raw.split(':') : ['', raw]
880
+ const seq = split[0] === '' ? null : Number(split[0])
881
+ const index = Number(split[1])
882
+ const block = target.blocks.find((entry) =>
883
+ entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
884
+ // Leaving the all-blocks view has to say so: the picker change alone left
885
+ // `allBlocks` true, so the box was redrawn as the all view again with a
886
+ // picker that said "正文" - two fields over a single-block selection.
887
+ this.editorAll = false
888
+ if (block === undefined || block.kind === undefined) {
889
+ // Nothing matched: still leave the all view, keeping the block that was
890
+ // selected before it, so the picker describes the box again.
891
+ this.publish({ allBlocks: false, failure: null })
892
+ return
893
+ }
638
894
  this.editorBlockIndex = block.index
639
895
  this.editorBlockKind = block.kind
896
+ this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
640
897
  this.editorDraft = block.text
641
898
  this.editorElement = null
642
- this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, failure: null })
899
+ this.publish({ draft: block.text, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
643
900
  }
644
901
 
645
902
  close() {
@@ -649,6 +906,7 @@ window.__ModuleLoader__.load({
649
906
  this.editorBox = null
650
907
  this.editorRow = null
651
908
  this.editorAnchor = null
909
+ this.editorMode = 'reply'
652
910
  this.editorFocus = false
653
911
  this.editor = null
654
912
  this.editorDraft = ''
@@ -658,6 +916,9 @@ window.__ModuleLoader__.load({
658
916
  this.editorBlocks = []
659
917
  this.editorBlockIndex = null
660
918
  this.editorBlockKind = null
919
+ this.editorTargetSeq = null
920
+ this.editorAll = false
921
+ this.editorAllDrafts = new Map()
661
922
  this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
662
923
  }
663
924
 
@@ -665,6 +926,13 @@ window.__ModuleLoader__.load({
665
926
  this.editorDraft = value
666
927
  }
667
928
 
929
+ setEditorStyle(style) {
930
+ if (!EDITOR_STYLES.includes(style)) return
931
+ setEditorStylePreference(style)
932
+ this.editorStyle = style
933
+ this.publish({ editorStyle: style })
934
+ }
935
+
668
936
  /**
669
937
  * One chip the user removed leaves the block list, and nothing else.
670
938
  *
@@ -730,6 +998,61 @@ window.__ModuleLoader__.load({
730
998
  }
731
999
  }
732
1000
 
1001
+ /** The all-blocks save: one request carrying every edited block. */
1002
+ async confirmAll(target) {
1003
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
1004
+ const drafts = this.editorAllDrafts instanceof Map ? this.editorAllDrafts : new Map()
1005
+ const targets = []
1006
+ for (const block of blocks) {
1007
+ const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
1008
+ const text = drafts.get(key)
1009
+ if (typeof text !== 'string' || text.trim() === '') {
1010
+ this.publish({ failure: 'empty' })
1011
+ return
1012
+ }
1013
+ if (text !== block.text) {
1014
+ targets.push({ seq: typeof block.seq === 'number' ? block.seq : target.seq, blockIndex: block.index, kind: block.kind, text })
1015
+ }
1016
+ }
1017
+ if (targets.length === 0) {
1018
+ this.publish({ failure: null })
1019
+ this.editor = null
1020
+ this.publish({ editing: null })
1021
+ return
1022
+ }
1023
+ this.publish({ pending: true, failure: null, failureDetail: null })
1024
+ try {
1025
+ const res = await fetch(`${ROUTE_PREFIX}/apply`, {
1026
+ method: 'POST',
1027
+ headers: { 'content-type': 'application/json' },
1028
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1029
+ ? { signal: AbortSignal.timeout(20000) }
1030
+ : {}),
1031
+ body: JSON.stringify({ sessionId: this.sessionId, targets }),
1032
+ })
1033
+ const data = await res.json().catch(() => ({}))
1034
+ if (!res.ok || !data.ok) {
1035
+ const code = data && data.code ? String(data.code) : 'generic'
1036
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1037
+ this.publish({ pending: false, failure: code, failureDetail: detail })
1038
+ return
1039
+ }
1040
+ this.editor = null
1041
+ this.editorDraft = ''
1042
+ this.editorAll = false
1043
+ this.editorAllDrafts = new Map()
1044
+ this.editorElement = null
1045
+ this.editorBox = null
1046
+ this.editorRow = null
1047
+ this.editorFocus = false
1048
+ this.confirming = false
1049
+ this.publish({ pending: false, editing: null, confirming: false, failure: null })
1050
+ this.load()
1051
+ } catch {
1052
+ this.publish({ pending: false, failure: 'generic' })
1053
+ }
1054
+ }
1055
+
733
1056
  review() {
734
1057
  this.pendingRerun = false
735
1058
  this.runReview()
@@ -765,6 +1088,9 @@ window.__ModuleLoader__.load({
765
1088
  if (target === null || this.view.pending) return
766
1089
  const rerunIntent = this.pendingRerun
767
1090
  this.pendingRerun = false
1091
+ if (this.editorAll === true) {
1092
+ return this.confirmAll(target)
1093
+ }
768
1094
  const text = this.editorDraft
769
1095
  if (text.trim() === '') {
770
1096
  this.publish({ failure: 'empty' })
@@ -777,11 +1103,16 @@ window.__ModuleLoader__.load({
777
1103
  const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
778
1104
  ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
779
1105
  : undefined
780
- this.publish({ pending: true, failure: null })
1106
+ this.publish({ pending: true, failure: null, failureDetail: null })
781
1107
  try {
782
1108
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
783
1109
  method: 'POST',
784
1110
  headers: { 'content-type': 'application/json' },
1111
+ // A hung host must not leave the editor pending forever with a
1112
+ // button that looks dead.
1113
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1114
+ ? { signal: AbortSignal.timeout(20000) }
1115
+ : {}),
785
1116
  body: JSON.stringify({
786
1117
  sessionId: this.sessionId,
787
1118
  seq: target.seq,
@@ -791,13 +1122,19 @@ window.__ModuleLoader__.load({
791
1122
  // The in-place reply path names one block; the host validates that
792
1123
  // it is still that kind at that index before writing the record.
793
1124
  ...(target.patch === true && typeof this.editorBlockIndex === 'number'
794
- ? { blockIndex: this.editorBlockIndex, kind: this.editorBlockKind }
1125
+ ? {
1126
+ blockIndex: this.editorBlockIndex,
1127
+ kind: this.editorBlockKind,
1128
+ ...(this.editorTargetSeq === null ? {} : { targetSeq: this.editorTargetSeq }),
1129
+ }
795
1130
  : {}),
796
1131
  }),
797
1132
  })
798
1133
  const data = await res.json().catch(() => ({}))
799
1134
  if (!res.ok || !data.ok) {
800
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1135
+ const code = data && data.code ? String(data.code) : 'generic'
1136
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1137
+ this.publish({ pending: false, failure: code, failureDetail: detail })
801
1138
  return
802
1139
  }
803
1140
  const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
@@ -1266,7 +1603,8 @@ window.__ModuleLoader__.load({
1266
1603
  }
1267
1604
  const entry = view.editable.get(headRevision(view.revisions.get(revisionSeq), view.revisions))
1268
1605
  const text = entry && typeof entry.text === 'string' ? entry.text : ''
1269
- if (text === '') {
1606
+ const extraBlocks = entry && Array.isArray(entry.blocks) ? entry.blocks.filter((block) => block.type !== 'text') : []
1607
+ if (text === '' && extraBlocks.length === 0) {
1270
1608
  if (existing !== null) existing.remove()
1271
1609
  releaseRowCopy(row)
1272
1610
  return node.kind !== 'user'
@@ -1279,7 +1617,22 @@ window.__ModuleLoader__.load({
1279
1617
  }
1280
1618
  }
1281
1619
  placeRevisionBubble(row, bubble)
1282
- if (bubble.textContent !== text) bubble.textContent = text
1620
+ // A rewritten prompt may still carry images/files. The carrier keeps
1621
+ // those blocks, the model reads them, and the editor lists them - the
1622
+ // standing-in bubble must show them too, or an edited image message looks
1623
+ // like the images were deleted.
1624
+ const signature = text + '|' + extraBlocks.map((block) =>
1625
+ `${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
1626
+ if (bubble.dataset.dshetRevisionContent !== signature) {
1627
+ bubble.textContent = ''
1628
+ const textNode = document.createElement('span')
1629
+ textNode.className = 'dshet-revision-text'
1630
+ textNode.textContent = text
1631
+ bubble.appendChild(textNode)
1632
+ const files = revisionAttachments(entry, controller)
1633
+ if (files !== null) bubble.appendChild(files)
1634
+ bubble.dataset.dshetRevisionContent = signature
1635
+ }
1283
1636
  // The edit entry goes where every other action of this row lives: the
1284
1637
  // platform's own bar, which the collapse keeps standing, at its right end
1285
1638
  // behind the clock and the copy button - the row the user reads as "this
@@ -1312,6 +1665,40 @@ window.__ModuleLoader__.load({
1312
1665
  return true
1313
1666
  }
1314
1667
 
1668
+ /**
1669
+ * The non-text blocks of a rewritten prompt, drawn under its text.
1670
+ *
1671
+ * These are the same blocks the editor lists as chips; the bytes come from
1672
+ * this plugin's attachment route, so a thumbnail works without a host API.
1673
+ */
1674
+ function revisionAttachments(entry, controller) {
1675
+ if (!entry || !Array.isArray(entry.blocks)) return null
1676
+ const blocks = entry.blocks.filter((block) => block.type !== 'text')
1677
+ if (blocks.length === 0) return null
1678
+ const strip = document.createElement('div')
1679
+ strip.className = 'dshet-revision-files'
1680
+ for (const block of blocks) {
1681
+ const item = document.createElement('div')
1682
+ item.className = 'dshet-revision-file'
1683
+ if (block.preview === true && typeof block.index === 'number') {
1684
+ const image = document.createElement('img')
1685
+ image.className = 'dshet-thumb'
1686
+ image.alt = ''
1687
+ image.src = previewUrl(controller.sessionId, entry.seq, block.index)
1688
+ image.addEventListener('error', () => {
1689
+ if (typeof image.remove === 'function') image.remove()
1690
+ })
1691
+ item.appendChild(image)
1692
+ }
1693
+ const label = document.createElement('span')
1694
+ label.className = 'dshet-chip-label'
1695
+ label.textContent = typeof block.name === 'string' && block.name !== '' ? block.name : block.type
1696
+ item.appendChild(label)
1697
+ strip.appendChild(item)
1698
+ }
1699
+ return strip
1700
+ }
1701
+
1315
1702
  // A copy of a live user bubble element, classes and all.
1316
1703
  // The key of the row a bubble stands in for. It is what makes a stray
1317
1704
  // bubble recognisable after the host's DOM has shifted around it: an
@@ -1957,6 +2344,49 @@ window.__ModuleLoader__.load({
1957
2344
  // Put the box under the row it belongs to: same width, just below its lower
1958
2345
  // edge. Called on render and whenever the page scrolls or resizes, since
1959
2346
  // the layer is fixed to the viewport.
2347
+ // Some host skins mark their editor chrome `!important`; only an inline
2348
+ // `!important` outranks that. The stub DOM has no `setProperty`, so it falls
2349
+ // back to a plain assignment there.
2350
+ function forceStyle(element, property, value) {
2351
+ if (element === null || element === undefined || element.style === undefined) return
2352
+ if (typeof element.style.setProperty === 'function') {
2353
+ element.style.setProperty(property, value, 'important')
2354
+ return
2355
+ }
2356
+ element.style[property] = value
2357
+ }
2358
+
2359
+ /**
2360
+ * The surface an in-place editor paints itself with, so the recorded text
2361
+ * underneath cannot show through the draft.
2362
+ *
2363
+ * The skin's own panel colour comes first. A skin that paints the app on
2364
+ * artwork usually leaves `body` transparent (the artwork is a layer behind
2365
+ * it), and the white fallback then put a white box on a night sky. The
2366
+ * palette keeps the skin's hue and drops its transparency, so what comes back
2367
+ * here is opaque by construction.
2368
+ *
2369
+ * @returns a CSS colour for the editor's own background.
2370
+ */
2371
+ function pageBackground() {
2372
+ try {
2373
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
2374
+ const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
2375
+ const match = /rgba?\(([^)]+)\)/.exec(panel)
2376
+ if (match !== null) {
2377
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
2378
+ const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
2379
+ if (opaque) return panel
2380
+ }
2381
+ const value = getComputedStyle(document.body).backgroundColor
2382
+ if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
2383
+ }
2384
+ } catch {
2385
+ /* fall through */
2386
+ }
2387
+ return '#ffffff'
2388
+ }
2389
+
1960
2390
  function viewportSize() {
1961
2391
  const win = typeof window === 'undefined' ? undefined : window
1962
2392
  const doc = typeof document === 'undefined' ? undefined : document
@@ -1973,41 +2403,437 @@ window.__ModuleLoader__.load({
1973
2403
  // past the bottom edge where nothing could reach it. Flip above the row when
1974
2404
  // there is room, otherwise pin the box to the bottom of the viewport and let
1975
2405
  // 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)
2406
+ /**
2407
+ * The bubble the editor stands in for.
2408
+ *
2409
+ * A prompt is a right-aligned input bubble; a reply is the output content
2410
+ * block. The editor takes that element's width and left edge and overlays its
2411
+ * position, so it reads as the message being edited instead of a floating
2412
+ * panel. Class hashes change between builds, so the suffixes are matched.
2413
+ */
2414
+ function bubbleFor(row, mode, blockKind = null) {
2415
+ let selectors
2416
+ if (mode === 'reply' && blockKind === 'reasoning') {
2417
+ selectors = ['details', '[class*="_reasoning"]', '[class*="_thinking"]', '[class*="reasoning"]']
2418
+ } else if (mode === 'reply') {
2419
+ selectors = ['[class*="_markdown"]', '[class*="_bubble"]', '[class*="messageBody"]']
2420
+ } else {
2421
+ selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]']
2422
+ }
2423
+ for (const selector of selectors) {
2424
+ let nodes = []
2425
+ try {
2426
+ nodes = [...row.querySelectorAll(selector)]
2427
+ } catch {
2428
+ nodes = []
2429
+ }
2430
+ let best = null
2431
+ for (const element of nodes) {
2432
+ if (typeof element.closest === 'function' && element.closest('.dshet-editor') !== null) continue
2433
+ const rect = element.getBoundingClientRect()
2434
+ if (rect.width < 80 || rect.height <= 0) continue
2435
+ if (best === null || rect.width * rect.height > best.rect.width * best.rect.height) best = { element, rect }
2436
+ }
2437
+ if (best !== null) return best
2438
+ }
1979
2439
  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
2440
+ return { element: row, rect }
2441
+ }
2442
+
2443
+ /**
2444
+ * Which row an editor should attach to when several cover the edited seq.
2445
+ *
2446
+ * A reply's seq appears in both the assistant-step row and the turn-tail
2447
+ * row that carries its action strip; the tail also covers the reply, so a
2448
+ * last-write-wins loop handed the editor the tail's little action bar and
2449
+ * the box came out message-sized instead of bubble-sized. The message row
2450
+ * always wins.
2451
+ */
2452
+ function editorRowRank(row, mode) {
2453
+ const kind = row.getAttribute('data-chat-flow-kind')
2454
+ if (mode === 'prompt') {
2455
+ if (kind === 'user') return 4
2456
+ if (kind === 'revision' || kind === 'user-revision') return 3
2457
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2458
+ return 2
1984
2459
  }
1985
- const vertical = anchorRect === null ? rect : anchorRect
2460
+ if (kind === 'assistant-step') return 4
2461
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2462
+ return 2
2463
+ }
2464
+
2465
+ /**
2466
+ * The vertical band the conversation owns: below its top, above the composer.
2467
+ *
2468
+ * This runs on every scroll frame, so the lookups are cached briefly: the
2469
+ * composer element and the conversation top do not move between frames, and
2470
+ * a global `[class*=...]` scan per frame is what made scrolling feel heavy.
2471
+ */
2472
+ let conversationBoundsCache = null
2473
+ let conversationBoundsAt = 0
2474
+ let composerElementCache = null
2475
+ function conversationBounds(row = null) {
2476
+ const now = Date.now()
2477
+ if (conversationBoundsCache !== null && now - conversationBoundsAt < 250) return conversationBoundsCache
2478
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
2479
+ const bounds = { top: 0, bottom: viewportHeight, composerLeft: 0, composerWidth: 0 }
2480
+ try {
2481
+ const conversation = document.querySelector('[data-slot="main.conversation"]')
2482
+ if (conversation !== null && typeof conversation.getBoundingClientRect === 'function') {
2483
+ bounds.top = conversation.getBoundingClientRect().top
2484
+ }
2485
+ } catch {}
2486
+ // The real scroll area is the nearest scrollable ancestor of the edited
2487
+ // row; the slot wrapper can start above the header, which let the box
2488
+ // cover the tab bar.
2489
+ try {
2490
+ let node = row !== null && row !== undefined ? row.parentElement : null
2491
+ while (node !== null && node !== undefined && node !== document.body) {
2492
+ const style = getComputedStyle(node)
2493
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.getBoundingClientRect().height > 120) {
2494
+ bounds.top = Math.max(bounds.top, node.getBoundingClientRect().top)
2495
+ break
2496
+ }
2497
+ node = node.parentElement
2498
+ }
2499
+ } catch {}
2500
+ // The composer stack has several wrappers with "composer" in the class;
2501
+ // the boundary is the LOWEST one that still sits above the viewport edge,
2502
+ // otherwise the box runs under the input box.
2503
+ try {
2504
+ // The composer CARD is the rounded surface that owns the input, not the
2505
+ // outer seat (which is wider and made the editor overhang). Walk up from
2506
+ // the input element to the first rounded, painted ancestor.
2507
+ let card = document.querySelector('textarea[placeholder], [contenteditable="true"]')
2508
+ while (card !== null && card !== undefined && card !== document.body) {
2509
+ const style = getComputedStyle(card)
2510
+ const radius = Number.parseFloat(style.borderTopLeftRadius)
2511
+ if (radius > 6 && style.backgroundColor !== 'rgba(0, 0, 0, 0)') break
2512
+ card = card.parentElement
2513
+ }
2514
+ let lowest = 0
2515
+ let lowestRect = null
2516
+ if (card !== null && card !== undefined && card !== document.body && typeof card.getBoundingClientRect === 'function') {
2517
+ const rect = card.getBoundingClientRect()
2518
+ if (rect.top > 0 && (viewportHeight === 0 || rect.top < viewportHeight)) {
2519
+ lowest = rect.top
2520
+ lowestRect = rect
2521
+ }
2522
+ }
2523
+ if (lowestRect === null) {
2524
+ const candidates = document.querySelectorAll('[data-slot="conversation.input"], [class*="composerSeat"], [class*="composer"]')
2525
+ for (const candidate of candidates) {
2526
+ if (typeof candidate.getBoundingClientRect !== 'function') continue
2527
+ const rect = candidate.getBoundingClientRect()
2528
+ if (rect.top > lowest && (viewportHeight === 0 || rect.top < viewportHeight - 40)) {
2529
+ lowest = rect.top
2530
+ lowestRect = rect
2531
+ }
2532
+ }
2533
+ }
2534
+ bounds.bottom = lowest > 0 ? lowest : Math.min(viewportHeight, viewportHeight - 120)
2535
+ if (lowestRect !== null) {
2536
+ bounds.composerLeft = lowestRect.left
2537
+ bounds.composerWidth = lowestRect.width
2538
+ }
2539
+ } catch {
2540
+ bounds.bottom = viewportHeight > 0 ? viewportHeight - 120 : viewportHeight
2541
+ }
2542
+ conversationBoundsCache = bounds
2543
+ conversationBoundsAt = now
2544
+ return bounds
2545
+ }
2546
+
2547
+ function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null) {
2548
+ const layer = ensureEditorLayer()
2549
+ if (box.parentElement !== layer) layer.appendChild(box)
2550
+ const placement = readStoredEditorPlacement() ?? 'overlay'
2551
+ const legacy = placement === 'legacy'
2552
+ const inline = placement === 'inline'
2553
+ // Every fixed mode is clamped to the conversation band by default; the
2554
+ // in-page mode keeps its page coordinates and page scroll.
2555
+ const clamped = !inline
2556
+ if (legacy) style = 'frame'
2557
+ if (inline) layer.dataset.dshetInline = '1'
2558
+ else delete layer.dataset.dshetInline
2559
+ const rowRect = row.getBoundingClientRect()
2560
+ const bubble = bubbleFor(row, mode, blockKind)
2561
+ const storedStyle = readStoredEditorStyle()
2562
+ if (storedStyle !== null) style = storedStyle
2563
+ const chipsInBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2564
+ const minimumHeight = chipsInBox !== null && chipsInBox !== undefined ? 190 : 150
1986
2565
  const viewport = viewportSize()
1987
2566
  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)
2567
+ const area = typeof box.querySelector === 'function' ? box.querySelector('textarea') : null
2568
+ const frame = typeof box.querySelector === 'function' ? box.querySelector('.dshet-frame') : null
2569
+ const allBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-all') : null
2570
+ const heightMode = readStoredEditorHeight() ?? 'fill'
2571
+ if (frame !== null && frame !== undefined) frame.style.display = style === 'frame' ? '' : 'none'
2572
+ box.style.boxShadow = 'none'
2573
+ const footerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2574
+ if (footerEl !== null && footerEl !== undefined && !(style === 'center' && !inline)) {
2575
+ footerEl.style.position = ''
2576
+ footerEl.style.height = ''
2577
+ footerEl.style.marginTop = ''
2578
+ footerEl.style.borderRadius = ''
2579
+ }
2580
+ if (style === 'center' && !inline) {
2581
+ // A large centred panel, the first shape that shipped. The textarea
2582
+ // grows with its content; the original message stays where it is.
2583
+ delete box.dataset.dshetInplace
2584
+ if (area !== null && area !== undefined) {
2585
+ area.style.position = ''
2586
+ area.style.left = ''
2587
+ area.style.top = ''
2588
+ area.style.width = ''
2589
+ area.style.height = ''
2590
+ area.style.maxHeight = ''
2591
+ area.style.background = ''
2592
+ area.style.paddingBottom = '16px'
2593
+ fitEditorHeight(area)
2594
+ const centerFooter = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2595
+ if (centerFooter !== null && centerFooter !== undefined) {
2596
+ centerFooter.style.position = 'static'
2597
+ centerFooter.style.height = '46px'
2598
+ centerFooter.style.marginTop = '0'
2599
+ centerFooter.style.borderRadius = '0 0 16px 16px'
2600
+ }
2601
+ }
2602
+ const width = viewport.width > 0 ? Math.max(320, Math.min(1400, viewport.width - margin * 2)) : Math.max(320, rowRect.width)
2603
+ box.style.boxSizing = 'border-box'
2604
+ box.style.width = `${Math.round(width)}px`
2605
+ box.style.left = `${Math.round(viewport.width > 0 ? (viewport.width - width) / 2 : rowRect.left)}px`
2606
+ const bounds = conversationBounds(row)
2607
+ const topLimit = bounds.top + margin
2608
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2609
+ const usable = bottomLimit > topLimit ? bottomLimit - topLimit : 0
2610
+ box.style.height = ''
2611
+ box.style.maxHeight = usable > 0
2612
+ ? `${Math.max(180, usable)}px`
2613
+ : viewport.height > 0
2614
+ ? `${Math.max(180, viewport.height - margin * 2)}px`
2615
+ : ''
2616
+ const measured = box.getBoundingClientRect()
2617
+ const height = (measured && measured.height) || box.offsetHeight || 0
2618
+ const top = usable > 0 && height > 0
2619
+ ? Math.round(Math.max(topLimit, Math.min(topLimit + (usable - height) / 2, bottomLimit - height)))
2620
+ : viewport.height > 0 && height > 0
2621
+ ? Math.round(Math.max(margin, Math.min((viewport.height - height) / 2, viewport.height - height - margin)))
2622
+ : Math.round((rowRect.bottom || 0) + 6)
2623
+ box.style.top = `${top}px`
2624
+ return
2625
+ }
2626
+ // `bubble` and `frame` both paint over the message, so the textarea gets
2627
+ // an opaque surface: the recorded text underneath must not show through
2628
+ // the draft. `frame` outlines the whole row; `bubble` outlines just the
2629
+ // message block.
2630
+ box.dataset.dshetInplace = '1'
2631
+ // A full opaque backdrop: the frame covers the whole row, so the host's
2632
+ // status line and action bar underneath must not show through either.
2633
+ const opaque = pageBackground()
2634
+ box.style.background = opaque
2635
+ if (area !== null && area !== undefined) {
2636
+ area.style.background = opaque
2637
+ }
2638
+ // A reply edit is scoped to the selected block (answer text or reasoning),
2639
+ // not to the whole assistant row: the other blocks stay out of the frame.
2640
+ const base = legacy ? rowRect : mode === 'reply' ? bubble.rect : style === 'frame' ? rowRect : bubble.rect
2641
+ let width = base.width
2642
+ let alignedLeft = null
2643
+ const fillAllView = allBox !== null && allBox !== undefined && heightMode !== 'content'
2644
+ if (fillAllView) {
2645
+ // The all-blocks page lines up with the composer card: same left edge,
2646
+ // same width, so the two boxes read as one column.
2647
+ const aligned = conversationBounds(row)
2648
+ if (aligned.composerWidth > 0) {
2649
+ width = aligned.composerWidth
2650
+ alignedLeft = aligned.composerLeft
2651
+ }
2652
+ }
2653
+ if (!fillAllView && mode === 'reply' && typeof rowRect.width === 'number' && rowRect.width > width) {
2654
+ // A reply block is narrower than the conversation row; the editor gets
2655
+ // the full row width so the text is not squeezed into the bubble.
2656
+ width = rowRect.width
2657
+ }
2658
+ let left = alignedLeft === null ? base.left : alignedLeft
2659
+ if (inline) {
2660
+ const pageX = typeof window !== 'undefined' && typeof window.scrollX === 'number' ? window.scrollX : 0
2661
+ left += pageX
2662
+ if (viewport.width > 0) width = Math.min(width, viewport.width - margin * 2)
2663
+ } else if (viewport.width > 0) {
2664
+ width = Math.min(width, viewport.width - margin * 2)
2665
+ left = Math.max(margin, Math.min(left, viewport.width - width - margin))
2666
+ }
2667
+ box.style.boxSizing = 'border-box'
1991
2668
  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
2669
  box.style.left = `${Math.round(left)}px`
2670
+ // The host's action strip sits under the reported bubble. Extend the
2671
+ // editor down to the row's bottom so that strip is covered, and the
2672
+ // footer controls end up where those host buttons used to be.
2673
+ const baseBottom = typeof base.bottom === 'number' ? base.bottom : base.top + base.height
2674
+ const rowBottom = typeof rowRect.bottom === 'number' ? rowRect.bottom : rowRect.top + rowRect.height
2675
+ // The action strip can be a sibling of the row (the turn-tail row), not
2676
+ // its child. Find the nearest one from our own injected action buttons
2677
+ // and include its bottom in the cover.
2678
+ let actionBottom = 0
2679
+ if (legacy) try {
2680
+ const actions = document.querySelectorAll('.dshet-action')
2681
+ for (const action of actions) {
2682
+ const bar = action.parentElement
2683
+ if (bar === null || bar === undefined || typeof bar.getBoundingClientRect !== 'function') continue
2684
+ const rect = bar.getBoundingClientRect()
2685
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 160) {
2686
+ actionBottom = Math.max(actionBottom, rect.bottom)
2687
+ }
2688
+ }
2689
+ } catch {}
2690
+ // The action strip is rendered as its own turn-tail row under the
2691
+ // message row. Use that row's bottom; if the host renders it in a shape
2692
+ // we cannot identify, reserve one action-band height so the buttons can
2693
+ // never peek out underneath the opaque editor.
2694
+ let tailBottom = 0
2695
+ if (legacy) try {
2696
+ const tails = document.querySelectorAll('[data-chat-flow-kind="turn-tail"]')
2697
+ for (const tail of tails) {
2698
+ if (typeof tail.getBoundingClientRect !== 'function') continue
2699
+ const rect = tail.getBoundingClientRect()
2700
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 200) {
2701
+ tailBottom = Math.max(tailBottom, rect.bottom)
2702
+ }
2703
+ }
2704
+ } catch {}
2705
+ const coverBottom = Math.max(rowBottom, actionBottom, tailBottom)
2706
+ const coveredHeight = Math.max(base.height, coverBottom - base.top + 48)
2707
+ // Non-legacy modes hug the content: the box ends right after the last
2708
+ // line plus the control band, instead of stretching to the row bottom.
2709
+ const FOOTER_BAND = 46
2710
+ const boxRect0 = typeof box.getBoundingClientRect === 'function' ? box.getBoundingClientRect() : { top: base.top }
2711
+ const chipStrip0 = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2712
+ const chipBottom0 = chipStrip0 === null || chipStrip0 === undefined || typeof chipStrip0.getBoundingClientRect !== 'function'
2713
+ ? 0
2714
+ : Math.max(0, chipStrip0.getBoundingClientRect().bottom - boxRect0.top + 8)
2715
+ const headerOffset = Math.max(38, chipBottom0, bubble.rect.top - base.top)
2716
+ let contentHeight = Math.round(base.height)
2717
+ if (area !== null && area !== undefined && (allBox === null || allBox === undefined)) {
2718
+ try {
2719
+ const previousValue = area.value
2720
+ const previousHeight = area.style.height
2721
+ const measure = (text) => {
2722
+ area.value = text
2723
+ area.style.height = 'auto'
2724
+ const measured = area.scrollHeight
2725
+ return typeof measured === 'number' && measured > 0 ? Math.round(measured) : 0
2726
+ }
2727
+ const own = measure(previousValue)
2728
+ if (own > 0) contentHeight = own
2729
+ // "Turn max" pins the box to the tallest block of the turn, so
2730
+ // switching between a short answer and a long thought does not resize
2731
+ // the frame under the user's hands. The measurement is cached: doing
2732
+ // it for every block on every scroll frame is what made the editor
2733
+ // drag while the conversation moved.
2734
+ if (heightMode !== 'content' && Array.isArray(blocks)) {
2735
+ const key = `${Math.round(area.clientWidth || 0)}|${blocks.map((block) => `${block.seq ?? ''}:${block.index}:${typeof block.text === 'string' ? block.text.length : 0}`).join(',')}`
2736
+ if (maxHeightCache.key === key) {
2737
+ contentHeight = Math.max(contentHeight, maxHeightCache.value)
2738
+ } else {
2739
+ let tallest = 0
2740
+ for (const block of blocks) {
2741
+ if (block === null || block === undefined || typeof block.text !== 'string') continue
2742
+ const measured = measure(block.text)
2743
+ if (measured > tallest) tallest = measured
2744
+ }
2745
+ maxHeightCache = { key, value: tallest }
2746
+ if (tallest > contentHeight) contentHeight = tallest
2747
+ }
2748
+ }
2749
+ area.value = previousValue
2750
+ area.style.height = previousHeight
2751
+ } catch {}
2752
+ }
2753
+ // Every mode hugs the measured content; `coveredHeight` is only a
2754
+ // fallback for a host whose textarea cannot be measured.
2755
+ let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND)
2756
+ let clampedTop = null
2757
+ if (clamped) {
2758
+ const bounds = conversationBounds(row)
2759
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2760
+ const topLimit = bounds.top + margin
2761
+ if (bottomLimit > topLimit) {
2762
+ // All-blocks mode shows one page of blocks behind ONE scrollbar: it
2763
+ // fills the WHOLE band (not just the message's own position), so the
2764
+ // top touches the conversation edge and the bottom the composer.
2765
+ const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
2766
+ clampedTop = fillAll ? topLimit : Math.max(base.top, topLimit)
2767
+ height = fillAll
2768
+ ? Math.max(minimumHeight, bottomLimit - clampedTop)
2769
+ : Math.min(height, Math.max(minimumHeight, bottomLimit - clampedTop))
2770
+ }
2771
+ }
2772
+ if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
2773
+ height = Math.max(minimumHeight, height)
2774
+ box.style.height = `${height}px`
2775
+ box.style.maxHeight = ''
2776
+ let top = clampedTop === null ? base.top : clampedTop
2777
+ if (inline) {
2778
+ const pageY = typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0
2779
+ top += pageY
2780
+ } else if (viewport.height > 0) {
2781
+ top = Math.max(margin, Math.min(top, viewport.height - height - margin))
2782
+ }
2010
2783
  box.style.top = `${Math.round(top)}px`
2784
+ if (allBox !== null && allBox !== undefined) {
2785
+ allBox.style.position = 'absolute'
2786
+ allBox.style.left = '8px'
2787
+ allBox.style.right = '8px'
2788
+ // The all-blocks page has its own sticky outline, so it does not need
2789
+ // the 38px control header the single-block editor reserves.
2790
+ allBox.style.top = '6px'
2791
+ allBox.style.bottom = `${FOOTER_BAND + 8}px`
2792
+ allBox.style.overflowY = 'auto'
2793
+ // The container is now laid out; re-measure each block so the document
2794
+ // is exactly as tall as its text and the ONE scrollbar appears when it
2795
+ // overflows the band.
2796
+ if (typeof allBox.querySelectorAll === 'function') {
2797
+ for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) {
2798
+ blockArea.style.height = 'auto'
2799
+ const measured = blockArea.scrollHeight
2800
+ if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
2801
+ }
2802
+ }
2803
+ } else if (area !== null && area !== undefined) {
2804
+ const offsetLeft = Math.max(0, bubble.rect.left - base.left)
2805
+ // Reserve a header band for the controls; when the prompt carries image
2806
+ // chips they sit above the text, so the text starts below them too.
2807
+ const offsetTop = headerOffset
2808
+ // The control band is painted over the box's bottom edge, so the field
2809
+ // has to stop above it: the old `height - offsetTop - 8` let the field
2810
+ // run 40px under the footer, and the last lines of a long block were
2811
+ // covered for good (the field scrolled, the footer never moved).
2812
+ const footerHeight =
2813
+ footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
2814
+ ? footerEl.offsetHeight
2815
+ : FOOTER_BAND
2816
+ const available = Math.max(40, height - offsetTop - footerHeight)
2817
+ area.style.position = 'absolute'
2818
+ area.style.boxSizing = 'border-box'
2819
+ area.style.left = `${Math.round(offsetLeft)}px`
2820
+ area.style.top = `${Math.round(offsetTop)}px`
2821
+ area.style.width = `${Math.round(mode === 'reply' ? Math.max(80, width - offsetLeft - 8) : Math.max(80, Math.min(bubble.rect.width, width - offsetLeft)))}px`
2822
+ // Never let the textarea paint outside the box: when the measured
2823
+ // content is taller than the box (viewport-capped overlay), it scrolls
2824
+ // inside instead of spilling past the border.
2825
+ area.style.height = `${Math.round(Math.max(32, Math.min(contentHeight, available)))}px`
2826
+ area.style.maxHeight = 'none'
2827
+ area.style.padding = ''
2828
+ area.style.paddingBottom = '8px'
2829
+ }
2830
+ box.style.borderRadius = ''
2831
+ try {
2832
+ const skin = getComputedStyle(bubble.element)
2833
+ if (skin.borderRadius && skin.borderRadius !== '0px') box.style.borderRadius = skin.borderRadius
2834
+ } catch {
2835
+ /* a detached node keeps the stylesheet skin */
2836
+ }
2011
2837
  }
2012
2838
 
2013
2839
  // Grow the textarea to its content, the way a normal input box behaves.
@@ -2015,7 +2841,10 @@ window.__ModuleLoader__.load({
2015
2841
  // in half an empty box. The stylesheet owns the limits (`min-height`,
2016
2842
  // `max-height`); the element only ever gets the measured height, and the
2017
2843
  // `+2` is the top and bottom border `scrollHeight` does not count.
2844
+ let maxHeightCache = { key: '', value: 0 }
2845
+
2018
2846
  function fitEditorHeight(area) {
2847
+ if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
2019
2848
  area.style.height = 'auto'
2020
2849
  const height = area.scrollHeight
2021
2850
  if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
@@ -2165,8 +2994,12 @@ window.__ModuleLoader__.load({
2165
2994
  // saving would write, so both have to redraw the strip - and the chip
2166
2995
  // keys are enough to tell one list from another.
2167
2996
  view.parts.map((part) => part.key).join('.'),
2997
+ // The mode itself: leaving the all-blocks view must rebuild even when the
2998
+ // single block that comes back is the one that was selected before it.
2999
+ view.allBlocks === true ? 'all' : 'one',
2168
3000
  // The selected reply block: switching blocks has to rebuild the box.
2169
3001
  view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
3002
+ view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
2170
3003
  ].join('|')
2171
3004
  }
2172
3005
 
@@ -2242,6 +3075,16 @@ window.__ModuleLoader__.load({
2242
3075
  return strip
2243
3076
  }
2244
3077
 
3078
+ /** The FileList of a paste/drop payload, without assuming the event shape. */
3079
+ function filesFromData(data) {
3080
+ if (data === null || data === undefined) return []
3081
+ try {
3082
+ return data.files === null || data.files === undefined ? [] : Array.prototype.slice.call(data.files)
3083
+ } catch {
3084
+ return []
3085
+ }
3086
+ }
3087
+
2245
3088
  /**
2246
3089
  * The add/replace entry: a picker that puts a block on the list.
2247
3090
  *
@@ -2270,7 +3113,10 @@ window.__ModuleLoader__.load({
2270
3113
  const add = document.createElement('button')
2271
3114
  add.type = 'button'
2272
3115
  add.className = 'dshet-attach'
2273
- add.textContent = t('editor.attach.add')
3116
+ 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>'
3117
+ add.setAttribute('aria-haspopup', 'listbox')
3118
+ add.title = t('editor.attach.add')
3119
+ add.setAttribute('aria-label', t('editor.attach.add'))
2274
3120
  add.disabled = view.pending
2275
3121
  pressable(add, () => {
2276
3122
  if (typeof picker.click === 'function') picker.click()
@@ -2288,7 +3134,7 @@ window.__ModuleLoader__.load({
2288
3134
  ? controller.editorBox
2289
3135
  : null
2290
3136
  if (existing && existing.dataset.dshetFor === marker) {
2291
- placeEditor(row, existing, controller.editorAnchor)
3137
+ placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2292
3138
  return
2293
3139
  }
2294
3140
 
@@ -2310,30 +3156,64 @@ window.__ModuleLoader__.load({
2310
3156
  box.className = 'dshet-editor'
2311
3157
  box.dataset[EDITOR_ATTR] = '1'
2312
3158
  box.dataset.dshetFor = marker
3159
+ // The frame is a child, not the box's own border: host skins mark the
3160
+ // editor chrome `!important` and win the cascade against the box itself,
3161
+ // but they cannot name this child. It draws the blue outline of the
3162
+ // reference UI on top of whatever border the skin puts on the box.
3163
+ const frame = document.createElement('span')
3164
+ frame.className = 'dshet-frame'
3165
+ frame.style.position = 'absolute'
3166
+ frame.style.inset = '-1px'
3167
+ frame.style.border = '2px solid #4a7dff'
3168
+ frame.style.borderRadius = 'inherit'
3169
+ frame.style.boxSizing = 'border-box'
3170
+ frame.style.pointerEvents = 'none'
3171
+ frame.style.zIndex = '3'
3172
+ box.appendChild(frame)
2313
3173
 
2314
3174
  // A reply on the patch path may carry several editable blocks (answer
2315
3175
  // text and reasoning). The picker chooses which one this save targets;
2316
3176
  // every option carries the block's CURRENT projected text, so switching
2317
3177
  // 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) {
3178
+ if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length >= 1) {
2319
3179
  const picker = document.createElement('label')
2320
3180
  picker.className = 'dshet-pick'
2321
3181
  const caption = document.createElement('span')
3182
+ caption.className = 'dshet-sr-only'
2322
3183
  caption.textContent = t('editor.block')
2323
3184
  const select = document.createElement('select')
2324
3185
  select.className = 'dshet-select'
2325
3186
  select.disabled = view.pending || view.confirming
2326
3187
  select.setAttribute('aria-label', t('editor.block'))
3188
+ // A single reply can carry several answer texts and several reasoning
3189
+ // blocks; number the labels so each one is addressable in the picker.
3190
+ const totals = {}
3191
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3192
+ const seen = {}
2327
3193
  for (const block of target.blocks) {
2328
3194
  const option = document.createElement('option')
2329
- option.value = String(block.index)
3195
+ option.value = typeof block.seq === 'number' ? `${block.seq}:${block.index}` : String(block.index)
2330
3196
  const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
2331
- option.textContent = t(kind)
3197
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3198
+ option.textContent = totals[block.kind] > 1 ? `${t(kind)} ${seen[block.kind]}` : t(kind)
2332
3199
  option.selected = block.index === view.blockIndex
2333
3200
  select.appendChild(option)
2334
3201
  }
2335
- if (view.blockIndex !== null && view.blockIndex !== undefined) select.value = String(view.blockIndex)
2336
- select.addEventListener('change', () => controller.selectBlock(Number(select.value)))
3202
+ // "All blocks" is always offered for a multi-block turn, not only once
3203
+ // it is already active - otherwise there is no way to select it.
3204
+ if (target.blocks.length > 1) {
3205
+ const allOption = document.createElement('option')
3206
+ allOption.value = 'all'
3207
+ allOption.textContent = t('editor.block.all')
3208
+ select.appendChild(allOption)
3209
+ }
3210
+ if (view.allBlocks === true) {
3211
+ select.value = 'all'
3212
+ } else if (view.blockIndex !== null && view.blockIndex !== undefined) {
3213
+ const current = target.blocks.find((block) => block.index === view.blockIndex && (view.targetSeq === null || view.targetSeq === undefined || block.seq === undefined || block.seq === view.targetSeq))
3214
+ select.value = current !== undefined && typeof current.seq === 'number' ? `${current.seq}:${current.index}` : String(view.blockIndex)
3215
+ }
3216
+ select.addEventListener('change', () => controller.selectBlock(select.value))
2337
3217
  picker.appendChild(caption)
2338
3218
  picker.appendChild(select)
2339
3219
  box.appendChild(picker)
@@ -2355,13 +3235,13 @@ window.__ModuleLoader__.load({
2355
3235
  fitEditorHeight(area)
2356
3236
  const row = controller.editorRow
2357
3237
  const box = controller.editorBox
2358
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorAnchor)
3238
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2359
3239
  })
2360
3240
  area.addEventListener('compositionend', () => {
2361
3241
  fitEditorHeight(area)
2362
3242
  const row = controller.editorRow
2363
3243
  const box = controller.editorBox
2364
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorAnchor)
3244
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2365
3245
  })
2366
3246
  area.addEventListener('focus', () => {
2367
3247
  controller.editorFocus = true
@@ -2376,18 +3256,114 @@ window.__ModuleLoader__.load({
2376
3256
  if (event.isComposing === true || event.keyCode === 229) return
2377
3257
  if (event.key === 'Enter' && event.shiftKey !== true) {
2378
3258
  event.preventDefault()
2379
- if (view.confirming) controller.confirm()
3259
+ const live = liveView()
3260
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
2380
3261
  else controller.review()
2381
3262
  return
2382
3263
  }
2383
3264
  if (event.key === 'Escape') {
2384
3265
  event.preventDefault()
2385
- if (view.confirming) controller.back()
3266
+ const live = liveView()
3267
+ if (live !== null && live !== undefined && live.confirming === true) controller.back()
2386
3268
  else controller.close()
2387
3269
  }
2388
3270
  })
2389
3271
  box.appendChild(area)
2390
3272
 
3273
+ // "All blocks": every reasoning and text block of the turn on one page,
3274
+ // each in its own textarea under a light separator.
3275
+ if (view.allBlocks === true && Array.isArray(target.blocks)) {
3276
+ const allBox = document.createElement('div')
3277
+ allBox.className = 'dshet-all'
3278
+ // A sticky outline over the page: one chip per block that scrolls the
3279
+ // container to it, so the document keeps its single scrollbar and still
3280
+ // has navigation.
3281
+ const nav = document.createElement('div')
3282
+ nav.className = 'dshet-all-nav'
3283
+ const totals = {}
3284
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3285
+ const seen = {}
3286
+ for (const block of target.blocks) {
3287
+ const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
3288
+ const section = document.createElement('div')
3289
+ section.className = 'dshet-all-block'
3290
+ const head = document.createElement('div')
3291
+ head.className = 'dshet-all-head'
3292
+ const label = document.createElement('span')
3293
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3294
+ const kindKey = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
3295
+ const labelText = totals[block.kind] > 1 ? `${t(kindKey)} ${seen[block.kind]}` : t(kindKey)
3296
+ label.textContent = labelText
3297
+ const navButton = document.createElement('button')
3298
+ navButton.type = 'button'
3299
+ navButton.textContent = labelText
3300
+ navButton.addEventListener('click', () => {
3301
+ if (typeof section.scrollIntoView === 'function') section.scrollIntoView({ block: 'start' })
3302
+ else allBox.scrollTop = section.offsetTop ?? 0
3303
+ })
3304
+ nav.appendChild(navButton)
3305
+ const line = document.createElement('span')
3306
+ line.className = 'dshet-all-line'
3307
+ head.appendChild(label)
3308
+ head.appendChild(line)
3309
+ const blockArea = document.createElement('textarea')
3310
+ blockArea.className = 'dshet-all-area'
3311
+ blockArea.rows = 1
3312
+ blockArea.spellcheck = false
3313
+ blockArea.style.overflow = 'hidden'
3314
+ blockArea.style.position = 'static'
3315
+ blockArea.style.left = 'auto'
3316
+ blockArea.style.top = 'auto'
3317
+ blockArea.value = controller.editorAllDrafts instanceof Map ? (controller.editorAllDrafts.get(key) ?? block.text) : block.text
3318
+ blockArea.disabled = view.pending || view.confirming
3319
+ const grow = () => {
3320
+ blockArea.style.height = 'auto'
3321
+ const measured = blockArea.scrollHeight
3322
+ if (typeof measured === 'number' && measured > 0) blockArea.style.height = `${measured}px`
3323
+ }
3324
+ blockArea.addEventListener('input', () => {
3325
+ if (controller.editorAllDrafts instanceof Map) controller.editorAllDrafts.set(key, blockArea.value)
3326
+ grow()
3327
+ })
3328
+ section.appendChild(head)
3329
+ section.appendChild(blockArea)
3330
+ allBox.appendChild(section)
3331
+ // Only now is the textarea in the document, so `scrollHeight` is the
3332
+ // real content height; measuring before the append returned 0 and the
3333
+ // block stayed 32px tall with its text clipped.
3334
+ grow()
3335
+ }
3336
+ allBox.insertBefore(nav, allBox.firstChild)
3337
+ box.appendChild(allBox)
3338
+ area.style.display = 'none'
3339
+ }
3340
+
3341
+ // Input parity with the composer: a screenshot pasted from the clipboard
3342
+ // and files dropped onto the box join the same chip list as the picker.
3343
+ // Reply edits cannot carry blocks, so the handlers exist only for prompts
3344
+ // whose host proved it can keep them.
3345
+ if (target.mode === 'prompt' && view.attachmentsOk === true) {
3346
+ area.addEventListener('paste', (event) => {
3347
+ const files = filesFromData(event.clipboardData)
3348
+ if (files.length === 0) return
3349
+ event.preventDefault()
3350
+ controller.attachFiles(files)
3351
+ })
3352
+ box.addEventListener('dragover', (event) => {
3353
+ event.preventDefault()
3354
+ box.dataset.dshetDrop = '1'
3355
+ })
3356
+ box.addEventListener('dragleave', () => {
3357
+ delete box.dataset.dshetDrop
3358
+ })
3359
+ box.addEventListener('drop', (event) => {
3360
+ event.preventDefault()
3361
+ delete box.dataset.dshetDrop
3362
+ const files = filesFromData(event.dataTransfer)
3363
+ if (files.length > 0) controller.attachFiles(files)
3364
+ })
3365
+ }
3366
+
2391
3367
  // The blocks this message carries, each one a chip the user can remove,
2392
3368
  // and the entry that adds another. Drawn only when the host answered that
2393
3369
  // it can admit what a chip promises - otherwise the warning below states
@@ -2417,22 +3393,50 @@ window.__ModuleLoader__.load({
2417
3393
  warn.textContent = texts.warn
2418
3394
  box.appendChild(warn)
2419
3395
  }
2420
- if (typeof view.failure === 'string' && view.failure !== '') {
3396
+ // A running turn only blocks the paths that move the surface (a prompt
3397
+ // rewrite, or a reply edit on a host without the in-place record). The
3398
+ // in-place record appends one event and changes nothing that is already
3399
+ // on the surface, so waiting for the agent to finish is not required -
3400
+ // the model picks the edit up on its next fold.
3401
+ const inPlaceEdit = target.mode === 'reply' && target.patch === true
3402
+ const busyBlocked = view.busy === true && !inPlaceEdit
3403
+ const failureCode = typeof view.failure === 'string' && view.failure !== ''
3404
+ ? view.failure
3405
+ : busyBlocked
3406
+ ? 'busy'
3407
+ : null
3408
+ if (failureCode !== null) {
2421
3409
  const error = document.createElement('p')
2422
3410
  error.className = 'dshet-error'
2423
3411
  error.setAttribute('role', 'status')
2424
- error.textContent = errorText(t, view.failure)
3412
+ const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
3413
+ error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
3414
+ ? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3415
+ : errorText(t, failureCode)
2425
3416
  box.appendChild(error)
2426
3417
  }
2427
3418
 
3419
+ // Handlers must read the CURRENT controller state: the host may not have
3420
+ // rebuilt the button after the first press, and a stale `view.confirming`
3421
+ // made the confirmation step a dead end (press again -> review again).
3422
+ const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
3423
+
2428
3424
  const footer = document.createElement('div')
2429
3425
  footer.className = 'dshet-footer'
3426
+ const footerActions = document.createElement('div')
3427
+ footerActions.className = 'dshet-footer-actions'
2430
3428
 
2431
3429
  const cancel = document.createElement('button')
2432
3430
  cancel.type = 'button'
2433
- cancel.className = 'dshet-btn'
2434
- cancel.textContent = t('editor.cancel')
2435
- cancel.title = `${t('editor.cancel')} (Esc)`
3431
+ cancel.className = 'dshet-btn dshet-btn-icon dshet-btn-cancel'
3432
+ const cancelLabel = t('editor.cancel')
3433
+ 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>'
3434
+ const cancelText = document.createElement('span')
3435
+ cancelText.className = 'dshet-sr-only'
3436
+ cancelText.textContent = cancelLabel
3437
+ cancel.appendChild(cancelText)
3438
+ cancel.title = `${cancelLabel} (Esc)`
3439
+ cancel.setAttribute('aria-label', cancelLabel)
2436
3440
  cancel.disabled = view.pending
2437
3441
  pressable(cancel, () => {
2438
3442
  if (view.confirming) controller.back()
@@ -2441,20 +3445,35 @@ window.__ModuleLoader__.load({
2441
3445
 
2442
3446
  const submit = document.createElement('button')
2443
3447
  submit.type = 'button'
2444
- submit.className = 'dshet-btn dshet-btn-primary'
2445
- submit.textContent = view.pending
3448
+ submit.className = 'dshet-btn dshet-btn-primary dshet-btn-save'
3449
+ const submitLabel = view.pending
2446
3450
  ? t('editor.pending')
2447
3451
  : view.confirming
2448
3452
  ? t('editor.review')
2449
3453
  : texts.save
2450
- submit.title = `${submit.textContent} (Enter)`
2451
- submit.disabled = view.pending
3454
+ // The official composer uses a bare double-check for confirm, not a black
3455
+ // pill. The label stays in the accessible name (and in the test-visible
3456
+ // textContent) through a visually hidden span.
3457
+ 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>'
3458
+ const submitText = document.createElement('span')
3459
+ submitText.className = 'dshet-sr-only'
3460
+ submitText.textContent = submitLabel
3461
+ submit.appendChild(submitText)
3462
+ submit.title = `${submitLabel} (Enter)`
3463
+ submit.setAttribute('aria-label', submitLabel)
3464
+ submit.disabled = view.pending || busyBlocked
2452
3465
  pressable(submit, () => {
2453
- if (view.confirming) controller.confirm()
3466
+ const live = liveView()
3467
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
2454
3468
  else controller.review()
2455
3469
  })
3470
+ if (view.confirming === true) {
3471
+ // The save label is visually hidden, so the confirmation step needs a
3472
+ // visible state of its own or the first press looks like nothing.
3473
+ submit.classList.add('dshet-btn-confirm')
3474
+ }
2456
3475
 
2457
- footer.appendChild(cancel)
3476
+ footerActions.appendChild(cancel)
2458
3477
  // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
2459
3478
  // only when that plugin is mounted, only for a prompt edit, and only
2460
3479
  // outside the confirmation step - the intent is carried through it. Save
@@ -2463,20 +3482,49 @@ window.__ModuleLoader__.load({
2463
3482
  if (target.mode === 'prompt' && view.siblingRerun === 'present' && view.confirming !== true) {
2464
3483
  const rerun = document.createElement('button')
2465
3484
  rerun.type = 'button'
2466
- rerun.className = 'dshet-btn dshet-rerun'
2467
- rerun.textContent = t('editor.rerun')
3485
+ rerun.className = 'dshet-btn dshet-btn-icon dshet-rerun'
3486
+ const rerunLabel = t('editor.rerun')
3487
+ 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>'
3488
+ const rerunText = document.createElement('span')
3489
+ rerunText.className = 'dshet-sr-only'
3490
+ rerunText.textContent = rerunLabel
3491
+ rerun.appendChild(rerunText)
3492
+ rerun.title = rerunLabel
3493
+ rerun.setAttribute('aria-label', rerunLabel)
2468
3494
  rerun.disabled = view.pending
2469
3495
  pressable(rerun, () => controller.reviewRerun())
2470
- footer.appendChild(rerun)
3496
+ footerActions.appendChild(rerun)
3497
+ }
3498
+ footerActions.appendChild(submit)
3499
+ footer.appendChild(footerActions)
3500
+ const attachRow = typeof box.querySelector === 'function' ? box.querySelector('.dshet-attach-row') : null
3501
+ if (attachRow !== null && attachRow !== undefined) {
3502
+ attachRow.style.position = 'static'
3503
+ attachRow.style.left = ''
3504
+ attachRow.style.bottom = ''
3505
+ attachRow.style.margin = '0'
3506
+ footer.insertBefore(attachRow, footer.firstChild)
3507
+ }
3508
+ // The block picker shares the control band and sits at its far right.
3509
+ const pickerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-pick') : null
3510
+ if (pickerEl !== null && pickerEl !== undefined) {
3511
+ pickerEl.style.position = 'static'
3512
+ pickerEl.style.left = ''
3513
+ pickerEl.style.top = ''
3514
+ pickerEl.style.marginLeft = 'auto'
3515
+ pickerEl.style.marginRight = '0'
3516
+ pickerEl.style.display = 'flex'
3517
+ pickerEl.style.flex = '0 0 auto'
3518
+ pickerEl.style.visibility = ''
3519
+ footer.appendChild(pickerEl)
2471
3520
  }
2472
- footer.appendChild(submit)
2473
3521
  box.appendChild(footer)
2474
3522
 
2475
3523
  controller.editorBox = box
2476
3524
  controller.editorRow = row
2477
3525
  controller.editorElement = area
3526
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
2478
3527
  fitEditorHeight(area)
2479
- placeEditor(row, box, controller.editorAnchor)
2480
3528
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
2481
3529
  try {
2482
3530
  area.focus()
@@ -2514,8 +3562,11 @@ window.__ModuleLoader__.load({
2514
3562
  // collapsed panel that may not be in the DOM at all.
2515
3563
 
2516
3564
  const PATCH_ATTR = 'data-dshet-patch'
2517
- const NOTE_ATTR = 'data-dshet-patch-note'
2518
3565
  const patchOriginals = new WeakMap()
3566
+ // The text this plugin last painted for a key, so a second edit of the same
3567
+ // block can still find the element after the first text replaced the
3568
+ // recorded original in the DOM.
3569
+ const patchedTexts = new Map()
2519
3570
  // React may re-render the assistant container and reset its attributes and
2520
3571
  // style while leaving the text node we replaced. Remember the element by
2521
3572
  // seq:block so the next pass can restore the marker and, on a later edit,
@@ -2593,10 +3644,11 @@ window.__ModuleLoader__.load({
2593
3644
  // element in the owning row whose whole text is exactly the recorded text;
2594
3645
  // never a button, a timestamp, our own chrome, or an ancestor that merely
2595
3646
  // contains the text.
2596
- function findPatchTextFallback(owner, wanted) {
3647
+ function findPatchTextFallback(owner, wanted, previousText = '') {
2597
3648
  if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
2598
3649
  const original = matchText(wanted.original)
2599
- if (original === '') return null
3650
+ const previous = matchText(previousText)
3651
+ if (original === '' && previous === '') return null
2600
3652
  let scope = owner
2601
3653
  try {
2602
3654
  const node = owner.querySelector('[data-slot="conversation.chat.node"]')
@@ -2608,12 +3660,12 @@ window.__ModuleLoader__.load({
2608
3660
  if (el === scope) continue
2609
3661
  if (el.getAttribute(PATCH_ATTR) !== null) continue
2610
3662
  try {
2611
- if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
3663
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
2612
3664
  } catch {
2613
3665
  continue
2614
3666
  }
2615
3667
  const text = matchText(el.textContent)
2616
- if (text !== original) continue
3668
+ if (text !== original && !(previous !== '' && text === previous)) continue
2617
3669
  let deeper = false
2618
3670
  for (const child of el.children ?? []) {
2619
3671
  if (matchText(child.textContent) === text) {
@@ -2626,6 +3678,97 @@ window.__ModuleLoader__.load({
2626
3678
  return null
2627
3679
  }
2628
3680
 
3681
+ /**
3682
+ * The collapsed reasoning header draws its own truncation of the RECORDED
3683
+ * text ("思考 · ..."), and when the panel is closed the body is not in the
3684
+ * DOM at all - so a patch of the reasoning body alone leaves the edit
3685
+ * invisible. Rewrite that snippet too, keeping whatever the host appended
3686
+ * (its ellipsis) and its own truncation length.
3687
+ */
3688
+ function patchReasoningSummary(owner, wanted) {
3689
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
3690
+ const original = String(wanted.original ?? '')
3691
+ const patched = String(wanted.text ?? '')
3692
+ if (original === '' || patched === '') return false
3693
+ const head = original.slice(0, 12)
3694
+ let changed = false
3695
+ const apply = (get, set) => {
3696
+ const data = String(get() ?? '')
3697
+ if (data === '' || !data.includes(head)) return
3698
+ // A node holding the whole text gets the whole replacement; a truncated
3699
+ // snippet keeps the host's length and gains an ellipsis when the new
3700
+ // text is longer.
3701
+ const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
3702
+ if (next === data) return
3703
+ set(next)
3704
+ changed = true
3705
+ }
3706
+ const walk = (node) => {
3707
+ for (const child of node.childNodes ?? []) {
3708
+ const type = child.nodeType
3709
+ if (type === 3) {
3710
+ apply(() => child.data, (value) => { child.data = value })
3711
+ continue
3712
+ }
3713
+ if (type !== 1) continue
3714
+ try {
3715
+ if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3716
+ } catch {
3717
+ /* a node without closest is walked anyway */
3718
+ }
3719
+ const kids = child.childNodes ?? []
3720
+ if (kids.length === 0) {
3721
+ apply(() => child.textContent, (value) => { child.textContent = value })
3722
+ continue
3723
+ }
3724
+ walk(child)
3725
+ }
3726
+ }
3727
+ walk(owner)
3728
+ return changed
3729
+ }
3730
+
3731
+ /**
3732
+ * The reasoning body on this host is its own row (the flow key carries a
3733
+ * `,reasoning` group part), so neither the answer row nor the first chat node
3734
+ * the shared fallback picks contains it. Search the whole conversation for
3735
+ * the deepest element whose text is the recorded original (or what we last
3736
+ * painted there).
3737
+ */
3738
+ function findReasoningElement(root, wanted, previousText) {
3739
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
3740
+ const original = matchText(wanted.original)
3741
+ const previous = matchText(previousText)
3742
+ if (original === '' && previous === '') return null
3743
+ let best = null
3744
+ let bestScore = Infinity
3745
+ for (const el of root.querySelectorAll('*')) {
3746
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
3747
+ try {
3748
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3749
+ } catch {
3750
+ /* a detached node is still a candidate */
3751
+ }
3752
+ const text = matchText(el.textContent)
3753
+ if (text === '') continue
3754
+ if (text !== original && !(previous !== '' && text === previous)) continue
3755
+ let deeper = false
3756
+ for (const child of el.children ?? []) {
3757
+ if (matchText(child.textContent) === text) {
3758
+ deeper = true
3759
+ break
3760
+ }
3761
+ }
3762
+ if (deeper) continue
3763
+ const score = Math.abs(text.length - original.length)
3764
+ if (score < bestScore) {
3765
+ best = el
3766
+ bestScore = score
3767
+ }
3768
+ }
3769
+ return best
3770
+ }
3771
+
2629
3772
  function applyPatchDisplay(snapshot, view, t) {
2630
3773
  // Map a reply seq to the host row that draws it, so a short answer like
2631
3774
  // "好的" cannot be matched in a different row. Fall back to a global
@@ -2659,7 +3802,7 @@ window.__ModuleLoader__.load({
2659
3802
  if (root === null) return
2660
3803
  const replies = view.replies instanceof Map ? view.replies : new Map()
2661
3804
  const wantedText = new Map()
2662
- const wantedNotes = new Map()
3805
+ const wantedReasoning = new Map()
2663
3806
  for (const patch of Array.isArray(view.patches) ? view.patches : []) {
2664
3807
  const reply = replies.get(patch.seq)
2665
3808
  if (reply === undefined || !Array.isArray(reply.blocks)) continue
@@ -2673,8 +3816,10 @@ window.__ModuleLoader__.load({
2673
3816
  text: String(patch.text ?? ''),
2674
3817
  })
2675
3818
  } else if (patch.kind === 'reasoning') {
2676
- wantedNotes.set(`${patch.seq}:${patch.blockIndex}`, {
3819
+ wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
2677
3820
  key: `${patch.seq}:${patch.blockIndex}`,
3821
+ seq: patch.seq,
3822
+ original: String(block.original ?? ''),
2678
3823
  text: String(patch.text ?? ''),
2679
3824
  reply,
2680
3825
  block,
@@ -2685,7 +3830,7 @@ window.__ModuleLoader__.load({
2685
3830
  // A patch that no longer exists must give the host's recorded text back,
2686
3831
  // even when a re-render already stripped our marker from the element.
2687
3832
  for (const [key, el] of [...patchedElements]) {
2688
- if (wantedText.has(key)) continue
3833
+ if (wantedText.has(key) || wantedReasoning.has(key)) continue
2689
3834
  patchedElements.delete(key)
2690
3835
  if (el.isConnected === true) restorePatchedBlock(el)
2691
3836
  }
@@ -2703,18 +3848,14 @@ window.__ModuleLoader__.load({
2703
3848
  el.textContent = shown
2704
3849
  }
2705
3850
  }
2706
- for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
2707
- const key = note.getAttribute(NOTE_ATTR)
2708
- if (!wantedNotes.has(key)) note.remove()
2709
- }
2710
- if (wantedText.size === 0 && wantedNotes.size === 0) return
3851
+ if (wantedText.size === 0 && wantedReasoning.size === 0) return
2711
3852
 
2712
3853
  const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
2713
3854
  const containers = []
2714
3855
  for (const node of nodes) {
2715
3856
  for (const el of node.querySelectorAll('*')) {
2716
3857
  try {
2717
- if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick, .dshet-patch-note') === null) containers.push(el)
3858
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
2718
3859
  } catch {
2719
3860
  /* a detached node is skipped */
2720
3861
  }
@@ -2745,7 +3886,8 @@ window.__ModuleLoader__.load({
2745
3886
  continue
2746
3887
  }
2747
3888
  const original = matchText(wanted.original)
2748
- if (original === '') continue
3889
+ const previous = matchText(patchedTexts.get(key) ?? '')
3890
+ if (original === '' && previous === '') continue
2749
3891
  let best = null
2750
3892
  let bestScore = Infinity
2751
3893
  for (const el of containers) {
@@ -2759,7 +3901,7 @@ window.__ModuleLoader__.load({
2759
3901
  }
2760
3902
  const text = matchText(el.textContent)
2761
3903
  if (text === '') continue
2762
- const exact = text === original
3904
+ const exact = text === original || (previous !== '' && text === previous)
2763
3905
  const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
2764
3906
  if (!exact && !prefix) continue
2765
3907
  // Prefer the exact match, then the container closest in length: a
@@ -2771,47 +3913,90 @@ window.__ModuleLoader__.load({
2771
3913
  bestScore = score
2772
3914
  }
2773
3915
  }
2774
- if (best === null) best = findPatchTextFallback(owner, wanted)
3916
+ if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
2775
3917
  if (best === null) continue
2776
3918
  patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
2777
3919
  best.textContent = displayText(wanted.text)
2778
3920
  best.setAttribute(PATCH_ATTR, key)
2779
3921
  best.style.whiteSpace = 'pre-wrap'
2780
3922
  patchedElements.set(key, best)
3923
+ patchedTexts.set(key, wanted.text)
2781
3924
  }
2782
3925
 
2783
- // Reasoning notes: put one under the reply row, found by the reply's own
2784
- // answer text (the panel itself may be collapsed and absent).
2785
- for (const [key, wanted] of wantedNotes) {
2786
- let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
2787
- let row = rowBySeq.get(wanted.reply.seq) ?? null
2788
- const replyText = matchText(wanted.reply.text)
2789
- for (const node of row === null ? nodes : []) {
2790
- const text = matchText(node.textContent)
2791
- if (text === '') continue
2792
- if (replyText !== '' && text.includes(replyText)) {
2793
- row = node
2794
- break
3926
+ // A reasoning patch is drawn in place when the host renders that
3927
+ // reasoning body; the note is only the fallback for a panel that is not
3928
+ // in the DOM (or is drawn as a short summary without the full text).
3929
+ for (const [key, wanted] of wantedReasoning) {
3930
+ const owner = rowBySeq.get(wanted.seq)
3931
+ const remembered = patchedElements.get(key)
3932
+ if (remembered !== undefined) {
3933
+ // The reasoning part is its own row (the flow key carries a
3934
+ // `,reasoning` group part), so it is NOT the row `rowBySeq` picked for
3935
+ // the seq - that one draws the answer. Only connectivity matters here.
3936
+ const stillThere = remembered.isConnected === true
3937
+ if (stillThere) {
3938
+ const shown = displayText(wanted.text)
3939
+ if (normalizedText(remembered.textContent) !== normalizedText(shown)) remembered.textContent = shown
3940
+ if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
3941
+ remembered.style.whiteSpace = 'pre-wrap'
3942
+ patchedTexts.set(key, wanted.text)
3943
+ continue
2795
3944
  }
2796
- if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
2797
- row = node
2798
- break
3945
+ patchedElements.delete(key)
3946
+ }
3947
+ const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
3948
+ if (done !== undefined) {
3949
+ patchedElements.set(key, done)
3950
+ continue
3951
+ }
3952
+ const original = matchText(wanted.original)
3953
+ const previous = matchText(patchedTexts.get(key) ?? '')
3954
+ const previousText = patchedTexts.get(key) ?? ''
3955
+ let best = null
3956
+ if (original !== '' || previous !== '') {
3957
+ // The owning row first (a host that draws the reasoning inside the
3958
+ // answer row), then the whole conversation: on this host the reasoning
3959
+ // is its own row, so the answer row holds no reasoning text at all.
3960
+ for (const scope of owner === undefined ? [null] : [owner, null]) {
3961
+ let bestScore = Infinity
3962
+ for (const el of containers) {
3963
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
3964
+ if (scope !== null) {
3965
+ try {
3966
+ if (el.closest('[data-chat-flow-key]') !== scope) continue
3967
+ } catch {
3968
+ /* keep the global match */
3969
+ }
3970
+ }
3971
+ const text = matchText(el.textContent)
3972
+ if (text === '') continue
3973
+ if (text !== original && !(previous !== '' && text === previous)) continue
3974
+ const score = Math.abs(text.length - original.length)
3975
+ if (score < bestScore) {
3976
+ best = el
3977
+ bestScore = score
3978
+ }
3979
+ }
3980
+ if (best !== null) break
2799
3981
  }
3982
+ if (best === null) best = findPatchTextFallback(owner ?? null, wanted, previousText)
3983
+ if (best === null) best = findReasoningElement(root, wanted, previousText)
2800
3984
  }
2801
- if (note === undefined || note === null) {
2802
- note = document.createElement('details')
2803
- note.className = 'dshet-patch-note'
2804
- note.setAttribute(NOTE_ATTR, key)
2805
- const summary = document.createElement('summary')
2806
- summary.textContent = t('editor.patch-note')
2807
- const body = document.createElement('pre')
2808
- body.className = 'dshet-patch-note-body'
2809
- note.appendChild(summary)
2810
- note.appendChild(body)
3985
+ if (best === null) {
3986
+ // No body in the DOM (the panel is closed): the header snippet is the
3987
+ // only place the edit can show.
3988
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
3989
+ patchReasoningSummary(root, wanted)
3990
+ continue
2811
3991
  }
2812
- const body = note.querySelector('.dshet-patch-note-body')
2813
- if (body !== null && body.textContent !== wanted.text) body.textContent = wanted.text
2814
- if (note.isConnected !== true && row !== null) row.after(note)
3992
+ patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
3993
+ best.textContent = displayText(wanted.text)
3994
+ best.setAttribute(PATCH_ATTR, key)
3995
+ best.style.whiteSpace = 'pre-wrap'
3996
+ patchedElements.set(key, best)
3997
+ patchedTexts.set(key, wanted.text)
3998
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
3999
+ patchReasoningSummary(root, wanted)
2815
4000
  }
2816
4001
  }
2817
4002
 
@@ -2820,6 +4005,8 @@ window.__ModuleLoader__.load({
2820
4005
  const animate = controller.consumeAnimate()
2821
4006
  const rows = document.querySelectorAll('[data-chat-flow-key]')
2822
4007
  syncEditableSet(controller, rows.length)
4008
+ let editorCandidate = null
4009
+ let editorRank = -1
2823
4010
  for (const row of rows) {
2824
4011
  if (!(row instanceof HTMLElement)) continue
2825
4012
  const key = row.getAttribute('data-chat-flow-key')
@@ -2872,7 +4059,20 @@ window.__ModuleLoader__.load({
2872
4059
  (seqsFor(node).includes(editing.seq) ||
2873
4060
  seqsFor(node).some((seq) => headRevision(seq, view.revisions) === editing.seq))
2874
4061
  if (coversEditing && (!hidden || row.style.display !== 'none')) {
2875
- renderEditor(row, controller, view, t)
4062
+ const rank = editorRowRank(row, editing.mode)
4063
+ if (rank > editorRank) {
4064
+ editorRank = rank
4065
+ editorCandidate = row
4066
+ }
4067
+ }
4068
+ }
4069
+ if (view.editing !== null) {
4070
+ if (editorCandidate !== null) {
4071
+ renderEditor(editorCandidate, controller, view, t)
4072
+ } else {
4073
+ clearEditor()
4074
+ controller.editorBox = null
4075
+ controller.editorRow = null
2876
4076
  }
2877
4077
  }
2878
4078
  if (view.editing === null) {
@@ -3001,23 +4201,51 @@ window.__ModuleLoader__.load({
3001
4201
  && target.closest('.dshet-editor') !== null
3002
4202
  if (!inside) controller.editorFocus = false
3003
4203
  }
4204
+ let placementScheduled = false
4205
+ let lastPlacementKey = ''
3004
4206
  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)
4207
+ if (placementScheduled) return
4208
+ placementScheduled = true
4209
+ const run = () => {
4210
+ placementScheduled = false
4211
+ const row = controller.editorRow
4212
+ const box = controller.editorBox
4213
+ if (row === null || box === null || box.isConnected !== true) return
4214
+ const rect = row.getBoundingClientRect()
4215
+ const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0
4216
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
4217
+ 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' : ''}`
4218
+ if (key === lastPlacementKey) return
4219
+ lastPlacementKey = key
4220
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks)
4221
+ }
4222
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
4223
+ else window.setTimeout(run, 16)
4224
+ }
4225
+ const onResize = () => {
4226
+ // A window resize (or a fullscreen toggle / zoom) moves the composer
4227
+ // and the conversation edges; the cached band must not survive it.
4228
+ conversationBoundsCache = null
4229
+ keepPlaced()
3008
4230
  }
3009
4231
  document.addEventListener('pointerdown', onPointerDown, true)
3010
4232
  // Scroll events do not bubble; capture reaches the inner scroller too.
3011
4233
  document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
3012
- window.addEventListener('resize', keepPlaced)
4234
+ window.addEventListener('resize', onResize)
4235
+ let resizeObserver = null
4236
+ if (typeof ResizeObserver === 'function') {
4237
+ resizeObserver = new ResizeObserver(onResize)
4238
+ try { resizeObserver.observe(document.body) } catch {}
4239
+ }
3013
4240
  return () => {
3014
4241
  document.removeEventListener('pointerdown', onPointerDown, true)
3015
4242
  document.removeEventListener('scroll', keepPlaced, true)
3016
- window.removeEventListener('resize', keepPlaced)
4243
+ window.removeEventListener('resize', onResize)
4244
+ if (resizeObserver !== null) resizeObserver.disconnect()
3017
4245
  }
3018
4246
  }, [controller])
3019
4247
 
3020
- react.useEffect(() => {
4248
+ useBrowserLayoutEffect(() => {
3021
4249
  if (snapshot === undefined) return undefined
3022
4250
  let scheduled = false
3023
4251
  const run = () => {
@@ -3029,7 +4257,33 @@ window.__ModuleLoader__.load({
3029
4257
  applyDom(snapshot, live, controller, t)
3030
4258
  }
3031
4259
  run()
3032
- const observer = new MutationObserver(() => {
4260
+ const observer = new MutationObserver((records) => {
4261
+ // Our own editor writes attributes and text while the user types;
4262
+ // feeding those back into a full document pass is what made typing
4263
+ // and streaming compete for the same frames.
4264
+ let relevant = false
4265
+ let lostOurAction = false
4266
+ for (const record of records) {
4267
+ const target = record.target
4268
+ if (target !== null && target !== undefined && typeof target.closest === 'function' && target.closest('.dshet-layer') !== null) continue
4269
+ relevant = true
4270
+ if (record.removedNodes && record.removedNodes.length > 0) {
4271
+ for (const node of record.removedNodes) {
4272
+ if (node === null || node === undefined) continue
4273
+ if (typeof node.closest === 'function' && node.closest('.dshet-action') !== null) lostOurAction = true
4274
+ else if (typeof node.querySelector === 'function' && node.querySelector('.dshet-action') !== null) lostOurAction = true
4275
+ }
4276
+ }
4277
+ break
4278
+ }
4279
+ if (!relevant) return
4280
+ // The host rebuilt a row and our button went with it: put it back in
4281
+ // the same task instead of waiting a frame for the rAF pass.
4282
+ if (lostOurAction) {
4283
+ scheduled = false
4284
+ run()
4285
+ return
4286
+ }
3033
4287
  if (scheduled) return
3034
4288
  scheduled = true
3035
4289
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
@@ -3136,6 +4390,192 @@ window.__ModuleLoader__.load({
3136
4390
  editorLayer = null
3137
4391
  }
3138
4392
 
4393
+ // --- editor-style preference ----------------------------------------------
4394
+ //
4395
+ // The style is a browser preference so the manager page and the editor read
4396
+ // one value with no host round-trip. The host config's editorStyle is the
4397
+ // default when nothing has been chosen here.
4398
+ const EDITOR_STYLE_KEY = 'dsh-edit-turn/editorStyle'
4399
+ const EDITOR_STYLES = ['frame', 'bubble', 'center']
4400
+
4401
+ function readStoredEditorStyle() {
4402
+ try {
4403
+ if (typeof localStorage === 'undefined') return null
4404
+ const value = localStorage.getItem(EDITOR_STYLE_KEY)
4405
+ return EDITOR_STYLES.includes(value) ? value : null
4406
+ } catch {
4407
+ return null
4408
+ }
4409
+ }
4410
+
4411
+ let editorStylePreference = readStoredEditorStyle() ?? 'frame'
4412
+ const EDITOR_PLACEMENT_KEY = 'dsh-edit-turn/editorPlacement'
4413
+ const EDITOR_PLACEMENTS = ['overlay', 'inline', 'legacy']
4414
+
4415
+ function readStoredEditorPlacement() {
4416
+ try {
4417
+ if (typeof localStorage === 'undefined') return null
4418
+ const value = localStorage.getItem(EDITOR_PLACEMENT_KEY)
4419
+ return EDITOR_PLACEMENTS.includes(value) ? value : null
4420
+ } catch {
4421
+ return null
4422
+ }
4423
+ }
4424
+
4425
+ let editorPlacementPreference = readStoredEditorPlacement() ?? 'overlay'
4426
+ const EDITOR_HEIGHT_KEY = 'dsh-edit-turn/editorHeight'
4427
+ const EDITOR_HEIGHTS = ['max', 'content', 'fill']
4428
+
4429
+ function readStoredEditorHeight() {
4430
+ try {
4431
+ if (typeof localStorage === 'undefined') return null
4432
+ const value = localStorage.getItem(EDITOR_HEIGHT_KEY)
4433
+ return EDITOR_HEIGHTS.includes(value) ? value : null
4434
+ } catch {
4435
+ return null
4436
+ }
4437
+ }
4438
+
4439
+ let editorHeightPreference = readStoredEditorHeight() ?? 'fill'
4440
+ const editorHeightListeners = new Set()
4441
+
4442
+ function subscribeEditorHeight(listener) {
4443
+ editorHeightListeners.add(listener)
4444
+ return () => editorHeightListeners.delete(listener)
4445
+ }
4446
+
4447
+ function setEditorHeightPreference(mode) {
4448
+ if (!EDITOR_HEIGHTS.includes(mode)) return
4449
+ editorHeightPreference = mode
4450
+ try {
4451
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_HEIGHT_KEY, mode)
4452
+ } catch {}
4453
+ for (const listener of [...editorHeightListeners]) {
4454
+ try { listener() } catch {}
4455
+ }
4456
+ }
4457
+
4458
+ const editorPlacementListeners = new Set()
4459
+
4460
+ function subscribeEditorPlacement(listener) {
4461
+ editorPlacementListeners.add(listener)
4462
+ return () => editorPlacementListeners.delete(listener)
4463
+ }
4464
+
4465
+ function setEditorPlacementPreference(placement) {
4466
+ if (!EDITOR_PLACEMENTS.includes(placement)) return
4467
+ editorPlacementPreference = placement
4468
+ try {
4469
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_PLACEMENT_KEY, placement)
4470
+ } catch {
4471
+ /* private mode keeps the in-memory choice */
4472
+ }
4473
+ for (const listener of [...editorPlacementListeners]) {
4474
+ try {
4475
+ listener()
4476
+ } catch {}
4477
+ }
4478
+ }
4479
+
4480
+ const editorStyleListeners = new Set()
4481
+
4482
+ function subscribeEditorStyle(listener) {
4483
+ editorStyleListeners.add(listener)
4484
+ return () => editorStyleListeners.delete(listener)
4485
+ }
4486
+
4487
+ function setEditorStylePreference(style) {
4488
+ if (!EDITOR_STYLES.includes(style)) return
4489
+ editorStylePreference = style
4490
+ try {
4491
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_STYLE_KEY, style)
4492
+ } catch {
4493
+ /* private mode keeps the in-memory choice */
4494
+ }
4495
+ for (const listener of [...editorStyleListeners]) {
4496
+ try {
4497
+ listener()
4498
+ } catch {}
4499
+ }
4500
+ }
4501
+
4502
+ /**
4503
+ * The component row's configure page (`plugins.row.config`), the same slot
4504
+ * dsh-delete-turn uses. The manager's summary asks for a one-liner; the page
4505
+ * itself renders the three-way picker.
4506
+ */
4507
+ function EditorStyleOption({ view, t }) {
4508
+ const tr = typeof t === 'function' ? t : (key) => key
4509
+ const [style, setStyle] = react.useState(editorStylePreference)
4510
+ const [placement, setPlacement] = react.useState(editorPlacementPreference)
4511
+ const [heightMode, setHeightMode] = react.useState(editorHeightPreference)
4512
+ react.useEffect(() => subscribeEditorStyle(() => setStyle(editorStylePreference)), [])
4513
+ react.useEffect(() => subscribeEditorPlacement(() => setPlacement(editorPlacementPreference)), [])
4514
+ react.useEffect(() => subscribeEditorHeight(() => setHeightMode(editorHeightPreference)), [])
4515
+ const summaryKey = style === 'bubble' ? 'config.summary.bubble' : style === 'center' ? 'config.summary.center' : 'config.summary.frame'
4516
+ 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')}`
4517
+ const hintKey = style === 'bubble' ? 'config.hint.bubble' : style === 'center' ? 'config.hint.center' : 'config.hint.frame'
4518
+ const placementHint = placement === 'inline' ? 'config.hint.inline' : placement === 'legacy' ? 'config.hint.legacy' : 'config.hint.overlay'
4519
+ const heightHint = heightMode === 'content' ? 'config.hint.height.content' : heightMode === 'fill' ? 'config.hint.height.fill' : 'config.hint.height.max'
4520
+ const choices = [
4521
+ ['frame', 'style.frame'],
4522
+ ['bubble', 'style.bubble'],
4523
+ ['center', 'style.center'],
4524
+ ]
4525
+ return jsxs('div', {
4526
+ className: 'dshet-config',
4527
+ children: [
4528
+ jsxs('label', {
4529
+ className: 'dshet-config-row',
4530
+ children: [
4531
+ jsx('span', { children: tr('config.style') }),
4532
+ jsx('select', {
4533
+ className: 'dshet-select',
4534
+ value: style,
4535
+ onChange: (event) => setEditorStylePreference(event.target.value),
4536
+ children: choices.map(([value, key]) => jsx('option', { value, children: tr(key) }, value)),
4537
+ }),
4538
+ ],
4539
+ }),
4540
+ jsx('p', { className: 'dshet-config-hint', children: tr(hintKey) }),
4541
+ jsxs('label', {
4542
+ className: 'dshet-config-row',
4543
+ children: [
4544
+ jsx('span', { children: tr('config.placement') }),
4545
+ jsx('select', {
4546
+ className: 'dshet-select',
4547
+ value: placement,
4548
+ onChange: (event) => setEditorPlacementPreference(event.target.value),
4549
+ children: [
4550
+ jsx('option', { value: 'overlay', children: tr('placement.overlay') }),
4551
+ jsx('option', { value: 'inline', children: tr('placement.inline') }),
4552
+ jsx('option', { value: 'legacy', children: tr('placement.legacy') }),
4553
+ ],
4554
+ }),
4555
+ ],
4556
+ }),
4557
+ jsx('p', { className: 'dshet-config-hint', children: tr(placementHint) }),
4558
+ jsxs('label', {
4559
+ className: 'dshet-config-row',
4560
+ children: [
4561
+ jsx('span', { children: tr('config.height') }),
4562
+ jsx('select', {
4563
+ className: 'dshet-select',
4564
+ value: heightMode,
4565
+ onChange: (event) => setEditorHeightPreference(event.target.value),
4566
+ children: [
4567
+ jsx('option', { value: 'max', children: tr('height.max') }),
4568
+ jsx('option', { value: 'content', children: tr('height.content') }),
4569
+ jsx('option', { value: 'fill', children: tr('height.fill') }),
4570
+ ],
4571
+ }),
4572
+ ],
4573
+ }),
4574
+ jsx('p', { className: 'dshet-config-hint', children: tr(heightHint) }),
4575
+ ],
4576
+ })
4577
+ }
4578
+
3139
4579
  // --- plugin ---------------------------------------------------------------
3140
4580
 
3141
4581
  function apply(ctx) {
@@ -3174,6 +4614,31 @@ window.__ModuleLoader__.load({
3174
4614
  // A reply's edit entry lives in the official assistant-actions strip, one
3175
4615
  // entry per assistant message. order 5 puts it ahead of the feedback
3176
4616
  // entries (order 10), so the pencil sits with the leading icons.
4617
+ // The active skin repaints its surfaces at any time (switching one
4618
+ // rewrites body's attributes and inline variables), so the palette is
4619
+ // resolved once and then again on every theme or skin change.
4620
+ ctx.effect(() => {
4621
+ const run = () => {
4622
+ try {
4623
+ resolveSkinPalette()
4624
+ } catch {
4625
+ /* the stylesheet defaults stand */
4626
+ }
4627
+ }
4628
+ run()
4629
+ const observer =
4630
+ typeof MutationObserver === 'function' && document && document.body
4631
+ ? new MutationObserver(run)
4632
+ : null
4633
+ if (observer !== null) {
4634
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
4635
+ if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
4636
+ }
4637
+ return () => {
4638
+ if (observer !== null) observer.disconnect()
4639
+ }
4640
+ }, 'dsh-edit-turn:skin-palette')
4641
+
3177
4642
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
3178
4643
  ctx.slots.register(
3179
4644
  {
@@ -3190,6 +4655,20 @@ window.__ModuleLoader__.load({
3190
4655
  ),
3191
4656
  )
3192
4657
 
4658
+ // The row's configure arrow in the plugin manager. Without this the
4659
+ // component page is a read-only overview (the card cannot be opened); the
4660
+ // key is the manager's `<bundle package>#<row id>` contract.
4661
+ ctx.slots.inject('plugins.row.config', () =>
4662
+ ctx.slots.register(
4663
+ {
4664
+ name: 'plugins.row.config',
4665
+ key: 'dsh-edit-turn#dsh-edit-turn',
4666
+ locale: NS,
4667
+ },
4668
+ EditorStyleOption,
4669
+ ),
4670
+ )
4671
+
3193
4672
  ctx.slots.inject('conversation.input.overlay', () =>
3194
4673
  ctx.slots.register(
3195
4674
  {