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/README.md +88 -1
- package/lib/client.js +1613 -134
- package/lib/index.js +170 -12
- package/lib/patch.js +27 -9
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
190
|
-
|
|
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:
|
|
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 -
|
|
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
|
|
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;
|
|
284
|
-
'.dshet-attach{
|
|
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:
|
|
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
|
-
|
|
301
|
-
'.dshet-
|
|
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
|
-
|
|
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(
|
|
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
|
|
637
|
-
if (
|
|
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
|
-
? {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
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
|
|
1989
|
-
|
|
1990
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2336
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2435
|
-
cancel.
|
|
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
|
-
|
|
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
|
-
|
|
2451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2784
|
-
//
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
const
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
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 (
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
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',
|
|
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',
|
|
4243
|
+
window.removeEventListener('resize', onResize)
|
|
4244
|
+
if (resizeObserver !== null) resizeObserver.disconnect()
|
|
3017
4245
|
}
|
|
3018
4246
|
}, [controller])
|
|
3019
4247
|
|
|
3020
|
-
|
|
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
|
{
|