dsh-as-aistudio 0.2.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -20,6 +20,9 @@ window.__ModuleLoader__.load({
20
20
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
21
21
 
22
22
  const react = require('react')
23
+ // DOM injection has to land before the browser paints, or the host's own
24
+ // buttons are visible for a frame and ours pop in after them.
25
+ const useBrowserLayoutEffect = typeof react.useLayoutEffect === 'function' ? react.useLayoutEffect : react.useEffect
23
26
  const jsxRuntime = require('react/jsx-runtime')
24
27
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
25
28
  const { jsx, jsxs, Fragment } = jsxRuntime
@@ -40,7 +43,7 @@ window.__ModuleLoader__.load({
40
43
  const RERUN_PREFIX = '/dsh-rerun-turn'
41
44
 
42
45
  /** Keep in sync with package.json and lib/index.js. */
43
- const PLUGIN_VERSION = '0.2.20'
46
+ const PLUGIN_VERSION = '0.5.4'
44
47
 
45
48
  // --- copy -----------------------------------------------------------------
46
49
 
@@ -57,11 +60,47 @@ window.__ModuleLoader__.load({
57
60
  'action.copied': '复制成功',
58
61
  'editor.title': '编辑这条消息',
59
62
  'editor.title-reply': '编辑这条回答',
60
- 'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
63
+ 'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
61
64
  'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
62
65
  'editor.save': '保存',
63
66
  'editor.rerun': '重跑',
64
67
  'editor.save-reply': '保存',
68
+ 'editor.block': '编辑哪一块',
69
+ 'editor.block.all': '全部',
70
+ 'config.style': '编辑器样式',
71
+ 'config.placement': '编辑器位置',
72
+ 'config.height': '编辑器高度',
73
+ 'height.max': '一轮最大',
74
+ 'height.content': '随内容',
75
+ 'height.fill': '全部铺满',
76
+ 'config.summary.height.max': '一轮最大',
77
+ 'config.summary.height.content': '随内容',
78
+ 'config.summary.height.fill': '全部铺满',
79
+ 'config.hint.height.max': '打开时取这一轮所有思维链/正文里的最大高度;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
80
+ 'config.hint.height.content': '打开时框高跟着当前块走;编辑中随你打字长高、删字变矮,但不会比打开时更矮。',
81
+ 'config.hint.height.fill': '普通块打开时按一轮最大,编辑中随打字长高、删字变矮(不会比打开时更矮);选「全部」时铺满对话区到输入框之间的整段高度,只有一个滚动条。',
82
+ 'placement.overlay': '浮层',
83
+ 'placement.inline': '页面内联',
84
+ 'placement.legacy': '整行旧版',
85
+ 'config.summary.legacy': '整行旧版',
86
+ 'config.hint.legacy': '整条 assistant 行套框、正文保持旧的内部滚动;同样向下盖住 action bar,功能键在左下。',
87
+ 'config.summary.overlay': '浮层',
88
+ 'config.summary.inline': '页面内联',
89
+ 'config.hint.overlay': '悬浮在视口内,长内容在编辑框内滚动。',
90
+ 'config.hint.inline': '按页面坐标铺在消息上,跟随页面滚动,高度按内容展开,不再截到视口。',
91
+ 'config.summary.frame': '整行描边',
92
+ 'config.summary.bubble': '贴合气泡',
93
+ 'config.summary.center': '居中面板',
94
+ 'config.hint.frame': '一圈描边套住整条消息行,正文输入框落在消息文字上,按钮在右上角。',
95
+ 'config.hint.bubble': '编辑框精确等于消息气泡的大小。',
96
+ 'config.hint.center': '大号居中面板,输入框随内容增长。',
97
+ 'style.frame': '整行',
98
+ 'style.bubble': '气泡',
99
+ 'style.center': '居中',
100
+ 'editor.block.text': '正文',
101
+ 'editor.block.reasoning': '思维链',
102
+ 'editor.placeholder-block': '只改这一块;工具调用、其余内容和后续对话保持不变。',
103
+ 'confirm.body-block': '确认后只替换这一块,工具调用、其他内容和后续对话保持不变。原始会话日志不会改写。',
65
104
  'editor.review': '确认执行',
66
105
  'editor.cancel': '取消',
67
106
  'editor.pending': '正在保存…',
@@ -69,12 +108,14 @@ window.__ModuleLoader__.load({
69
108
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
70
109
  'editor.attach.add': '添加附件',
71
110
  'editor.attach.remove': '移除这个附件',
72
- 'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
111
+ 'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
73
112
  'error.invalid': '请求无效,请刷新后重试。',
74
113
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
75
114
  'error.session-not-found': '找不到该会话的日志。',
115
+ 'error.session-empty': '这个会话还没有内容,暂时没有可编辑的东西。',
76
116
  'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
77
117
  'error.not-editable': '这条消息不支持编辑。',
118
+ 'error.unsupported': '当前宿主不支持就地改写回复,需要走重放路径。',
78
119
  'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
79
120
  'error.stale': '会话刚刚发生了变化,请重试。',
80
121
  'error.forbidden': '请求来源不被允许。',
@@ -89,11 +130,13 @@ window.__ModuleLoader__.load({
89
130
  'error.already-retired': '这一轮的内容已经不在当前上下文里了。',
90
131
  'error.rerunning': '已经有一次重跑在进行中,请等它结束。',
91
132
  'error.internal': '服务器内部错误,请稍后重试。',
133
+ 'error.projection-missing': '编辑投影未注册,请重启 DSH 后重试。',
92
134
  'error.attachment-refused': '附件没能存下来,本次修改没有执行。',
93
135
  'error.attachments-dropped': '已保存,但这条消息的图片或文件没能保留:当前部署没有可用的附件存储。',
94
136
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
95
137
  'error.attachment-too-large': '这个文件太大,无法作为附件。',
96
138
  'error.too-large': '这次修改太大,服务器没有接收。',
139
+ 'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
97
140
  }
98
141
 
99
142
  const en = {
@@ -104,11 +147,47 @@ window.__ModuleLoader__.load({
104
147
  'action.copied': 'Copied',
105
148
  'editor.title': 'Edit this message',
106
149
  'editor.title-reply': 'Edit this reply',
107
- 'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
150
+ 'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
108
151
  'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
109
152
  'editor.save': 'Save',
110
153
  'editor.rerun': 'Re-run',
111
154
  'editor.save-reply': 'Save',
155
+ 'editor.block': 'Block',
156
+ 'editor.block.all': 'All blocks',
157
+ 'config.style': 'Editor style',
158
+ 'config.placement': 'Editor placement',
159
+ 'config.height': 'Editor height',
160
+ 'height.max': 'Turn max',
161
+ 'height.content': 'Content',
162
+ 'height.fill': 'Fill when all',
163
+ 'config.summary.height.max': 'Turn max',
164
+ 'config.summary.height.content': 'Content',
165
+ 'config.summary.height.fill': 'Fill when all',
166
+ 'config.hint.height.max': 'Opens at the tallest reasoning/text block of the turn; while editing it grows with your text and shrinks back, never below the height it opened at.',
167
+ 'config.hint.height.content': 'Opens sized for the current block; while editing it grows with your text and shrinks back, never below the height it opened at.',
168
+ 'config.hint.height.fill': 'Single blocks open at the turn max and follow your text while editing (never below the opening height); the all-blocks page fills the whole band between the conversation top and the composer, with one scrollbar.',
169
+ 'placement.overlay': 'Overlay',
170
+ 'placement.inline': 'In-page',
171
+ 'placement.legacy': 'Legacy row',
172
+ 'config.summary.legacy': 'Legacy row',
173
+ '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.',
174
+ 'config.summary.overlay': 'Overlay',
175
+ 'config.summary.inline': 'In-page',
176
+ 'config.hint.overlay': 'Floats inside the viewport; long content scrolls inside the editor.',
177
+ '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.',
178
+ 'config.summary.frame': 'Row frame',
179
+ 'config.summary.bubble': 'Exact bubble',
180
+ 'config.summary.center': 'Centered panel',
181
+ 'config.hint.frame': 'Outline the whole message row; the textarea sits on the message text and the actions move to the top-right.',
182
+ 'config.hint.bubble': 'The editor is exactly the message bubble.',
183
+ 'config.hint.center': 'A large centred panel whose textarea grows with the content.',
184
+ 'style.frame': 'Frame',
185
+ 'style.bubble': 'Bubble',
186
+ 'style.center': 'Center',
187
+ 'editor.block.text': 'Answer',
188
+ 'editor.block.reasoning': 'Reasoning',
189
+ 'editor.placeholder-block': 'Only this block changes; tool calls, the rest of the reply and later turns stay.',
190
+ '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.',
112
191
  'editor.review': 'Confirm',
113
192
  'editor.cancel': 'Cancel',
114
193
  'editor.pending': 'Working...',
@@ -116,12 +195,14 @@ window.__ModuleLoader__.load({
116
195
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
117
196
  'editor.attach.add': 'Add an attachment',
118
197
  'editor.attach.remove': 'Remove this attachment',
119
- 'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
198
+ 'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
120
199
  'error.invalid': 'Invalid request; refresh and try again.',
121
200
  'error.session-not-active': 'This session is not open in DSH; open it first.',
122
201
  'error.session-not-found': 'No session log was found for this id.',
202
+ 'error.session-empty': 'This session has nothing in it yet; there is nothing to edit.',
123
203
  'error.busy': 'This session is still replying; wait for it to finish.',
124
204
  'error.not-editable': 'This message cannot be edited.',
205
+ 'error.unsupported': 'This harness does not expose the in-place reply path; the replay path is needed.',
125
206
  'error.already-rolled-back': 'This message is no longer in the current context.',
126
207
  'error.stale': 'The session just changed; try again.',
127
208
  'error.forbidden': 'The request origin is not allowed.',
@@ -136,11 +217,13 @@ window.__ModuleLoader__.load({
136
217
  'error.already-retired': 'This turn is no longer part of the current context.',
137
218
  'error.rerunning': 'A re-run is already in flight; wait for it to finish.',
138
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.',
139
221
  'error.attachment-refused': 'The attachment could not be stored, so nothing was changed.',
140
222
  'error.attachments-dropped': 'Saved, but the images or files on this message could not be kept: this deployment mounts no attachment store.',
141
223
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
142
224
  'error.attachment-too-large': 'That file is too large to attach.',
143
225
  'error.too-large': 'That revision was too large for the host to accept.',
226
+ 'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
144
227
  }
145
228
 
146
229
  // Every error code the host can return travels through here. A code is
@@ -153,7 +236,157 @@ window.__ModuleLoader__.load({
153
236
  return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
154
237
  }
155
238
 
239
+ // --- a rewritten prompt, rendered the way the bubble it stands in for is ---
240
+
241
+ /**
242
+ * Whether the markdown bubble plugin is on this page.
243
+ *
244
+ * Its own presence marker is the only honest signal: the host draws a user
245
+ * bubble as plain text (`white-space: pre-wrap`), so rendering markdown in
246
+ * the revision bubble is only correct when something else is already
247
+ * rendering the OTHER user bubbles that way. Without it, a rewritten prompt
248
+ * would look different from every untouched prompt around it.
249
+ */
250
+ function markdownBubbleInstalled() {
251
+ try {
252
+ return typeof globalThis !== 'undefined' && globalThis.__DSH_MARKDOWN_BUBBLE__ !== undefined
253
+ } catch {
254
+ return false
255
+ }
256
+ }
257
+
258
+ /** One mounted markdown occurrence per bubble, so a rebuild can unmount it. */
259
+ const revisionRoots = new WeakMap()
260
+
261
+ /**
262
+ * Draw one rewritten prompt's text into `host`.
263
+ *
264
+ * The host's own markdown renderer (`MarkdownText`) is a React component and
265
+ * this half builds DOM by hand, so it is mounted through `react-dom/client` -
266
+ * the same module the host's own bundles require. Anything missing (no
267
+ * react-dom, no primitives, a hostile host shape) falls back to the plain
268
+ * text node this bubble has always used.
269
+ *
270
+ * @param host - the bubble element that shows the text.
271
+ * @param text - the rewritten text.
272
+ * @returns whether the markdown path was used.
273
+ */
274
+ function renderRevisionText(host, text) {
275
+ if (!markdownBubbleInstalled()) return false
276
+ try {
277
+ const react = require('react')
278
+ const reactDom = require('react-dom/client')
279
+ const MarkdownText = primitives.MarkdownText
280
+ if (
281
+ typeof react?.createElement !== 'function' ||
282
+ typeof reactDom?.createRoot !== 'function' ||
283
+ MarkdownText === undefined ||
284
+ MarkdownText === null
285
+ ) {
286
+ return false
287
+ }
288
+ const previous = revisionRoots.get(host)
289
+ if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
290
+ const node = document.createElement('span')
291
+ node.className = 'dshet-revision-text'
292
+ host.appendChild(node)
293
+ const root = reactDom.createRoot(node)
294
+ root.render(react.createElement(MarkdownText, { text }))
295
+ revisionRoots.set(host, root)
296
+ return true
297
+ } catch (error) {
298
+ console.warn('[dsh-edit-turn] the rewritten prompt could not be rendered as markdown:', error)
299
+ return false
300
+ }
301
+ }
302
+
156
303
  // --- style ----------------------------------------------------------------
304
+ /**
305
+ * Take the active skin's own surfaces when they are actually opaque.
306
+ *
307
+ * A skin may paint its surfaces translucent so artwork shows through, and it
308
+ * compensates with its own text halo - which a plugin element does not have.
309
+ * So the theme's surfaces are adopted only after resolving them to a real
310
+ * colour and finding them opaque; a translucent skin leaves this editor on
311
+ * its own opaque palette. Colours, radii and shadows need no such guard:
312
+ * those are hue and geometry, and they survive any skin.
313
+ *
314
+ * The result is written as inline variables on `body`, which beats both the
315
+ * `:root` defaults and the dark-theme rule below them.
316
+ *
317
+ * @returns nothing; the stylesheet defaults stand when no probe is possible.
318
+ */
319
+ function resolveSkinPalette() {
320
+ if (typeof document === 'undefined' || !document.body || typeof document.createElement !== 'function') return
321
+ if (typeof getComputedStyle !== 'function') return
322
+ const probe = document.createElement('span')
323
+ probe.setAttribute('aria-hidden', 'true')
324
+ probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;pointer-events:none'
325
+ document.body.appendChild(probe)
326
+ const read = (name) => {
327
+ try {
328
+ probe.style.color = `var(${name})`
329
+ const match = /rgba?\(([^)]+)\)/.exec(getComputedStyle(probe).color)
330
+ if (match === null) return null
331
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
332
+ if (parts.length < 3 || parts.some((value) => !Number.isFinite(value))) return null
333
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
334
+ } catch {
335
+ return null
336
+ }
337
+ }
338
+ const css = (color) => `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
339
+ const style = document.body.style
340
+ const setVar = (name, value) => {
341
+ if (style === undefined || style === null) return
342
+ if (typeof style.setProperty === 'function') style.setProperty(name, value)
343
+ else style[name] = value
344
+ }
345
+ const clearVar = (name) => {
346
+ if (style === undefined || style === null) return
347
+ if (typeof style.removeProperty === 'function') style.removeProperty(name)
348
+ else delete style[name]
349
+ }
350
+ try {
351
+ const panel = read('--dsw-alias-bg-layer-1')
352
+ const layer2 = read('--dsw-alias-bg-layer-2')
353
+ const accent = read('--dsw-alias-button-primary-fill')
354
+ // The skin's surface HUE is taken; its transparency is not. A skin paints
355
+ // its own elements translucent over artwork and gives them a text halo -
356
+ // a plugin element has none, so the same colour is adopted at full
357
+ // opacity. That keeps a dark artwork skin dark (instead of falling back
358
+ // to this plugin's light palette, which is what a skin that never sets
359
+ // `data-ds-dark-theme` used to get) without ever painting text on art.
360
+ const solid = (color) => (color === null ? null : { r: color.r, g: color.g, b: color.b, a: 1 })
361
+ const panelSolid = solid(panel)
362
+ if (panelSolid === null) {
363
+ clearVar('--dshet-panel')
364
+ clearVar('--dshet-chip')
365
+ clearVar('--dshet-field')
366
+ } else {
367
+ const fieldSolid = solid(layer2) ?? panelSolid
368
+ setVar('--dshet-panel', css(panelSolid))
369
+ setVar('--dshet-chip', css(panelSolid))
370
+ setVar('--dshet-field', css(fieldSolid))
371
+ }
372
+ if (accent === null) {
373
+ clearVar('--dshet-on-accent')
374
+ } else {
375
+ // The label that sits ON the accent fill: the platform exposes no
376
+ // inverse token here, so it is picked from the fill's own luminance.
377
+ const channel = (value) => {
378
+ const v = value / 255
379
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
380
+ }
381
+ const luminance = 0.2126 * channel(accent.r) + 0.7152 * channel(accent.g) + 0.0722 * channel(accent.b)
382
+ setVar('--dshet-on-accent', luminance > 0.35 ? '#0f1115' : '#fff')
383
+ }
384
+ } finally {
385
+ if (typeof probe.remove === 'function') probe.remove()
386
+ }
387
+ }
388
+
389
+
157
390
 
158
391
  // A skin is free to make the theme's surface colours translucent - that is
159
392
  // the point of one: the artwork shows through, and the skin compensates by
@@ -168,8 +401,21 @@ window.__ModuleLoader__.load({
168
401
  // UI packages use. Theme variables are still used for *colours* (hue,
169
402
  // semantic tones) - those survive a skin; it is alpha that does not.
170
403
  const CSS = [
171
- ':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
172
- 'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
404
+ // Colours, radii and shadows come from the active theme's own tokens, so a
405
+ // skin restyles this editor the way it restyles the rest of the app; every
406
+ // one of them falls back to the value this plugin shipped with. The palette
407
+ // hangs off `body`, not `:root`: a custom property substitutes against the
408
+ // values in scope where it is DEFINED, and the theme declares its tokens on
409
+ // body - on `:root` every reference would silently fall back. Only the
410
+ // SURFACES stay namespaced: a skin is free to make its own surfaces
411
+ // translucent (that is the point of one), and a plugin's element has no
412
+ // artwork-aware halo to stay readable behind. The surfaces are taken from
413
+ // the theme too, but only when a runtime probe finds them opaque (see
414
+ // resolveSkinPalette).
415
+ '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)}',
416
+ '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))}',
417
+ // A hover wash that follows the skin's ink instead of a fixed grey.
418
+ '@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)}}',
173
419
  // The pencil sits among the platform's own action icons, so it takes the
174
420
  // platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
175
421
  // read as a different, "active" icon next to the neutral grey ones. The
@@ -195,7 +441,7 @@ window.__ModuleLoader__.load({
195
441
  // inherit picks up the row's 16px, fixed ignores the reader's content
196
442
  // font setting, and either way the rewritten text changed size against
197
443
  // the bubble it replaces.
198
- '.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:16px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
444
+ '.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}',
199
445
  '.dshet-revision:empty{display:none}',
200
446
  '.dshet-revision{position:relative}',
201
447
  // Fallback only: a row with no action bar to keep is displayed away
@@ -204,6 +450,9 @@ window.__ModuleLoader__.load({
204
450
  // positioned button inside the bubble used to land on the last line, which
205
451
  // is how the rewritten prompt got covered up.
206
452
  '.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
453
+ '.dshet-revision-text{display:block}',
454
+ '.dshet-revision-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}',
455
+ '.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}',
207
456
  '.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
208
457
  '.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
209
458
  // A sibling plugin can append its own button (dsh-delete-turn's bin) into
@@ -242,11 +491,21 @@ window.__ModuleLoader__.load({
242
491
  // composition and its clicks a piece at a time. The layer covers the
243
492
  // viewport but only the box itself takes pointer events, and the box is
244
493
  // positioned under its row by `placeEditor`.
494
+ '.dshet-layer[data-dshet-inline="1"]{position:absolute;left:0;top:0;right:auto;bottom:auto;width:100%;height:0}',
245
495
  '.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
246
496
  '.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
247
- '.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
497
+ '.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}',
498
+ '.dshet-editor[data-dshet-drop="1"]{outline:2px dashed #4a7dff;outline-offset:-4px}',
248
499
  '.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
249
- '.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));outline:none}',
500
+ '.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}',
501
+ '.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}',
502
+ '.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}',
503
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn{padding:2px 8px;font-size:12px;line-height:18px;border-radius:8px}',
504
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-pick{position:static;top:auto;left:auto;padding:0;background:transparent;border-radius:0}',
505
+ '.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}',
506
+ '.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}',
507
+
508
+ '.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}',
250
509
  '.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
251
510
  '.dshet-editor textarea:disabled{opacity:.65}',
252
511
  // The blocks this message carries: one chip each, drawn from the same
@@ -262,8 +521,9 @@ window.__ModuleLoader__.load({
262
521
  '.dshet-chip-remove:hover:not(:disabled){background:var(--dshet-hover);color:var(--dsw-alias-state-error-primary,#c93a31)}',
263
522
  '.dshet-chip-remove:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:1px}',
264
523
  '.dshet-chip-remove:disabled{cursor:default;opacity:.5}',
265
- '.dshet-attach-row{display:flex;align-items:center;gap:8px;margin:8px 14px 0}',
266
- '.dshet-attach{padding:3px 10px;border:1px dashed var(--dshet-line);border-radius:999px;background:transparent;color:var(--dshet-ink-dim);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s,color .12s}',
524
+ '.dshet-attach-row{position:absolute;left:12px;bottom:10px;z-index:2;display:flex;align-items:center;margin:0}',
525
+ '.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}',
526
+ '.dshet-attach svg{width:14px;height:14px;display:block;stroke:currentColor;fill:none;stroke-width:1.3;}',
267
527
  '.dshet-attach:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink)}',
268
528
  '.dshet-attach:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
269
529
  '.dshet-attach:disabled{cursor:default;opacity:.5}',
@@ -272,13 +532,46 @@ window.__ModuleLoader__.load({
272
532
  '.dshet-note{color:var(--dshet-ink-dim)}',
273
533
  '.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
274
534
  '.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
275
- '.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
535
+ '.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)}',
536
+ '.dshet-footer-actions{display:flex;align-items:center;gap:6px}',
537
+ '.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}',
538
+ '.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}',
539
+ '.dshet-all-nav button:hover{color:var(--dshet-ink)}',
540
+ '.dshet-all{position:absolute;left:8px;right:8px;top:6px;bottom:52px;overflow-y:auto;display:flex;flex-direction:column;padding:2px 4px 4px}',
541
+ '.dshet-all-block{display:flex;flex-direction:column;padding:6px 0 8px}',
542
+ '.dshet-all-block:first-child{padding-top:2px}',
543
+ '.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}',
544
+ '.dshet-all-head>span:first-child{background:var(--dshet-hover);border-radius:6px;padding:1px 6px;white-space:nowrap}',
545
+ '.dshet-all-line{flex:1;height:1px;background:var(--dshet-line);opacity:.55}',
546
+ '.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}',
547
+ '.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}',
548
+ '.dshet-editor .dshet-footer .dshet-btn{height:34px;box-sizing:border-box;padding:0 14px}',
276
549
  '.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}',
277
550
  '.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
278
551
  '.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
279
552
  '.dshet-btn:disabled{cursor:default;opacity:.5}',
553
+ '.dshet-btn-save{width:28px;height:28px;padding:0;border:0;border-radius:50%;background:transparent;color:#81858c}',
554
+ '.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover)}',
555
+ '.dshet-btn-save svg{width:15px;height:15px;display:block;stroke:currentColor;fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
556
+ '.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}',
280
557
  '.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))}',
281
558
  '.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,var(--dshet-accent));filter:brightness(1.08)}',
559
+ '.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}',
560
+ '.dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
561
+ '.dshet-btn.dshet-btn-save.dshet-btn-confirm{background:var(--dshet-hover);box-shadow:inset 0 0 0 1.5px var(--dshet-accent)}',
562
+ '.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}',
563
+ '.dshet-editor .dshet-footer .dshet-btn.dshet-btn-icon:hover:not(:disabled){background:var(--dshet-hover);color:var(--dshet-ink-soft);filter:none}',
564
+ '.dshet-btn-icon svg{width:15px;height:15px;display:block;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round}',
565
+ '.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}',
566
+ '.dshet-editor[data-dshet-inplace="1"] .dshet-btn.dshet-btn-save:hover:not(:disabled){background:var(--dshet-hover);filter:none}',
567
+ '.dshet-pick{display:flex;align-items:center;gap:4px;padding:0;font-size:12px;color:var(--dshet-ink-dim)}',
568
+ '.dshet-config{display:flex;flex-direction:column;gap:10px}',
569
+ '.dshet-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
570
+ '.dshet-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
571
+ '.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}',
572
+ '.dshet-pick .dshet-select{height:28px;padding:0 6px;border:0;border-radius:8px;background:transparent;line-height:1;cursor:pointer;outline:none}',
573
+ '.dshet-pick .dshet-select:hover{background:var(--dshet-hover)}',
574
+ '[data-dshet-patch]{white-space:pre-wrap}',
282
575
  '.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}',
283
576
  '@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
284
577
  ].join('')
@@ -340,8 +633,13 @@ window.__ModuleLoader__.load({
340
633
  this.editorElement = null
341
634
  this.editorBox = null
342
635
  this.editorRow = null
636
+ this.editorAnchor = null
637
+ this.editorMode = 'reply'
638
+ this.editorStyle = 'frame'
343
639
  this.editorFocus = false
344
640
  this.editorDraft = ''
641
+ this.editorBlockKey = ''
642
+ this.editorDrafts = new Map()
345
643
  // The block list being edited: what the carrier will be rebuilt from.
346
644
  // Every entry is either `{ keep: <index> }` - a block of this message the
347
645
  // user left alone - or `{ add: { data, mediaType?, name } }` for
@@ -366,6 +664,15 @@ window.__ModuleLoader__.load({
366
664
  editable: new Map(),
367
665
  replies: new Map(),
368
666
  repliesByMessage: new Map(),
667
+ // Landed in-place reply patches, latest per block, and the same data
668
+ // indexed by seq for the in-place text display.
669
+ patches: [],
670
+ patchBySeq: new Map(),
671
+ replyMode: 'replay',
672
+ // The block being edited in the reply editor (null for a prompt).
673
+ blocks: [],
674
+ blockIndex: null,
675
+ blockKind: null,
369
676
  editing: null,
370
677
  draft: '',
371
678
  parts: [],
@@ -444,6 +751,22 @@ window.__ModuleLoader__.load({
444
751
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
445
752
  if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
446
753
  }
754
+ // The in-place reply patches, latest per block. The model sees them
755
+ // through the host projection; this half uses them to draw the
756
+ // projected text over the recorded row.
757
+ const patches = []
758
+ const patchBySeq = new Map()
759
+ for (const patch of Array.isArray(data.patches) ? data.patches : []) {
760
+ if (!patch || typeof patch.seq !== 'number' || typeof patch.blockIndex !== 'number') continue
761
+ if (patch.kind !== 'text' && patch.kind !== 'reasoning') continue
762
+ const normalized = { seq: patch.seq, blockIndex: patch.blockIndex, kind: patch.kind, text: String(patch.text ?? '') }
763
+ const previous = patches.findIndex((entry) => entry.seq === normalized.seq && entry.blockIndex === normalized.blockIndex)
764
+ if (previous >= 0) patches[previous] = normalized
765
+ else patches.push(normalized)
766
+ let byBlock = patchBySeq.get(normalized.seq)
767
+ if (byBlock === undefined) { byBlock = new Map(); patchBySeq.set(normalized.seq, byBlock) }
768
+ byBlock.set(normalized.blockIndex, normalized)
769
+ }
447
770
  // The model's replies are editable too, as a separate list: they are
448
771
  // rewritten rather than re-run, so the editor words the consequence
449
772
  // differently.
@@ -456,13 +779,26 @@ window.__ModuleLoader__.load({
456
779
  // replies are indexed by it too.
457
780
  if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
458
781
  }
782
+ let preferredStyle = data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
783
+ ? data.config.editorStyle
784
+ : 'frame'
785
+ const savedStyle = readStoredEditorStyle()
786
+ if (savedStyle !== null) preferredStyle = savedStyle
787
+ this.editorStyle = preferredStyle
459
788
  this.publish({
460
789
  hidden,
461
790
  revisions,
462
791
  editable,
463
792
  replies,
464
793
  repliesByMessage,
794
+ patches,
795
+ patchBySeq,
796
+ replyMode: data.mode === 'patch' ? 'patch' : 'replay',
797
+ editorStyle: data.config && (data.config.editorStyle === 'bubble' || data.config.editorStyle === 'center')
798
+ ? data.config.editorStyle
799
+ : 'frame',
465
800
  surfaceReady: true,
801
+ busy: data.busy === true,
466
802
  loaded: true,
467
803
  loadError: false,
468
804
  confirmStep: !(data.config && data.config.confirm === false),
@@ -477,6 +813,14 @@ window.__ModuleLoader__.load({
477
813
  })
478
814
  .finally(() => {
479
815
  this.inflight = null
816
+ // While the session is replying, the editor stays open but cannot
817
+ // save; re-check so the banner clears itself when the turn ends.
818
+ if (this.view.busy === true && this.editor !== null) {
819
+ const token = (this.busyToken = (this.busyToken ?? 0) + 1)
820
+ window.setTimeout(() => {
821
+ if (this.busyToken === token && this.editor !== null) this.load()
822
+ }, 3000)
823
+ }
480
824
  })
481
825
  this.inflight = pending
482
826
  return pending
@@ -489,10 +833,53 @@ window.__ModuleLoader__.load({
489
833
  const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
490
834
  const previous = this.editorElement
491
835
  this.load()
492
- this.editor = target
836
+ // A reply on the patch path carries its editable blocks; open the one
837
+ // the editor was asked for, or the answer text when none was named.
838
+ const replyBlocks = target.mode === 'reply' && Array.isArray(target.blocks) ? target.blocks : []
839
+ const requested = typeof target.blockIndex === 'number'
840
+ ? replyBlocks.find((block) => block.index === target.blockIndex)
841
+ : undefined
842
+ // Pressing the same pencil again keeps the block the user already
843
+ // switched to; only a fresh open picks the answer block by default.
844
+ const current = same && typeof this.editorBlockIndex === 'number'
845
+ ? replyBlocks.find((block) => block.index === this.editorBlockIndex)
846
+ : undefined
847
+ // A fresh open always lands on the answer text: a turn's block list is
848
+ // mostly reasoning now, and opening on a chain-of-thought is not what
849
+ // the pencil means. The picker is how the user goes to a thought.
850
+ const answerBlock = [...replyBlocks].reverse().find((block) => block.kind === 'text')
851
+ const chosen = (same ? current : undefined) ?? answerBlock ?? requested ?? replyBlocks[0]
852
+ const normalized = replyBlocks.length > 0
853
+ ? {
854
+ ...target,
855
+ patch: true,
856
+ blocks: replyBlocks,
857
+ blockIndex: chosen === undefined ? undefined : chosen.index,
858
+ blockKind: chosen === undefined ? undefined : chosen.kind,
859
+ }
860
+ : target
861
+ this.editor = normalized
862
+ this.editorMode = normalized.mode === 'prompt' ? 'prompt' : 'reply'
863
+ this.editorAnchor = target.anchor !== undefined && target.anchor !== null && target.anchor.isConnected === true
864
+ ? target.anchor
865
+ : null
866
+ this.editorBlocks = replyBlocks
867
+ this.editorBlockIndex = chosen === undefined ? null : chosen.index
868
+ this.editorBlockKind = chosen === undefined ? null : chosen.kind
869
+ this.editorTargetSeq = chosen !== undefined && typeof chosen.seq === 'number' ? chosen.seq : null
870
+ this.editorAll = false
871
+ // The height the box opens at: the floor the user cannot shrink below
872
+ // for as long as this edit lasts (save or cancel ends it). Null until
873
+ // the first layout measures it.
874
+ this.editorBaseHeight = null
875
+ // ONE draft store per open, keyed by block: the 正文/思维链 pages and the
876
+ // sub-pages inside 全部 all read and write it, so switching pages never
877
+ // drops what the user typed (only closing the editor does).
878
+ this.editorDrafts = new Map()
493
879
  if (!same) {
494
- this.editorDraft = target.text
495
- this.editorParts = blocksToParts(target, this.sessionId)
880
+ this.editorBlockKey = chosen === undefined ? '' : blockDraftKey(chosen)
881
+ this.editorDraft = chosen === undefined ? target.text : chosen.text
882
+ this.editorParts = blocksToParts(normalized, this.sessionId)
496
883
  }
497
884
  this.confirming = false
498
885
  this.editorElement = null
@@ -509,7 +896,18 @@ window.__ModuleLoader__.load({
509
896
  /* focus is best-effort */
510
897
  }
511
898
  }
512
- this.publish({ editing: target, draft: this.editorDraft, parts: this.editorParts.slice(), confirming: false, failure: null })
899
+ this.publish({
900
+ editing: normalized,
901
+ draft: this.editorDraft,
902
+ parts: this.editorParts.slice(),
903
+ blocks: replyBlocks,
904
+ blockIndex: this.editorBlockIndex,
905
+ blockKind: this.editorBlockKind,
906
+ targetSeq: this.editorTargetSeq,
907
+ allBlocks: false,
908
+ confirming: false,
909
+ failure: null,
910
+ })
513
911
  this.probeSiblingRerun()
514
912
  }
515
913
 
@@ -537,23 +935,108 @@ window.__ModuleLoader__.load({
537
935
  if (this.view.siblingRerun !== next) this.publish({ siblingRerun: next })
538
936
  }
539
937
 
938
+ // Pick another block of the same reply. The editor is rebuilt by the
939
+ // block index in its marker; the draft becomes that block's current
940
+ // (already projected) text.
941
+ selectBlock(value) {
942
+ if (this.view.pending) return
943
+ const target = this.editor
944
+ if (target === null || !Array.isArray(target.blocks)) return
945
+ const raw = String(value)
946
+ if (raw === 'all') {
947
+ // The all-blocks page reads the SAME drafts: a block edited on its own
948
+ // page shows that edit in its sub-page, and the other way round.
949
+ this.editorAll = true
950
+ this.editorElement = null
951
+ this.publish({ allBlocks: true, blockIndex: null, blockKind: null, targetSeq: null, failure: null })
952
+ return
953
+ }
954
+ const split = raw.includes(':') ? raw.split(':') : ['', raw]
955
+ const seq = split[0] === '' ? null : Number(split[0])
956
+ const index = Number(split[1])
957
+ const block = target.blocks.find((entry) =>
958
+ entry.index === index && (seq === null || entry.seq === undefined || entry.seq === seq))
959
+ // Leaving the all-blocks view has to say so: the picker change alone left
960
+ // `allBlocks` true, so the box was redrawn as the all view again with a
961
+ // picker that said "正文" - two fields over a single-block selection.
962
+ this.editorAll = false
963
+ if (block === undefined || block.kind === undefined) {
964
+ // Nothing matched: still leave the all view, keeping the block that was
965
+ // selected before it, so the picker describes the box again.
966
+ this.publish({ allBlocks: false, failure: null })
967
+ return
968
+ }
969
+ this.editorBlockIndex = block.index
970
+ this.editorBlockKind = block.kind
971
+ this.editorTargetSeq = typeof block.seq === 'number' ? block.seq : this.editorTargetSeq
972
+ this.editorBlockKey = blockDraftKey(block)
973
+ // This block's own draft, which may already carry what the user typed on
974
+ // another page - never the recorded text, which used to wipe it.
975
+ this.editorDraft = this.draftOfBlock(block)
976
+ this.editorElement = null
977
+ this.publish({ draft: this.editorDraft, blockIndex: block.index, blockKind: block.kind, targetSeq: this.editorTargetSeq, allBlocks: false, failure: null })
978
+ }
979
+
540
980
  close() {
541
981
  if (this.view.pending) return
542
982
  this.pendingRerun = false
543
983
  this.editorElement = null
544
984
  this.editorBox = null
545
985
  this.editorRow = null
986
+ this.editorAnchor = null
987
+ this.editorMode = 'reply'
546
988
  this.editorFocus = false
547
989
  this.editor = null
548
990
  this.editorDraft = ''
549
991
  this.editorParts = []
550
992
  this.confirming = false
551
993
  this.editorElement = null
552
- this.publish({ editing: null, draft: '', parts: [], confirming: false, failure: null })
994
+ this.editorBlocks = []
995
+ this.editorBlockIndex = null
996
+ this.editorBlockKind = null
997
+ this.editorTargetSeq = null
998
+ this.editorAll = false
999
+ this.editorDrafts = new Map()
1000
+ this.editorBlockKey = ''
1001
+ this.editorBaseHeight = null
1002
+ this.publish({ editing: null, draft: '', parts: [], blocks: [], blockIndex: null, blockKind: null, confirming: false, failure: null })
1003
+ }
1004
+
1005
+ /** The draft of one block: what the user typed, else the recorded text. */
1006
+ draftOfBlock(block) {
1007
+ const drafts = this.editorDrafts
1008
+ if (drafts instanceof Map) {
1009
+ const key = blockDraftKey(block)
1010
+ const typed = drafts.get(key)
1011
+ if (typeof typed === 'string') return typed
1012
+ }
1013
+ return block !== null && block !== undefined && typeof block.text === 'string' ? block.text : ''
1014
+ }
1015
+
1016
+ /** Remember one block's draft, wherever the user typed it. */
1017
+ setDraftFor(block, value) {
1018
+ if (!(this.editorDrafts instanceof Map)) this.editorDrafts = new Map()
1019
+ this.editorDrafts.set(blockDraftKey(block), String(value ?? ''))
553
1020
  }
554
1021
 
555
1022
  setDraft(value) {
556
1023
  this.editorDraft = value
1024
+ // The 正文/思维链 page and the matching sub-page inside 全部 are the same
1025
+ // block: one store, so the edit survives a page switch.
1026
+ const blocks = Array.isArray(this.editorBlocks) ? this.editorBlocks : []
1027
+ const current =
1028
+ blocks.find((block) => blockDraftKey(block) === this.editorBlockKey) ??
1029
+ (typeof this.editorBlockIndex === 'number'
1030
+ ? blocks.find((block) => block.index === this.editorBlockIndex && (this.editorBlockKind === null || block.kind === this.editorBlockKind))
1031
+ : undefined)
1032
+ if (current !== undefined) this.setDraftFor(current, value)
1033
+ }
1034
+
1035
+ setEditorStyle(style) {
1036
+ if (!EDITOR_STYLES.includes(style)) return
1037
+ setEditorStylePreference(style)
1038
+ this.editorStyle = style
1039
+ this.publish({ editorStyle: style })
557
1040
  }
558
1041
 
559
1042
  /**
@@ -621,6 +1104,59 @@ window.__ModuleLoader__.load({
621
1104
  }
622
1105
  }
623
1106
 
1107
+ /** The all-blocks save: one request carrying every edited block. */
1108
+ async confirmAll(target) {
1109
+ const blocks = Array.isArray(target.blocks) ? target.blocks : []
1110
+ const targets = []
1111
+ for (const block of blocks) {
1112
+ const text = this.draftOfBlock(block)
1113
+ if (typeof text !== 'string' || text.trim() === '') {
1114
+ this.publish({ failure: 'empty' })
1115
+ return
1116
+ }
1117
+ if (text !== block.text) {
1118
+ targets.push({ seq: typeof block.seq === 'number' ? block.seq : target.seq, blockIndex: block.index, kind: block.kind, text })
1119
+ }
1120
+ }
1121
+ if (targets.length === 0) {
1122
+ this.publish({ failure: null })
1123
+ this.editor = null
1124
+ this.publish({ editing: null })
1125
+ return
1126
+ }
1127
+ this.publish({ pending: true, failure: null, failureDetail: null })
1128
+ try {
1129
+ const res = await fetch(`${ROUTE_PREFIX}/apply`, {
1130
+ method: 'POST',
1131
+ headers: { 'content-type': 'application/json' },
1132
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1133
+ ? { signal: AbortSignal.timeout(20000) }
1134
+ : {}),
1135
+ body: JSON.stringify({ sessionId: this.sessionId, targets }),
1136
+ })
1137
+ const data = await res.json().catch(() => ({}))
1138
+ if (!res.ok || !data.ok) {
1139
+ const code = data && data.code ? String(data.code) : 'generic'
1140
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1141
+ this.publish({ pending: false, failure: code, failureDetail: detail })
1142
+ return
1143
+ }
1144
+ this.editor = null
1145
+ this.editorDraft = ''
1146
+ this.editorAll = false
1147
+ this.editorDrafts = new Map()
1148
+ this.editorElement = null
1149
+ this.editorBox = null
1150
+ this.editorRow = null
1151
+ this.editorFocus = false
1152
+ this.confirming = false
1153
+ this.publish({ pending: false, editing: null, confirming: false, failure: null })
1154
+ this.load()
1155
+ } catch {
1156
+ this.publish({ pending: false, failure: 'generic' })
1157
+ }
1158
+ }
1159
+
624
1160
  review() {
625
1161
  this.pendingRerun = false
626
1162
  this.runReview()
@@ -656,6 +1192,9 @@ window.__ModuleLoader__.load({
656
1192
  if (target === null || this.view.pending) return
657
1193
  const rerunIntent = this.pendingRerun
658
1194
  this.pendingRerun = false
1195
+ if (this.editorAll === true) {
1196
+ return this.confirmAll(target)
1197
+ }
659
1198
  const text = this.editorDraft
660
1199
  if (text.trim() === '') {
661
1200
  this.publish({ failure: 'empty' })
@@ -668,22 +1207,38 @@ window.__ModuleLoader__.load({
668
1207
  const parts = target.mode === 'prompt' && this.view.attachmentsOk === true
669
1208
  ? this.editorParts.map((part) => (part.keep !== undefined ? { keep: part.keep } : { add: part.add }))
670
1209
  : undefined
671
- this.publish({ pending: true, failure: null })
1210
+ this.publish({ pending: true, failure: null, failureDetail: null })
672
1211
  try {
673
1212
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
674
1213
  method: 'POST',
675
1214
  headers: { 'content-type': 'application/json' },
1215
+ // A hung host must not leave the editor pending forever with a
1216
+ // button that looks dead.
1217
+ ...(typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1218
+ ? { signal: AbortSignal.timeout(20000) }
1219
+ : {}),
676
1220
  body: JSON.stringify({
677
1221
  sessionId: this.sessionId,
678
1222
  seq: target.seq,
679
1223
  messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
680
1224
  text,
681
1225
  ...(parts === undefined ? {} : { parts }),
1226
+ // The in-place reply path names one block; the host validates that
1227
+ // it is still that kind at that index before writing the record.
1228
+ ...(target.patch === true && typeof this.editorBlockIndex === 'number'
1229
+ ? {
1230
+ blockIndex: this.editorBlockIndex,
1231
+ kind: this.editorBlockKind,
1232
+ ...(this.editorTargetSeq === null ? {} : { targetSeq: this.editorTargetSeq }),
1233
+ }
1234
+ : {}),
682
1235
  }),
683
1236
  })
684
1237
  const data = await res.json().catch(() => ({}))
685
1238
  if (!res.ok || !data.ok) {
686
- this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
1239
+ const code = data && data.code ? String(data.code) : 'generic'
1240
+ const detail = data && typeof data.error === 'string' && data.error !== '' ? data.error : null
1241
+ this.publish({ pending: false, failure: code, failureDetail: detail })
687
1242
  return
688
1243
  }
689
1244
  const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
@@ -720,6 +1275,60 @@ window.__ModuleLoader__.load({
720
1275
  // replies here and re-enters through the next /state refresh (the
721
1276
  // appended correction is itself an editable reply).
722
1277
  const replies = new Map(this.view.replies)
1278
+ // The in-place path wrote one small record and touched no surface
1279
+ // row: the reply stays where it is, and its block list is updated
1280
+ // optimistically so the next render already shows the new text.
1281
+ let patches = this.view.patches
1282
+ let patchBySeq = this.view.patchBySeq
1283
+ if (data.mode === 'patch' && data.target && typeof data.target.blockIndex === 'number') {
1284
+ // The BLOCK's own seq, not the reply's. A turn's reasoning can live
1285
+ // in another step's message, and the host reports that seq in the
1286
+ // answer. Keying the optimistic patch by the reply's seq looked up
1287
+ // the wrong block, took the wrong `original`, and the patch then
1288
+ // matched nothing in the DOM - the edit appeared only after a reload,
1289
+ // when /state supplied it under its own seq (reported from the field
1290
+ // for the 思维链 page; the 正文 page looked fine because its block IS
1291
+ // the reply's own).
1292
+ const patchSeq = typeof data.target.seq === 'number' ? data.target.seq : target.seq
1293
+ const targetPatch = {
1294
+ seq: patchSeq,
1295
+ blockIndex: data.target.blockIndex,
1296
+ kind: data.target.kind === 'reasoning' ? 'reasoning' : 'text',
1297
+ text: String(data.target.text ?? ''),
1298
+ }
1299
+ patches = patches
1300
+ .filter((entry) => !(entry.seq === targetPatch.seq && entry.blockIndex === targetPatch.blockIndex))
1301
+ .concat([targetPatch])
1302
+ patchBySeq = new Map(patchBySeq)
1303
+ const byBlock = new Map(patchBySeq.get(targetPatch.seq) ?? [])
1304
+ byBlock.set(targetPatch.blockIndex, targetPatch)
1305
+ patchBySeq.set(targetPatch.seq, byBlock)
1306
+ // The entry that carries this block: the reply's own, or the one
1307
+ // whose block list holds another step's reasoning.
1308
+ let entry = replies.get(patchSeq)
1309
+ if (entry === undefined || !Array.isArray(entry.blocks)) {
1310
+ for (const candidate of replies.values()) {
1311
+ if (Array.isArray(candidate?.blocks) && candidate.blocks.some((block) => block.seq === patchSeq)) {
1312
+ entry = candidate
1313
+ break
1314
+ }
1315
+ }
1316
+ }
1317
+ if (entry !== undefined && Array.isArray(entry.blocks)) {
1318
+ const blocks = entry.blocks.map((block) =>
1319
+ block.index === targetPatch.blockIndex &&
1320
+ (block.seq === undefined || block.seq === patchSeq)
1321
+ ? { ...block, text: targetPatch.text, patched: true }
1322
+ : block)
1323
+ const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
1324
+ replies.set(target.seq, {
1325
+ ...entry,
1326
+ blocks,
1327
+ text,
1328
+ patched: blocks.some((block) => block.patched === true),
1329
+ })
1330
+ }
1331
+ }
723
1332
  for (const entry of replies.values()) {
724
1333
  if (hidden.has(entry.seq)) replies.delete(entry.seq)
725
1334
  }
@@ -740,6 +1349,11 @@ window.__ModuleLoader__.load({
740
1349
  revisions,
741
1350
  editable,
742
1351
  replies,
1352
+ patches,
1353
+ patchBySeq,
1354
+ blocks: [],
1355
+ blockIndex: null,
1356
+ blockKind: null,
743
1357
  parts: [],
744
1358
  failure: null,
745
1359
  })
@@ -773,6 +1387,11 @@ window.__ModuleLoader__.load({
773
1387
  // the only place the user can still be told (see applyEdit's
774
1388
  // `dropped`).
775
1389
  if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
1390
+ // The correction landed, but replaying the turns behind it did not:
1391
+ // the editor is already gone, so this is the only place left to say
1392
+ // that the tail is missing from the context instead of silently
1393
+ // hiding rows the user still expects.
1394
+ if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
776
1395
  }
777
1396
  // Save first, re-run second - always: the sibling re-runs the turn
778
1397
  // from the prompt the surface NOW shows, so the revised wording has
@@ -1044,9 +1663,9 @@ window.__ModuleLoader__.load({
1044
1663
  * at all and reads exactly like a dead pencil.
1045
1664
  */
1046
1665
  function wireAction(button) {
1047
- pressable(button, () => {
1666
+ pressable(button, (event) => {
1048
1667
  const activate = button[ACTIVATE]
1049
- if (typeof activate === 'function') activate()
1668
+ if (typeof activate === 'function') activate(event)
1050
1669
  })
1051
1670
  }
1052
1671
 
@@ -1108,7 +1727,8 @@ window.__ModuleLoader__.load({
1108
1727
  }
1109
1728
  const entry = view.editable.get(headRevision(view.revisions.get(revisionSeq), view.revisions))
1110
1729
  const text = entry && typeof entry.text === 'string' ? entry.text : ''
1111
- if (text === '') {
1730
+ const extraBlocks = entry && Array.isArray(entry.blocks) ? entry.blocks.filter((block) => block.type !== 'text') : []
1731
+ if (text === '' && extraBlocks.length === 0) {
1112
1732
  if (existing !== null) existing.remove()
1113
1733
  releaseRowCopy(row)
1114
1734
  return node.kind !== 'user'
@@ -1121,7 +1741,30 @@ window.__ModuleLoader__.load({
1121
1741
  }
1122
1742
  }
1123
1743
  placeRevisionBubble(row, bubble)
1124
- if (bubble.textContent !== text) bubble.textContent = text
1744
+ // A rewritten prompt may still carry images/files. The carrier keeps
1745
+ // those blocks, the model reads them, and the editor lists them - the
1746
+ // standing-in bubble must show them too, or an edited image message looks
1747
+ // like the images were deleted.
1748
+ const signature = text + '|' + extraBlocks.map((block) =>
1749
+ `${String(block.index)}:${String(block.type)}:${String(block.name ?? '')}:${String(block.bytes ?? '')}`).join(',')
1750
+ // The signature carries whether the markdown path was used: installing or
1751
+ // removing the markdown bubble plugin must redraw this bubble too.
1752
+ const markdownSignature = signature + '|md:' + (markdownBubbleInstalled() ? '1' : '0')
1753
+ if (bubble.dataset.dshetRevisionContent !== markdownSignature) {
1754
+ const previous = revisionRoots.get(bubble)
1755
+ if (previous !== undefined && typeof previous.unmount === 'function') previous.unmount()
1756
+ revisionRoots.delete(bubble)
1757
+ bubble.textContent = ''
1758
+ if (!renderRevisionText(bubble, text)) {
1759
+ const textNode = document.createElement('span')
1760
+ textNode.className = 'dshet-revision-text'
1761
+ textNode.textContent = text
1762
+ bubble.appendChild(textNode)
1763
+ }
1764
+ const files = revisionAttachments(entry, controller)
1765
+ if (files !== null) bubble.appendChild(files)
1766
+ bubble.dataset.dshetRevisionContent = markdownSignature
1767
+ }
1125
1768
  // The edit entry goes where every other action of this row lives: the
1126
1769
  // platform's own bar, which the collapse keeps standing, at its right end
1127
1770
  // behind the clock and the copy button - the row the user reads as "this
@@ -1154,6 +1797,40 @@ window.__ModuleLoader__.load({
1154
1797
  return true
1155
1798
  }
1156
1799
 
1800
+ /**
1801
+ * The non-text blocks of a rewritten prompt, drawn under its text.
1802
+ *
1803
+ * These are the same blocks the editor lists as chips; the bytes come from
1804
+ * this plugin's attachment route, so a thumbnail works without a host API.
1805
+ */
1806
+ function revisionAttachments(entry, controller) {
1807
+ if (!entry || !Array.isArray(entry.blocks)) return null
1808
+ const blocks = entry.blocks.filter((block) => block.type !== 'text')
1809
+ if (blocks.length === 0) return null
1810
+ const strip = document.createElement('div')
1811
+ strip.className = 'dshet-revision-files'
1812
+ for (const block of blocks) {
1813
+ const item = document.createElement('div')
1814
+ item.className = 'dshet-revision-file'
1815
+ if (block.preview === true && typeof block.index === 'number') {
1816
+ const image = document.createElement('img')
1817
+ image.className = 'dshet-thumb'
1818
+ image.alt = ''
1819
+ image.src = previewUrl(controller.sessionId, entry.seq, block.index)
1820
+ image.addEventListener('error', () => {
1821
+ if (typeof image.remove === 'function') image.remove()
1822
+ })
1823
+ item.appendChild(image)
1824
+ }
1825
+ const label = document.createElement('span')
1826
+ label.className = 'dshet-chip-label'
1827
+ label.textContent = typeof block.name === 'string' && block.name !== '' ? block.name : block.type
1828
+ item.appendChild(label)
1829
+ strip.appendChild(item)
1830
+ }
1831
+ return strip
1832
+ }
1833
+
1157
1834
  // A copy of a live user bubble element, classes and all.
1158
1835
  // The key of the row a bubble stands in for. It is what makes a stray
1159
1836
  // bubble recognisable after the host's DOM has shifted around it: an
@@ -1489,9 +2166,39 @@ window.__ModuleLoader__.load({
1489
2166
  // `_actions` suffix is stable) and hides that strip's ancestors' other
1490
2167
  // children up to the row. Returns false when there is no strip to keep, and
1491
2168
  // false is what tells the caller to hide the whole row instead.
2169
+ /**
2170
+ * The host's own message markup inside a row - the stack / bubble / markdown
2171
+ * the transcript draws the text in. That subtree is the ONLY thing the
2172
+ * collapse walk may hide: a sibling that does not contain it belongs to
2173
+ * somebody else (another plugin's injected button, a portal), and hiding it
2174
+ * is issue #1 - `dsh-rewind-pro`'s button stayed in the DOM, stayed
2175
+ * clickable, and was `display:none` on screen with no console error to
2176
+ * notice. Null when the host shape is not one we recognise; the walk then
2177
+ * keeps its old "everything but ours" behaviour instead of leaving a
2178
+ * rewritten prompt on screen.
2179
+ */
2180
+ function messageContentRoot(row) {
2181
+ const selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]', '[class*="_stack"]', '[class*="messageBody"]']
2182
+ for (const selector of selectors) {
2183
+ let found = null
2184
+ try {
2185
+ found = row.querySelector(selector)
2186
+ } catch {
2187
+ found = null
2188
+ }
2189
+ if (found === null || found === undefined) continue
2190
+ if (typeof found.closest === 'function' && found.closest('.dshet-editor') !== null) continue
2191
+ return found
2192
+ }
2193
+ return null
2194
+ }
2195
+
1492
2196
  function collapseRowContent(row, hide) {
1493
2197
  const strip = row.querySelector('[class*="_actions"]')
1494
2198
  if (strip === null) return false
2199
+ // Only consulted while hiding: the restore pass goes by the marker this
2200
+ // plugin wrote, so a node an older build had hidden is still restored.
2201
+ const contentRoot = hide ? messageContentRoot(row) : null
1495
2202
  let node = strip
1496
2203
  while (node !== null && node !== row) {
1497
2204
  const parent = node.parentElement
@@ -1511,6 +2218,16 @@ window.__ModuleLoader__.load({
1511
2218
  ) {
1512
2219
  continue
1513
2220
  }
2221
+ // I5 / issue #1: another plugin's node beside the message is not ours
2222
+ // to hide. Only a sibling that holds the message content is.
2223
+ if (
2224
+ hide &&
2225
+ contentRoot !== null &&
2226
+ typeof child.contains === 'function' &&
2227
+ child.contains(contentRoot) !== true
2228
+ ) {
2229
+ continue
2230
+ }
1514
2231
  if (hide) {
1515
2232
  // Written only when the value changes: this runs on every DOM pass,
1516
2233
  // and an attribute write the observer can see would never settle.
@@ -1662,7 +2379,14 @@ window.__ModuleLoader__.load({
1662
2379
  } else if (button.dataset !== undefined && button.dataset.dshetFallback !== undefined) {
1663
2380
  delete button.dataset.dshetFallback
1664
2381
  }
1665
- button[ACTIVATE] = () => controller.open(entry.target)
2382
+ button[ACTIVATE] = (event) => controller.open({
2383
+ ...entry.target,
2384
+ // The editor belongs beside the pencil the user just pressed, not at the
2385
+ // top of a row that may be taller than the screen.
2386
+ anchor: event !== undefined && event !== null && event.currentTarget !== undefined
2387
+ ? event.currentTarget
2388
+ : button,
2389
+ })
1666
2390
  liveHosts.add(host)
1667
2391
  if (button.getAttribute('aria-label') !== label) {
1668
2392
  button.setAttribute('aria-label', label)
@@ -1792,13 +2516,606 @@ window.__ModuleLoader__.load({
1792
2516
  // Put the box under the row it belongs to: same width, just below its lower
1793
2517
  // edge. Called on render and whenever the page scrolls or resizes, since
1794
2518
  // the layer is fixed to the viewport.
1795
- function placeEditor(row, box) {
2519
+ // Some host skins mark their editor chrome `!important`; only an inline
2520
+ // `!important` outranks that. The stub DOM has no `setProperty`, so it falls
2521
+ // back to a plain assignment there.
2522
+ function forceStyle(element, property, value) {
2523
+ if (element === null || element === undefined || element.style === undefined) return
2524
+ if (typeof element.style.setProperty === 'function') {
2525
+ element.style.setProperty(property, value, 'important')
2526
+ return
2527
+ }
2528
+ element.style[property] = value
2529
+ }
2530
+
2531
+ /**
2532
+ * The surface an in-place editor paints itself with, so the recorded text
2533
+ * underneath cannot show through the draft.
2534
+ *
2535
+ * The skin's own panel colour comes first. A skin that paints the app on
2536
+ * artwork usually leaves `body` transparent (the artwork is a layer behind
2537
+ * it), and the white fallback then put a white box on a night sky. The
2538
+ * palette keeps the skin's hue and drops its transparency, so what comes back
2539
+ * here is opaque by construction.
2540
+ *
2541
+ * @returns a CSS colour for the editor's own background.
2542
+ */
2543
+ function pageBackground() {
2544
+ try {
2545
+ if (typeof getComputedStyle === 'function' && typeof document !== 'undefined' && document.body) {
2546
+ const panel = String(getComputedStyle(document.body).getPropertyValue('--dshet-panel') || '').trim()
2547
+ const match = /rgba?\(([^)]+)\)/.exec(panel)
2548
+ if (match !== null) {
2549
+ const parts = match[1].split(',').map((value) => Number.parseFloat(value))
2550
+ const opaque = parts.length >= 3 && parts.every((value) => Number.isFinite(value)) && (parts.length < 4 || parts[3] >= 0.9)
2551
+ if (opaque) return panel
2552
+ }
2553
+ const value = getComputedStyle(document.body).backgroundColor
2554
+ if (value && value !== 'transparent' && !/rgba?\(0, 0, 0, 0\)/.test(value)) return value
2555
+ }
2556
+ } catch {
2557
+ /* fall through */
2558
+ }
2559
+ return '#ffffff'
2560
+ }
2561
+
2562
+ function viewportSize() {
2563
+ const win = typeof window === 'undefined' ? undefined : window
2564
+ const doc = typeof document === 'undefined' ? undefined : document
2565
+ const width = (win !== undefined && win.innerWidth) ||
2566
+ (doc !== undefined && doc.documentElement && doc.documentElement.clientWidth) || 0
2567
+ const height = (win !== undefined && win.innerHeight) ||
2568
+ (doc !== undefined && doc.documentElement && doc.documentElement.clientHeight) || 0
2569
+ return { width, height }
2570
+ }
2571
+
2572
+ // Put the box under the row it belongs to: same width, just below its lower
2573
+ // edge. The layer is fixed to the viewport, so a row near the bottom (or one
2574
+ // taller than the screen) used to push the footer - and its save button -
2575
+ // past the bottom edge where nothing could reach it. Flip above the row when
2576
+ // there is room, otherwise pin the box to the bottom of the viewport and let
2577
+ // its own scrollbar carry the content; the footer stays sticky either way.
2578
+ /**
2579
+ * The bubble the editor stands in for.
2580
+ *
2581
+ * A prompt is a right-aligned input bubble; a reply is the output content
2582
+ * block. The editor takes that element's width and left edge and overlays its
2583
+ * position, so it reads as the message being edited instead of a floating
2584
+ * panel. Class hashes change between builds, so the suffixes are matched.
2585
+ */
2586
+ function bubbleFor(row, mode, blockKind = null) {
2587
+ let selectors
2588
+ if (mode === 'reply' && blockKind === 'reasoning') {
2589
+ selectors = ['details', '[class*="_reasoning"]', '[class*="_thinking"]', '[class*="reasoning"]']
2590
+ } else if (mode === 'reply') {
2591
+ selectors = ['[class*="_markdown"]', '[class*="_bubble"]', '[class*="messageBody"]']
2592
+ } else {
2593
+ selectors = ['[class*="_bubble"]', '[class*="userStack"]', '[class*="_markdown"]']
2594
+ }
2595
+ for (const selector of selectors) {
2596
+ let nodes = []
2597
+ try {
2598
+ nodes = [...row.querySelectorAll(selector)]
2599
+ } catch {
2600
+ nodes = []
2601
+ }
2602
+ let best = null
2603
+ for (const element of nodes) {
2604
+ if (typeof element.closest === 'function' && element.closest('.dshet-editor') !== null) continue
2605
+ const rect = element.getBoundingClientRect()
2606
+ if (rect.width < 80 || rect.height <= 0) continue
2607
+ if (best === null || rect.width * rect.height > best.rect.width * best.rect.height) best = { element, rect }
2608
+ }
2609
+ if (best !== null) return best
2610
+ }
2611
+ const rect = row.getBoundingClientRect()
2612
+ return { element: row, rect }
2613
+ }
2614
+
2615
+ /**
2616
+ * Which row an editor should attach to when several cover the edited seq.
2617
+ *
2618
+ * A reply's seq appears in both the assistant-step row and the turn-tail
2619
+ * row that carries its action strip; the tail also covers the reply, so a
2620
+ * last-write-wins loop handed the editor the tail's little action bar and
2621
+ * the box came out message-sized instead of bubble-sized. The message row
2622
+ * always wins.
2623
+ */
2624
+ function editorRowRank(row, mode) {
2625
+ const kind = row.getAttribute('data-chat-flow-kind')
2626
+ if (mode === 'prompt') {
2627
+ if (kind === 'user') return 4
2628
+ if (kind === 'revision' || kind === 'user-revision') return 3
2629
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2630
+ return 2
2631
+ }
2632
+ if (kind === 'assistant-step') return 4
2633
+ if (kind === 'turn-tail' || kind === 'turn-process') return 1
2634
+ return 2
2635
+ }
2636
+
2637
+ /**
2638
+ * The vertical band the conversation owns: below its top, above the composer.
2639
+ *
2640
+ * This runs on every scroll frame, so the lookups are cached briefly: the
2641
+ * composer element and the conversation top do not move between frames, and
2642
+ * a global `[class*=...]` scan per frame is what made scrolling feel heavy.
2643
+ */
2644
+ let conversationBoundsCache = null
2645
+ let conversationBoundsAt = 0
2646
+ let composerElementCache = null
2647
+ function conversationBounds(row = null) {
2648
+ const now = Date.now()
2649
+ if (conversationBoundsCache !== null && now - conversationBoundsAt < 250) return conversationBoundsCache
2650
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
2651
+ const bounds = { top: 0, bottom: viewportHeight, composerLeft: 0, composerWidth: 0 }
2652
+ try {
2653
+ const conversation = document.querySelector('[data-slot="main.conversation"]')
2654
+ if (conversation !== null && typeof conversation.getBoundingClientRect === 'function') {
2655
+ bounds.top = conversation.getBoundingClientRect().top
2656
+ }
2657
+ } catch {}
2658
+ // The real scroll area is the nearest scrollable ancestor of the edited
2659
+ // row; the slot wrapper can start above the header, which let the box
2660
+ // cover the tab bar.
2661
+ try {
2662
+ let node = row !== null && row !== undefined ? row.parentElement : null
2663
+ while (node !== null && node !== undefined && node !== document.body) {
2664
+ const style = getComputedStyle(node)
2665
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.getBoundingClientRect().height > 120) {
2666
+ bounds.top = Math.max(bounds.top, node.getBoundingClientRect().top)
2667
+ break
2668
+ }
2669
+ node = node.parentElement
2670
+ }
2671
+ } catch {}
2672
+ // The composer stack has several wrappers with "composer" in the class;
2673
+ // the boundary is the LOWEST one that still sits above the viewport edge,
2674
+ // otherwise the box runs under the input box.
2675
+ try {
2676
+ // The composer CARD is the rounded surface that owns the input, not the
2677
+ // outer seat (which is wider and made the editor overhang). Walk up from
2678
+ // the input element to the first rounded, painted ancestor.
2679
+ let card = document.querySelector('textarea[placeholder], [contenteditable="true"]')
2680
+ while (card !== null && card !== undefined && card !== document.body) {
2681
+ const style = getComputedStyle(card)
2682
+ const radius = Number.parseFloat(style.borderTopLeftRadius)
2683
+ if (radius > 6 && style.backgroundColor !== 'rgba(0, 0, 0, 0)') break
2684
+ card = card.parentElement
2685
+ }
2686
+ let lowest = 0
2687
+ let lowestRect = null
2688
+ if (card !== null && card !== undefined && card !== document.body && typeof card.getBoundingClientRect === 'function') {
2689
+ const rect = card.getBoundingClientRect()
2690
+ if (rect.top > 0 && (viewportHeight === 0 || rect.top < viewportHeight)) {
2691
+ lowest = rect.top
2692
+ lowestRect = rect
2693
+ }
2694
+ }
2695
+ if (lowestRect === null) {
2696
+ const candidates = document.querySelectorAll('[data-slot="conversation.input"], [class*="composerSeat"], [class*="composer"]')
2697
+ for (const candidate of candidates) {
2698
+ if (typeof candidate.getBoundingClientRect !== 'function') continue
2699
+ const rect = candidate.getBoundingClientRect()
2700
+ if (rect.top > lowest && (viewportHeight === 0 || rect.top < viewportHeight - 40)) {
2701
+ lowest = rect.top
2702
+ lowestRect = rect
2703
+ }
2704
+ }
2705
+ }
2706
+ bounds.bottom = lowest > 0 ? lowest : Math.min(viewportHeight, viewportHeight - 120)
2707
+ if (lowestRect !== null) {
2708
+ bounds.composerLeft = lowestRect.left
2709
+ bounds.composerWidth = lowestRect.width
2710
+ }
2711
+ } catch {
2712
+ bounds.bottom = viewportHeight > 0 ? viewportHeight - 120 : viewportHeight
2713
+ }
2714
+ conversationBoundsCache = bounds
2715
+ conversationBoundsAt = now
2716
+ return bounds
2717
+ }
2718
+
2719
+ function placeEditor(row, box, mode = 'reply', style = 'frame', blockKind = null, blocks = null, controller = null) {
1796
2720
  const layer = ensureEditorLayer()
1797
2721
  if (box.parentElement !== layer) layer.appendChild(box)
1798
- const rect = row.getBoundingClientRect()
1799
- box.style.left = `${Math.round(rect.left)}px`
1800
- box.style.top = `${Math.round(rect.bottom + 6)}px`
1801
- box.style.width = `${Math.round(rect.width)}px`
2722
+ const placement = readStoredEditorPlacement() ?? 'overlay'
2723
+ const legacy = placement === 'legacy'
2724
+ const inline = placement === 'inline'
2725
+ // Every fixed mode is clamped to the conversation band by default; the
2726
+ // in-page mode keeps its page coordinates and page scroll.
2727
+ const clamped = !inline
2728
+ if (legacy) style = 'frame'
2729
+ if (inline) layer.dataset.dshetInline = '1'
2730
+ else delete layer.dataset.dshetInline
2731
+ const rowRect = row.getBoundingClientRect()
2732
+ const bubble = bubbleFor(row, mode, blockKind)
2733
+ const storedStyle = readStoredEditorStyle()
2734
+ if (storedStyle !== null) style = storedStyle
2735
+ const chipsInBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2736
+ const minimumHeight = chipsInBox !== null && chipsInBox !== undefined ? 190 : 150
2737
+ const viewport = viewportSize()
2738
+ const margin = 8
2739
+ const area = typeof box.querySelector === 'function' ? box.querySelector('textarea') : null
2740
+ const frame = typeof box.querySelector === 'function' ? box.querySelector('.dshet-frame') : null
2741
+ const allBox = typeof box.querySelector === 'function' ? box.querySelector('.dshet-all') : null
2742
+ const heightMode = readStoredEditorHeight() ?? 'fill'
2743
+ if (frame !== null && frame !== undefined) frame.style.display = style === 'frame' ? '' : 'none'
2744
+ box.style.boxShadow = 'none'
2745
+ const footerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2746
+ if (footerEl !== null && footerEl !== undefined && !(style === 'center' && !inline)) {
2747
+ footerEl.style.position = ''
2748
+ footerEl.style.height = ''
2749
+ footerEl.style.marginTop = ''
2750
+ footerEl.style.borderRadius = ''
2751
+ }
2752
+ if (style === 'center' && !inline) {
2753
+ // A large centred panel, the first shape that shipped. The textarea
2754
+ // grows with its content; the original message stays where it is.
2755
+ delete box.dataset.dshetInplace
2756
+ if (area !== null && area !== undefined) {
2757
+ area.style.position = ''
2758
+ area.style.left = ''
2759
+ area.style.top = ''
2760
+ area.style.width = ''
2761
+ area.style.height = ''
2762
+ area.style.maxHeight = ''
2763
+ area.style.background = ''
2764
+ area.style.paddingBottom = '16px'
2765
+ fitEditorHeight(area)
2766
+ const centerFooter = typeof box.querySelector === 'function' ? box.querySelector('.dshet-footer') : null
2767
+ if (centerFooter !== null && centerFooter !== undefined) {
2768
+ centerFooter.style.position = 'static'
2769
+ centerFooter.style.height = '46px'
2770
+ centerFooter.style.marginTop = '0'
2771
+ centerFooter.style.borderRadius = '0 0 16px 16px'
2772
+ }
2773
+ }
2774
+ const width = viewport.width > 0 ? Math.max(320, Math.min(1400, viewport.width - margin * 2)) : Math.max(320, rowRect.width)
2775
+ box.style.boxSizing = 'border-box'
2776
+ box.style.width = `${Math.round(width)}px`
2777
+ box.style.left = `${Math.round(viewport.width > 0 ? (viewport.width - width) / 2 : rowRect.left)}px`
2778
+ const bounds = conversationBounds(row)
2779
+ const topLimit = bounds.top + margin
2780
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2781
+ const usable = bottomLimit > topLimit ? bottomLimit - topLimit : 0
2782
+ box.style.height = ''
2783
+ box.style.maxHeight = usable > 0
2784
+ ? `${Math.max(180, usable)}px`
2785
+ : viewport.height > 0
2786
+ ? `${Math.max(180, viewport.height - margin * 2)}px`
2787
+ : ''
2788
+ const measured = box.getBoundingClientRect()
2789
+ const height = (measured && measured.height) || box.offsetHeight || 0
2790
+ const top = usable > 0 && height > 0
2791
+ ? Math.round(Math.max(topLimit, Math.min(topLimit + (usable - height) / 2, bottomLimit - height)))
2792
+ : viewport.height > 0 && height > 0
2793
+ ? Math.round(Math.max(margin, Math.min((viewport.height - height) / 2, viewport.height - height - margin)))
2794
+ : Math.round((rowRect.bottom || 0) + 6)
2795
+ box.style.top = `${top}px`
2796
+ return
2797
+ }
2798
+ // `bubble` and `frame` both paint over the message, so the textarea gets
2799
+ // an opaque surface: the recorded text underneath must not show through
2800
+ // the draft. `frame` outlines the whole row; `bubble` outlines just the
2801
+ // message block.
2802
+ box.dataset.dshetInplace = '1'
2803
+ // A full opaque backdrop: the frame covers the whole row, so the host's
2804
+ // status line and action bar underneath must not show through either.
2805
+ const opaque = pageBackground()
2806
+ box.style.background = opaque
2807
+ if (area !== null && area !== undefined) {
2808
+ area.style.background = opaque
2809
+ }
2810
+ // A reply edit is scoped to the selected block (answer text or reasoning),
2811
+ // not to the whole assistant row: the other blocks stay out of the frame.
2812
+ const base = legacy ? rowRect : mode === 'reply' ? bubble.rect : style === 'frame' ? rowRect : bubble.rect
2813
+ let width = base.width
2814
+ let alignedLeft = null
2815
+ const fillAllView = allBox !== null && allBox !== undefined && heightMode !== 'content'
2816
+ if (fillAllView) {
2817
+ // The all-blocks page lines up with the composer card: same left edge,
2818
+ // same width, so the two boxes read as one column.
2819
+ const aligned = conversationBounds(row)
2820
+ if (aligned.composerWidth > 0) {
2821
+ width = aligned.composerWidth
2822
+ alignedLeft = aligned.composerLeft
2823
+ }
2824
+ }
2825
+ if (!fillAllView && mode === 'reply' && typeof rowRect.width === 'number' && rowRect.width > width) {
2826
+ // A reply block is narrower than the conversation row; the editor gets
2827
+ // the full row width so the text is not squeezed into the bubble.
2828
+ width = rowRect.width
2829
+ }
2830
+ let left = alignedLeft === null ? base.left : alignedLeft
2831
+ if (inline) {
2832
+ const pageX = typeof window !== 'undefined' && typeof window.scrollX === 'number' ? window.scrollX : 0
2833
+ left += pageX
2834
+ if (viewport.width > 0) width = Math.min(width, viewport.width - margin * 2)
2835
+ } else if (viewport.width > 0) {
2836
+ width = Math.min(width, viewport.width - margin * 2)
2837
+ left = Math.max(margin, Math.min(left, viewport.width - width - margin))
2838
+ }
2839
+ box.style.boxSizing = 'border-box'
2840
+ box.style.width = `${Math.round(width)}px`
2841
+ box.style.left = `${Math.round(left)}px`
2842
+ // The host's action strip sits under the reported bubble. Extend the
2843
+ // editor down to the row's bottom so that strip is covered, and the
2844
+ // footer controls end up where those host buttons used to be.
2845
+ const baseBottom = typeof base.bottom === 'number' ? base.bottom : base.top + base.height
2846
+ const rowBottom = typeof rowRect.bottom === 'number' ? rowRect.bottom : rowRect.top + rowRect.height
2847
+ // The action strip can be a sibling of the row (the turn-tail row), not
2848
+ // its child. Find the nearest one from our own injected action buttons
2849
+ // and include its bottom in the cover.
2850
+ let actionBottom = 0
2851
+ if (legacy) try {
2852
+ const actions = document.querySelectorAll('.dshet-action')
2853
+ for (const action of actions) {
2854
+ const bar = action.parentElement
2855
+ if (bar === null || bar === undefined || typeof bar.getBoundingClientRect !== 'function') continue
2856
+ const rect = bar.getBoundingClientRect()
2857
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 160) {
2858
+ actionBottom = Math.max(actionBottom, rect.bottom)
2859
+ }
2860
+ }
2861
+ } catch {}
2862
+ // The action strip is rendered as its own turn-tail row under the
2863
+ // message row. Use that row's bottom; if the host renders it in a shape
2864
+ // we cannot identify, reserve one action-band height so the buttons can
2865
+ // never peek out underneath the opaque editor.
2866
+ let tailBottom = 0
2867
+ if (legacy) try {
2868
+ const tails = document.querySelectorAll('[data-chat-flow-kind="turn-tail"]')
2869
+ for (const tail of tails) {
2870
+ if (typeof tail.getBoundingClientRect !== 'function') continue
2871
+ const rect = tail.getBoundingClientRect()
2872
+ if (rect.top >= rowRect.top - 8 && rect.top <= rowRect.bottom + 200) {
2873
+ tailBottom = Math.max(tailBottom, rect.bottom)
2874
+ }
2875
+ }
2876
+ } catch {}
2877
+ const coverBottom = Math.max(rowBottom, actionBottom, tailBottom)
2878
+ const coveredHeight = Math.max(base.height, coverBottom - base.top + 48)
2879
+ // Non-legacy modes hug the content: the box ends right after the last
2880
+ // line plus the control band, instead of stretching to the row bottom.
2881
+ const FOOTER_BAND = 46
2882
+ const boxRect0 = typeof box.getBoundingClientRect === 'function' ? box.getBoundingClientRect() : { top: base.top }
2883
+ const chipStrip0 = typeof box.querySelector === 'function' ? box.querySelector('.dshet-chips') : null
2884
+ const chipBottom0 = chipStrip0 === null || chipStrip0 === undefined || typeof chipStrip0.getBoundingClientRect !== 'function'
2885
+ ? 0
2886
+ : Math.max(0, chipStrip0.getBoundingClientRect().bottom - boxRect0.top + 8)
2887
+ const headerOffset = Math.max(38, chipBottom0, bubble.rect.top - base.top)
2888
+ let contentHeight = Math.round(base.height)
2889
+ if (area !== null && area !== undefined && (allBox === null || allBox === undefined)) {
2890
+ try {
2891
+ const previousValue = area.value
2892
+ const previousHeight = area.style.height
2893
+ // Assigning `value` drops a textarea's scroll position to the top -
2894
+ // even when the assignment is only a measuring detour. Verified in
2895
+ // Chromium: the SAME string keeps the scroll, a DIFFERENT string
2896
+ // resets it to 0, and putting the original back does not bring it
2897
+ // back. The "turn max" pass below writes every other block's text into
2898
+ // this field, so every cache-miss layout (the first one, a page
2899
+ // switch, and every pass while the turn's blocks change - i.e. while
2900
+ // the model streams) threw the reader back to the top. That is what
2901
+ // "the 思维链/正文 page cannot be scrolled down" was: the wheel worked,
2902
+ // the position did not survive the next layout.
2903
+ const previousScroll = typeof area.scrollTop === 'number' ? area.scrollTop : 0
2904
+ const measure = (text) => {
2905
+ area.value = text
2906
+ // The settled height: one pass reads a line short (see
2907
+ // settleAreaHeight), and this number decides the whole box. The
2908
+ // scroller is the box itself, so its position is kept too.
2909
+ return Math.round(withScrollKept(box, () => settleAreaHeight(area)))
2910
+ }
2911
+ const own = measure(previousValue)
2912
+ if (own > 0) contentHeight = own
2913
+ // "Turn max" pins the box to the tallest block of the turn, so
2914
+ // switching between a short answer and a long thought does not resize
2915
+ // the frame under the user's hands. The measurement is cached: doing
2916
+ // it for every block on every scroll frame is what made the editor
2917
+ // drag while the conversation moved.
2918
+ if (heightMode !== 'content' && Array.isArray(blocks)) {
2919
+ const key = `${Math.round(area.clientWidth || 0)}|${blocks.map((block) => `${block.seq ?? ''}:${block.index}:${typeof block.text === 'string' ? block.text.length : 0}`).join(',')}`
2920
+ if (maxHeightCache.key === key) {
2921
+ contentHeight = Math.max(contentHeight, maxHeightCache.value)
2922
+ } else {
2923
+ let tallest = 0
2924
+ for (const block of blocks) {
2925
+ if (block === null || block === undefined || typeof block.text !== 'string') continue
2926
+ const measured = measure(block.text)
2927
+ if (measured > tallest) tallest = measured
2928
+ }
2929
+ maxHeightCache = { key, value: tallest }
2930
+ if (tallest > contentHeight) contentHeight = tallest
2931
+ }
2932
+ }
2933
+ area.value = previousValue
2934
+ area.style.height = previousHeight
2935
+ if (typeof area.scrollTop === 'number' && area.scrollTop !== previousScroll) {
2936
+ area.scrollTop = previousScroll
2937
+ }
2938
+ } catch {}
2939
+ }
2940
+ // Every mode hugs the measured content; `coveredHeight` is only a
2941
+ // fallback for a host whose textarea cannot be measured.
2942
+ // While the editor is open its height FOLLOWS the draft: more lines grow
2943
+ // the box, deleting lines shrinks it - but never below the height it had
2944
+ // when it opened, and never past the composer below or the conversation
2945
+ // top above. The floor belongs to the whole edit (a page switch keeps it)
2946
+ // and only a save or a cancel ends it.
2947
+ const baseHeight =
2948
+ controller !== null &&
2949
+ controller !== undefined &&
2950
+ typeof controller.editorBaseHeight === 'number' &&
2951
+ controller.editorBaseHeight > 0
2952
+ ? controller.editorBaseHeight
2953
+ : null
2954
+ let height = Math.max(minimumHeight, contentHeight + headerOffset + FOOTER_BAND, baseHeight === null ? 0 : baseHeight)
2955
+ let clampedTop = null
2956
+ if (clamped) {
2957
+ const bounds = conversationBounds(row)
2958
+ const bottomLimit = (bounds.bottom > 0 ? bounds.bottom : viewport.height) - margin
2959
+ const topLimit = bounds.top + margin
2960
+ if (bottomLimit > topLimit) {
2961
+ // All-blocks mode shows one page of blocks behind ONE scrollbar: it
2962
+ // fills the WHOLE band (not just the message's own position), so the
2963
+ // top touches the conversation edge and the bottom the composer.
2964
+ const fillAll = allBox !== null && allBox !== undefined && heightMode !== 'content'
2965
+ const band = bottomLimit - topLimit
2966
+ if (fillAll) {
2967
+ clampedTop = topLimit
2968
+ height = Math.max(minimumHeight, band)
2969
+ } else if (height <= band) {
2970
+ // It fits: grow DOWN from the row while there is room for it, and
2971
+ // when there is not, keep the bottom at the composer edge and grow
2972
+ // UP instead - the direction follows the space, the box never covers
2973
+ // the composer and never leaves the conversation's top.
2974
+ const room = bottomLimit - Math.max(base.top, topLimit)
2975
+ clampedTop = height <= room ? Math.max(base.top, topLimit) : Math.max(topLimit, bottomLimit - height)
2976
+ height = Math.min(height, band)
2977
+ } else {
2978
+ // Taller than the whole band: fill it and let the field scroll.
2979
+ clampedTop = topLimit
2980
+ height = Math.max(minimumHeight, band)
2981
+ }
2982
+ }
2983
+ }
2984
+ if (!inline && !clamped && viewport.height > 0) height = Math.min(height, Math.max(minimumHeight, viewport.height - margin * 2))
2985
+ height = Math.max(minimumHeight, height)
2986
+ if (baseHeight === null && controller !== null && controller !== undefined) {
2987
+ // The first layout of this open fixes the floor: from here on the box
2988
+ // may grow and shrink with the draft, but not below this.
2989
+ controller.editorBaseHeight = height
2990
+ }
2991
+ box.style.height = `${height}px`
2992
+ box.style.maxHeight = ''
2993
+ let top = clampedTop === null ? base.top : clampedTop
2994
+ if (inline) {
2995
+ const pageY = typeof window !== 'undefined' && typeof window.scrollY === 'number' ? window.scrollY : 0
2996
+ top += pageY
2997
+ } else if (viewport.height > 0) {
2998
+ top = Math.max(margin, Math.min(top, viewport.height - height - margin))
2999
+ }
3000
+ box.style.top = `${Math.round(top)}px`
3001
+ if (allBox !== null && allBox !== undefined) {
3002
+ allBox.style.position = 'absolute'
3003
+ allBox.style.left = '8px'
3004
+ allBox.style.right = '8px'
3005
+ // The all-blocks page has its own sticky outline, so it does not need
3006
+ // the 38px control header the single-block editor reserves.
3007
+ allBox.style.top = '6px'
3008
+ allBox.style.bottom = `${FOOTER_BAND + 8}px`
3009
+ allBox.style.overflowY = 'auto'
3010
+ // The container is now laid out; re-measure each block so the document
3011
+ // is exactly as tall as its text and the ONE scrollbar appears when it
3012
+ // overflows the band.
3013
+ if (typeof allBox.querySelectorAll === 'function') {
3014
+ withScrollKept(allBox, () => {
3015
+ for (const blockArea of allBox.querySelectorAll('.dshet-all-area')) settleAreaHeight(blockArea)
3016
+ })
3017
+ }
3018
+ } else if (area !== null && area !== undefined) {
3019
+ const offsetLeft = Math.max(0, bubble.rect.left - base.left)
3020
+ // Reserve a header band for the controls; when the prompt carries image
3021
+ // chips they sit above the text, so the text starts below them too.
3022
+ const offsetTop = headerOffset
3023
+ // The control band is painted over the box's bottom edge, so the field
3024
+ // has to stop above it: the old `height - offsetTop - 8` let the field
3025
+ // run 40px under the footer, and the last lines of a long block were
3026
+ // covered for good (the field scrolled, the footer never moved).
3027
+ const footerHeight =
3028
+ footerEl !== null && footerEl !== undefined && typeof footerEl.offsetHeight === 'number' && footerEl.offsetHeight > 0
3029
+ ? footerEl.offsetHeight
3030
+ : FOOTER_BAND
3031
+ const available = Math.max(40, height - offsetTop - footerHeight)
3032
+ area.style.position = 'absolute'
3033
+ area.style.boxSizing = 'border-box'
3034
+ area.style.left = `${Math.round(offsetLeft)}px`
3035
+ area.style.top = `${Math.round(offsetTop)}px`
3036
+ area.style.width = `${Math.round(mode === 'reply' ? Math.max(80, width - offsetLeft - 8) : Math.max(80, Math.min(bubble.rect.width, width - offsetLeft)))}px`
3037
+ // Never let the textarea paint outside the box: when the measured
3038
+ // content is taller than the box (viewport-capped overlay), it scrolls
3039
+ // inside instead of spilling past the border.
3040
+ area.style.height = `${Math.round(Math.max(32, Math.min(contentHeight, available)))}px`
3041
+ area.style.maxHeight = 'none'
3042
+ area.style.padding = ''
3043
+ area.style.paddingBottom = '8px'
3044
+ }
3045
+ box.style.borderRadius = ''
3046
+ try {
3047
+ const skin = getComputedStyle(bubble.element)
3048
+ if (skin.borderRadius && skin.borderRadius !== '0px') box.style.borderRadius = skin.borderRadius
3049
+ } catch {
3050
+ /* a detached node keeps the stylesheet skin */
3051
+ }
3052
+ }
3053
+
3054
+ /**
3055
+ * Run `work` without losing the reader's place.
3056
+ *
3057
+ * Measuring a textarea means collapsing it (`height: auto`), which shrinks
3058
+ * the document; the browser then clamps the scroller's `scrollTop` to the
3059
+ * new maximum, and the position is gone even though the heights come back.
3060
+ * The host re-renders constantly (a streaming turn, a state refresh), so
3061
+ * this is what "scroll down and it jumps back up" was. The position is
3062
+ * captured before the work and put back after.
3063
+ *
3064
+ * @param scroller - the scrolling element that holds the textareas.
3065
+ * @param work - the measuring pass.
3066
+ * @returns whatever `work` returned.
3067
+ */
3068
+ function withScrollKept(scroller, work) {
3069
+ const keep =
3070
+ scroller !== null && scroller !== undefined && typeof scroller.scrollTop === 'number'
3071
+ ? scroller.scrollTop
3072
+ : null
3073
+ const result = work()
3074
+ if (keep !== null && scroller.scrollTop !== keep) scroller.scrollTop = keep
3075
+ return result
3076
+ }
3077
+
3078
+ /**
3079
+ * The content height of one textarea, SETTLED and applied.
3080
+ *
3081
+ * One measurement is not enough. With the height reset to `auto`, the first
3082
+ * read comes back exactly one line short - the wrap is resolved after the
3083
+ * style write - and the element is then pinned to that height with
3084
+ * `overflow:hidden`. In the all-blocks page that made the last line of a
3085
+ * long body unreachable: the surrounding scroller's height is computed from
3086
+ * the pinned value, so it cannot scroll to a line the textarea is hiding
3087
+ * (reported from the field: a long 正文 could not be scrolled past).
3088
+ *
3089
+ * Measured live: first read 2392, second read 2415, line height 23px.
3090
+ *
3091
+ * @param area - the textarea to measure and size.
3092
+ * @returns the applied height in CSS pixels, or 0 when unmeasurable.
3093
+ */
3094
+ function settleAreaHeight(area) {
3095
+ if (area === null || area === undefined || typeof area.scrollHeight !== 'number') return 0
3096
+ area.style.height = 'auto'
3097
+ const first = area.scrollHeight
3098
+ if (!(typeof first === 'number' && first > 0)) return 0
3099
+ area.style.height = `${first}px`
3100
+ const settled = area.scrollHeight
3101
+ const height = typeof settled === 'number' && settled > first ? settled : first
3102
+ area.style.height = `${height}px`
3103
+ return height
3104
+ }
3105
+
3106
+ /**
3107
+ * One block's identity inside the editor's draft store.
3108
+ *
3109
+ * A turn's blocks are addressed by (seq, index): the same block reached from
3110
+ * the 正文 page and from its sub-page inside 全部 must resolve to the SAME
3111
+ * key, or an unsaved edit disappears when the user switches pages.
3112
+ *
3113
+ * @param block - an editor block (seq, index).
3114
+ * @returns the stable key.
3115
+ */
3116
+ function blockDraftKey(block) {
3117
+ if (block === null || block === undefined) return ''
3118
+ return `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
1802
3119
  }
1803
3120
 
1804
3121
  // Grow the textarea to its content, the way a normal input box behaves.
@@ -1806,10 +3123,16 @@ window.__ModuleLoader__.load({
1806
3123
  // in half an empty box. The stylesheet owns the limits (`min-height`,
1807
3124
  // `max-height`); the element only ever gets the measured height, and the
1808
3125
  // `+2` is the top and bottom border `scrollHeight` does not count.
3126
+ let maxHeightCache = { key: '', value: 0 }
3127
+
1809
3128
  function fitEditorHeight(area) {
1810
- area.style.height = 'auto'
1811
- const height = area.scrollHeight
1812
- if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
3129
+ if (typeof area.closest === 'function' && area.closest('[data-dshet-inplace="1"]') !== null) return
3130
+ const scroller =
3131
+ typeof area.closest === 'function'
3132
+ ? area.closest('.dshet-all') ?? area.closest('.dshet-editor')
3133
+ : null
3134
+ const height = withScrollKept(scroller, () => settleAreaHeight(area))
3135
+ if (height > 0) area.style.height = `${height + 2}px`
1813
3136
  }
1814
3137
 
1815
3138
  // Wire an action so a host re-render cannot eat the click.
@@ -1828,7 +3151,7 @@ window.__ModuleLoader__.load({
1828
3151
  pressed = true
1829
3152
  event.preventDefault()
1830
3153
  event.stopPropagation()
1831
- action()
3154
+ action(event)
1832
3155
  }
1833
3156
  button.onclick = (event) => {
1834
3157
  event.preventDefault()
@@ -1837,7 +3160,7 @@ window.__ModuleLoader__.load({
1837
3160
  pressed = false
1838
3161
  return
1839
3162
  }
1840
- action()
3163
+ action(event)
1841
3164
  }
1842
3165
  }
1843
3166
 
@@ -1956,6 +3279,12 @@ window.__ModuleLoader__.load({
1956
3279
  // saving would write, so both have to redraw the strip - and the chip
1957
3280
  // keys are enough to tell one list from another.
1958
3281
  view.parts.map((part) => part.key).join('.'),
3282
+ // The mode itself: leaving the all-blocks view must rebuild even when the
3283
+ // single block that comes back is the one that was selected before it.
3284
+ view.allBlocks === true ? 'all' : 'one',
3285
+ // The selected reply block: switching blocks has to rebuild the box.
3286
+ view.blockIndex === null || view.blockIndex === undefined ? '' : String(view.blockIndex),
3287
+ view.targetSeq === null || view.targetSeq === undefined ? '' : String(view.targetSeq),
1959
3288
  ].join('|')
1960
3289
  }
1961
3290
 
@@ -1964,9 +3293,10 @@ window.__ModuleLoader__.load({
1964
3293
  // from drifting apart in wording.
1965
3294
  function editorTexts(t, target) {
1966
3295
  const reply = target.mode === 'reply'
3296
+ const block = reply && target.patch === true
1967
3297
  return {
1968
3298
  title: t(reply ? 'editor.title-reply' : 'editor.title'),
1969
- placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
3299
+ placeholder: t(block ? 'editor.placeholder-block' : reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
1970
3300
  warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
1971
3301
  // Saving writes the revision into the context; only the opt-in config asks
1972
3302
  // the model again, and the button says which one will happen.
@@ -2030,6 +3360,16 @@ window.__ModuleLoader__.load({
2030
3360
  return strip
2031
3361
  }
2032
3362
 
3363
+ /** The FileList of a paste/drop payload, without assuming the event shape. */
3364
+ function filesFromData(data) {
3365
+ if (data === null || data === undefined) return []
3366
+ try {
3367
+ return data.files === null || data.files === undefined ? [] : Array.prototype.slice.call(data.files)
3368
+ } catch {
3369
+ return []
3370
+ }
3371
+ }
3372
+
2033
3373
  /**
2034
3374
  * The add/replace entry: a picker that puts a block on the list.
2035
3375
  *
@@ -2058,7 +3398,10 @@ window.__ModuleLoader__.load({
2058
3398
  const add = document.createElement('button')
2059
3399
  add.type = 'button'
2060
3400
  add.className = 'dshet-attach'
2061
- add.textContent = t('editor.attach.add')
3401
+ 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>'
3402
+ add.setAttribute('aria-haspopup', 'listbox')
3403
+ add.title = t('editor.attach.add')
3404
+ add.setAttribute('aria-label', t('editor.attach.add'))
2062
3405
  add.disabled = view.pending
2063
3406
  pressable(add, () => {
2064
3407
  if (typeof picker.click === 'function') picker.click()
@@ -2076,7 +3419,7 @@ window.__ModuleLoader__.load({
2076
3419
  ? controller.editorBox
2077
3420
  : null
2078
3421
  if (existing && existing.dataset.dshetFor === marker) {
2079
- placeEditor(row, existing)
3422
+ placeEditor(row, existing, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
2080
3423
  return
2081
3424
  }
2082
3425
 
@@ -2098,6 +3441,68 @@ window.__ModuleLoader__.load({
2098
3441
  box.className = 'dshet-editor'
2099
3442
  box.dataset[EDITOR_ATTR] = '1'
2100
3443
  box.dataset.dshetFor = marker
3444
+ // The frame is a child, not the box's own border: host skins mark the
3445
+ // editor chrome `!important` and win the cascade against the box itself,
3446
+ // but they cannot name this child. It draws the blue outline of the
3447
+ // reference UI on top of whatever border the skin puts on the box.
3448
+ const frame = document.createElement('span')
3449
+ frame.className = 'dshet-frame'
3450
+ frame.style.position = 'absolute'
3451
+ frame.style.inset = '-1px'
3452
+ frame.style.border = '2px solid #4a7dff'
3453
+ frame.style.borderRadius = 'inherit'
3454
+ frame.style.boxSizing = 'border-box'
3455
+ frame.style.pointerEvents = 'none'
3456
+ frame.style.zIndex = '3'
3457
+ box.appendChild(frame)
3458
+
3459
+ // A reply on the patch path may carry several editable blocks (answer
3460
+ // text and reasoning). The picker chooses which one this save targets;
3461
+ // every option carries the block's CURRENT projected text, so switching
3462
+ // never shows the recorded original over a landed patch.
3463
+ if (target.mode === 'reply' && target.patch === true && Array.isArray(target.blocks) && target.blocks.length >= 1) {
3464
+ const picker = document.createElement('label')
3465
+ picker.className = 'dshet-pick'
3466
+ const caption = document.createElement('span')
3467
+ caption.className = 'dshet-sr-only'
3468
+ caption.textContent = t('editor.block')
3469
+ const select = document.createElement('select')
3470
+ select.className = 'dshet-select'
3471
+ select.disabled = view.pending || view.confirming
3472
+ select.setAttribute('aria-label', t('editor.block'))
3473
+ // A single reply can carry several answer texts and several reasoning
3474
+ // blocks; number the labels so each one is addressable in the picker.
3475
+ const totals = {}
3476
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3477
+ const seen = {}
3478
+ for (const block of target.blocks) {
3479
+ const option = document.createElement('option')
3480
+ option.value = typeof block.seq === 'number' ? `${block.seq}:${block.index}` : String(block.index)
3481
+ const kind = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
3482
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3483
+ option.textContent = totals[block.kind] > 1 ? `${t(kind)} ${seen[block.kind]}` : t(kind)
3484
+ option.selected = block.index === view.blockIndex
3485
+ select.appendChild(option)
3486
+ }
3487
+ // "All blocks" is always offered for a multi-block turn, not only once
3488
+ // it is already active - otherwise there is no way to select it.
3489
+ if (target.blocks.length > 1) {
3490
+ const allOption = document.createElement('option')
3491
+ allOption.value = 'all'
3492
+ allOption.textContent = t('editor.block.all')
3493
+ select.appendChild(allOption)
3494
+ }
3495
+ if (view.allBlocks === true) {
3496
+ select.value = 'all'
3497
+ } else if (view.blockIndex !== null && view.blockIndex !== undefined) {
3498
+ const current = target.blocks.find((block) => block.index === view.blockIndex && (view.targetSeq === null || view.targetSeq === undefined || block.seq === undefined || block.seq === view.targetSeq))
3499
+ select.value = current !== undefined && typeof current.seq === 'number' ? `${current.seq}:${current.index}` : String(view.blockIndex)
3500
+ }
3501
+ select.addEventListener('change', () => controller.selectBlock(select.value))
3502
+ picker.appendChild(caption)
3503
+ picker.appendChild(select)
3504
+ box.appendChild(picker)
3505
+ }
2101
3506
 
2102
3507
  const area = document.createElement('textarea')
2103
3508
  area.rows = 1
@@ -2113,8 +3518,16 @@ window.__ModuleLoader__.load({
2113
3518
  // "eats" pinyin.
2114
3519
  if (event.isComposing === true) return
2115
3520
  fitEditorHeight(area)
3521
+ const row = controller.editorRow
3522
+ const box = controller.editorBox
3523
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
3524
+ })
3525
+ area.addEventListener('compositionend', () => {
3526
+ fitEditorHeight(area)
3527
+ const row = controller.editorRow
3528
+ const box = controller.editorBox
3529
+ if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
2116
3530
  })
2117
- area.addEventListener('compositionend', () => fitEditorHeight(area))
2118
3531
  area.addEventListener('focus', () => {
2119
3532
  controller.editorFocus = true
2120
3533
  })
@@ -2128,18 +3541,114 @@ window.__ModuleLoader__.load({
2128
3541
  if (event.isComposing === true || event.keyCode === 229) return
2129
3542
  if (event.key === 'Enter' && event.shiftKey !== true) {
2130
3543
  event.preventDefault()
2131
- if (view.confirming) controller.confirm()
3544
+ const live = liveView()
3545
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
2132
3546
  else controller.review()
2133
3547
  return
2134
3548
  }
2135
3549
  if (event.key === 'Escape') {
2136
3550
  event.preventDefault()
2137
- if (view.confirming) controller.back()
3551
+ const live = liveView()
3552
+ if (live !== null && live !== undefined && live.confirming === true) controller.back()
2138
3553
  else controller.close()
2139
3554
  }
2140
3555
  })
2141
3556
  box.appendChild(area)
2142
3557
 
3558
+ // "All blocks": every reasoning and text block of the turn on one page,
3559
+ // each in its own textarea under a light separator.
3560
+ if (view.allBlocks === true && Array.isArray(target.blocks)) {
3561
+ const allBox = document.createElement('div')
3562
+ allBox.className = 'dshet-all'
3563
+ // A sticky outline over the page: one chip per block that scrolls the
3564
+ // container to it, so the document keeps its single scrollbar and still
3565
+ // has navigation.
3566
+ const nav = document.createElement('div')
3567
+ nav.className = 'dshet-all-nav'
3568
+ const totals = {}
3569
+ for (const block of target.blocks) totals[block.kind] = (totals[block.kind] ?? 0) + 1
3570
+ const seen = {}
3571
+ for (const block of target.blocks) {
3572
+ const key = `${typeof block.seq === 'number' ? block.seq : ''}:${block.index}`
3573
+ const section = document.createElement('div')
3574
+ section.className = 'dshet-all-block'
3575
+ const head = document.createElement('div')
3576
+ head.className = 'dshet-all-head'
3577
+ const label = document.createElement('span')
3578
+ seen[block.kind] = (seen[block.kind] ?? 0) + 1
3579
+ const kindKey = block.kind === 'reasoning' ? 'editor.block.reasoning' : 'editor.block.text'
3580
+ const labelText = totals[block.kind] > 1 ? `${t(kindKey)} ${seen[block.kind]}` : t(kindKey)
3581
+ label.textContent = labelText
3582
+ const navButton = document.createElement('button')
3583
+ navButton.type = 'button'
3584
+ navButton.textContent = labelText
3585
+ navButton.addEventListener('click', () => {
3586
+ if (typeof section.scrollIntoView === 'function') section.scrollIntoView({ block: 'start' })
3587
+ else allBox.scrollTop = section.offsetTop ?? 0
3588
+ })
3589
+ nav.appendChild(navButton)
3590
+ const line = document.createElement('span')
3591
+ line.className = 'dshet-all-line'
3592
+ head.appendChild(label)
3593
+ head.appendChild(line)
3594
+ const blockArea = document.createElement('textarea')
3595
+ blockArea.className = 'dshet-all-area'
3596
+ blockArea.rows = 1
3597
+ blockArea.spellcheck = false
3598
+ blockArea.style.overflow = 'hidden'
3599
+ blockArea.style.position = 'static'
3600
+ blockArea.style.left = 'auto'
3601
+ blockArea.style.top = 'auto'
3602
+ blockArea.value = controller.draftOfBlock(block)
3603
+ blockArea.disabled = view.pending || view.confirming
3604
+ const grow = () =>
3605
+ withScrollKept(
3606
+ typeof blockArea.closest === 'function' ? blockArea.closest('.dshet-all') : null,
3607
+ () => settleAreaHeight(blockArea),
3608
+ )
3609
+ blockArea.addEventListener('input', () => {
3610
+ controller.setDraftFor(block, blockArea.value)
3611
+ grow()
3612
+ })
3613
+ section.appendChild(head)
3614
+ section.appendChild(blockArea)
3615
+ allBox.appendChild(section)
3616
+ // Only now is the textarea in the document, so `scrollHeight` is the
3617
+ // real content height; measuring before the append returned 0 and the
3618
+ // block stayed 32px tall with its text clipped.
3619
+ grow()
3620
+ }
3621
+ allBox.insertBefore(nav, allBox.firstChild)
3622
+ box.appendChild(allBox)
3623
+ area.style.display = 'none'
3624
+ }
3625
+
3626
+ // Input parity with the composer: a screenshot pasted from the clipboard
3627
+ // and files dropped onto the box join the same chip list as the picker.
3628
+ // Reply edits cannot carry blocks, so the handlers exist only for prompts
3629
+ // whose host proved it can keep them.
3630
+ if (target.mode === 'prompt' && view.attachmentsOk === true) {
3631
+ area.addEventListener('paste', (event) => {
3632
+ const files = filesFromData(event.clipboardData)
3633
+ if (files.length === 0) return
3634
+ event.preventDefault()
3635
+ controller.attachFiles(files)
3636
+ })
3637
+ box.addEventListener('dragover', (event) => {
3638
+ event.preventDefault()
3639
+ box.dataset.dshetDrop = '1'
3640
+ })
3641
+ box.addEventListener('dragleave', () => {
3642
+ delete box.dataset.dshetDrop
3643
+ })
3644
+ box.addEventListener('drop', (event) => {
3645
+ event.preventDefault()
3646
+ delete box.dataset.dshetDrop
3647
+ const files = filesFromData(event.dataTransfer)
3648
+ if (files.length > 0) controller.attachFiles(files)
3649
+ })
3650
+ }
3651
+
2143
3652
  // The blocks this message carries, each one a chip the user can remove,
2144
3653
  // and the entry that adds another. Drawn only when the host answered that
2145
3654
  // it can admit what a chip promises - otherwise the warning below states
@@ -2158,33 +3667,61 @@ window.__ModuleLoader__.load({
2158
3667
  if (view.confirming) {
2159
3668
  const note = document.createElement('p')
2160
3669
  note.className = 'dshet-note'
2161
- note.textContent = t('confirm.body')
3670
+ note.textContent = t(target.mode === 'reply' && target.patch === true ? 'confirm.body-block' : 'confirm.body')
2162
3671
  box.appendChild(note)
2163
3672
  }
2164
3673
  // Shown when saving really would drop something: a reply's tool calls
2165
3674
  // always, and a prompt's blocks when this host cannot carry them.
2166
- if (target.attachments > 0 && !chips) {
3675
+ if (target.attachments > 0 && !chips && !(target.mode === 'reply' && target.patch === true)) {
2167
3676
  const warn = document.createElement('p')
2168
3677
  warn.className = 'dshet-warn'
2169
3678
  warn.textContent = texts.warn
2170
3679
  box.appendChild(warn)
2171
3680
  }
2172
- if (typeof view.failure === 'string' && view.failure !== '') {
3681
+ // A running turn only blocks the paths that move the surface (a prompt
3682
+ // rewrite, or a reply edit on a host without the in-place record). The
3683
+ // in-place record appends one event and changes nothing that is already
3684
+ // on the surface, so waiting for the agent to finish is not required -
3685
+ // the model picks the edit up on its next fold.
3686
+ const inPlaceEdit = target.mode === 'reply' && target.patch === true
3687
+ const busyBlocked = view.busy === true && !inPlaceEdit
3688
+ const failureCode = typeof view.failure === 'string' && view.failure !== ''
3689
+ ? view.failure
3690
+ : busyBlocked
3691
+ ? 'busy'
3692
+ : null
3693
+ if (failureCode !== null) {
2173
3694
  const error = document.createElement('p')
2174
3695
  error.className = 'dshet-error'
2175
3696
  error.setAttribute('role', 'status')
2176
- error.textContent = errorText(t, view.failure)
3697
+ const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
3698
+ error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
3699
+ ? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3700
+ : errorText(t, failureCode)
2177
3701
  box.appendChild(error)
2178
3702
  }
2179
3703
 
3704
+ // Handlers must read the CURRENT controller state: the host may not have
3705
+ // rebuilt the button after the first press, and a stale `view.confirming`
3706
+ // made the confirmation step a dead end (press again -> review again).
3707
+ const liveView = () => (typeof controller.getSnapshot === 'function' ? controller.getSnapshot() : view)
3708
+
2180
3709
  const footer = document.createElement('div')
2181
3710
  footer.className = 'dshet-footer'
3711
+ const footerActions = document.createElement('div')
3712
+ footerActions.className = 'dshet-footer-actions'
2182
3713
 
2183
3714
  const cancel = document.createElement('button')
2184
3715
  cancel.type = 'button'
2185
- cancel.className = 'dshet-btn'
2186
- cancel.textContent = t('editor.cancel')
2187
- cancel.title = `${t('editor.cancel')} (Esc)`
3716
+ cancel.className = 'dshet-btn dshet-btn-icon dshet-btn-cancel'
3717
+ const cancelLabel = t('editor.cancel')
3718
+ 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>'
3719
+ const cancelText = document.createElement('span')
3720
+ cancelText.className = 'dshet-sr-only'
3721
+ cancelText.textContent = cancelLabel
3722
+ cancel.appendChild(cancelText)
3723
+ cancel.title = `${cancelLabel} (Esc)`
3724
+ cancel.setAttribute('aria-label', cancelLabel)
2188
3725
  cancel.disabled = view.pending
2189
3726
  pressable(cancel, () => {
2190
3727
  if (view.confirming) controller.back()
@@ -2193,20 +3730,35 @@ window.__ModuleLoader__.load({
2193
3730
 
2194
3731
  const submit = document.createElement('button')
2195
3732
  submit.type = 'button'
2196
- submit.className = 'dshet-btn dshet-btn-primary'
2197
- submit.textContent = view.pending
3733
+ submit.className = 'dshet-btn dshet-btn-primary dshet-btn-save'
3734
+ const submitLabel = view.pending
2198
3735
  ? t('editor.pending')
2199
3736
  : view.confirming
2200
3737
  ? t('editor.review')
2201
3738
  : texts.save
2202
- submit.title = `${submit.textContent} (Enter)`
2203
- submit.disabled = view.pending
3739
+ // The official composer uses a bare double-check for confirm, not a black
3740
+ // pill. The label stays in the accessible name (and in the test-visible
3741
+ // textContent) through a visually hidden span.
3742
+ 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>'
3743
+ const submitText = document.createElement('span')
3744
+ submitText.className = 'dshet-sr-only'
3745
+ submitText.textContent = submitLabel
3746
+ submit.appendChild(submitText)
3747
+ submit.title = `${submitLabel} (Enter)`
3748
+ submit.setAttribute('aria-label', submitLabel)
3749
+ submit.disabled = view.pending || busyBlocked
2204
3750
  pressable(submit, () => {
2205
- if (view.confirming) controller.confirm()
3751
+ const live = liveView()
3752
+ if (live !== null && live !== undefined && live.confirming === true) controller.confirm()
2206
3753
  else controller.review()
2207
3754
  })
3755
+ if (view.confirming === true) {
3756
+ // The save label is visually hidden, so the confirmation step needs a
3757
+ // visible state of its own or the first press looks like nothing.
3758
+ submit.classList.add('dshet-btn-confirm')
3759
+ }
2208
3760
 
2209
- footer.appendChild(cancel)
3761
+ footerActions.appendChild(cancel)
2210
3762
  // The re-run button belongs to the sibling (dsh-rerun-turn): it appears
2211
3763
  // only when that plugin is mounted, only for a prompt edit, and only
2212
3764
  // outside the confirmation step - the intent is carried through it. Save
@@ -2215,19 +3767,48 @@ window.__ModuleLoader__.load({
2215
3767
  if (target.mode === 'prompt' && view.siblingRerun === 'present' && view.confirming !== true) {
2216
3768
  const rerun = document.createElement('button')
2217
3769
  rerun.type = 'button'
2218
- rerun.className = 'dshet-btn dshet-rerun'
2219
- rerun.textContent = t('editor.rerun')
3770
+ rerun.className = 'dshet-btn dshet-btn-icon dshet-rerun'
3771
+ const rerunLabel = t('editor.rerun')
3772
+ 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>'
3773
+ const rerunText = document.createElement('span')
3774
+ rerunText.className = 'dshet-sr-only'
3775
+ rerunText.textContent = rerunLabel
3776
+ rerun.appendChild(rerunText)
3777
+ rerun.title = rerunLabel
3778
+ rerun.setAttribute('aria-label', rerunLabel)
2220
3779
  rerun.disabled = view.pending
2221
3780
  pressable(rerun, () => controller.reviewRerun())
2222
- footer.appendChild(rerun)
3781
+ footerActions.appendChild(rerun)
3782
+ }
3783
+ footerActions.appendChild(submit)
3784
+ footer.appendChild(footerActions)
3785
+ const attachRow = typeof box.querySelector === 'function' ? box.querySelector('.dshet-attach-row') : null
3786
+ if (attachRow !== null && attachRow !== undefined) {
3787
+ attachRow.style.position = 'static'
3788
+ attachRow.style.left = ''
3789
+ attachRow.style.bottom = ''
3790
+ attachRow.style.margin = '0'
3791
+ footer.insertBefore(attachRow, footer.firstChild)
3792
+ }
3793
+ // The block picker shares the control band and sits at its far right.
3794
+ const pickerEl = typeof box.querySelector === 'function' ? box.querySelector('.dshet-pick') : null
3795
+ if (pickerEl !== null && pickerEl !== undefined) {
3796
+ pickerEl.style.position = 'static'
3797
+ pickerEl.style.left = ''
3798
+ pickerEl.style.top = ''
3799
+ pickerEl.style.marginLeft = 'auto'
3800
+ pickerEl.style.marginRight = '0'
3801
+ pickerEl.style.display = 'flex'
3802
+ pickerEl.style.flex = '0 0 auto'
3803
+ pickerEl.style.visibility = ''
3804
+ footer.appendChild(pickerEl)
2223
3805
  }
2224
- footer.appendChild(submit)
2225
3806
  box.appendChild(footer)
2226
3807
 
2227
- placeEditor(row, box)
2228
3808
  controller.editorBox = box
2229
3809
  controller.editorRow = row
2230
3810
  controller.editorElement = area
3811
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
2231
3812
  fitEditorHeight(area)
2232
3813
  if (!view.confirming && !view.pending && (fresh || wasFocused)) {
2233
3814
  try {
@@ -2255,11 +3836,531 @@ window.__ModuleLoader__.load({
2255
3836
  controller.load(true)
2256
3837
  }
2257
3838
 
3839
+ // --- projected reply display ---------------------------------------------
3840
+ //
3841
+ // The host draws a reply from the recorded event, not from the projected
3842
+ // message, so a landed patch would be invisible in the transcript. The
3843
+ // projection is the model's copy; this is the reader's copy. Text blocks are
3844
+ // replaced in place inside the host's own markdown container (the row,
3845
+ // actions and tool cards stay untouched); a patched reasoning block gets a
3846
+ // small note under the reply, because the host renders reasoning as a
3847
+ // collapsed panel that may not be in the DOM at all.
3848
+
3849
+ const PATCH_ATTR = 'data-dshet-patch'
3850
+ const patchOriginals = new WeakMap()
3851
+ // The text this plugin last painted for a key, so a second edit of the same
3852
+ // block can still find the element after the first text replaced the
3853
+ // recorded original in the DOM.
3854
+ const patchedTexts = new Map()
3855
+ // React may re-render the assistant container and reset its attributes and
3856
+ // style while leaving the text node we replaced. Remember the element by
3857
+ // seq:block so the next pass can restore the marker and, on a later edit,
3858
+ // update it without having to re-find the original text (which is gone).
3859
+ const patchedElements = new Map()
3860
+
3861
+ function normalizedText(value) {
3862
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
3863
+ }
3864
+
3865
+ // Markdown syntax is not part of the rendered textContent. Strip the
3866
+ // structural characters before comparing, so a reply that uses **bold** or
3867
+ // a fenced block still matches its own recorded text.
3868
+ function plainText(value) {
3869
+ return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
3870
+ }
3871
+
3872
+ // Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
3873
+ // the spaces left by stripping the markers must not defeat the comparison.
3874
+ // Whitespace carries no block identity here, so remove it entirely.
3875
+ function matchText(value) {
3876
+ return plainText(value).replace(/\s+/g, '')
3877
+ }
3878
+
3879
+ // What the reader sees for a patched block. The model context keeps the
3880
+ // raw Markdown the editor wrote; the transcript should not show the markers
3881
+ // as literal text (`**1**`), which is what made a successful edit look like
3882
+ // an unrendered draft. This is deliberately minimal: bold, inline code and
3883
+ // headings are unwrapped; everything else stays as written.
3884
+ function displayText(value) {
3885
+ return String(value ?? '')
3886
+ .replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
3887
+ .replace(/`([^`]*)`/g, '$1')
3888
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
3889
+ .replace(/__([^_]+)__/g, '$1')
3890
+ .replace(/^#{1,6}\s+/gm, '')
3891
+ }
3892
+
3893
+ function isMarkdownContainer(el) {
3894
+ if (el === null || el === undefined || !el.children) return false
3895
+ const kids = el.children
3896
+ if (kids.length === 0) return false
3897
+ let blocks = 0
3898
+ for (const kid of kids) {
3899
+ try {
3900
+ if (kid.matches && kid.matches('p, pre, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table')) blocks += 1
3901
+ } catch {
3902
+ return false
3903
+ }
3904
+ }
3905
+ return blocks >= 1 && blocks * 2 >= kids.length
3906
+ }
3907
+
3908
+ function conversationRootForPatches() {
3909
+ const root = document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
3910
+ return root ?? document.body
3911
+ }
3912
+
3913
+ function restorePatchedBlock(el) {
3914
+ const saved = patchOriginals.get(el)
3915
+ if (saved !== null && saved !== undefined) {
3916
+ try {
3917
+ if (typeof saved.html === 'string' && saved.html !== '') el.innerHTML = saved.html
3918
+ else if (typeof saved.text === 'string') el.textContent = saved.text
3919
+ } catch {
3920
+ /* a detached row is not worth a throw */
3921
+ }
3922
+ }
3923
+ el.removeAttribute(PATCH_ATTR)
3924
+ el.style.whiteSpace = ''
3925
+ }
3926
+
3927
+ // A short reply can be a bare text node (or a span) rather than the
3928
+ // block-level markdown container the picker looks for. Match the deepest
3929
+ // element in the owning row whose whole text is exactly the recorded text;
3930
+ // never a button, a timestamp, our own chrome, or an ancestor that merely
3931
+ // contains the text.
3932
+ function findPatchTextFallback(owner, wanted, previousText = '') {
3933
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
3934
+ const original = matchText(wanted.original)
3935
+ const previous = matchText(previousText)
3936
+ if (original === '' && previous === '') return null
3937
+ let scope = owner
3938
+ try {
3939
+ const node = owner.querySelector('[data-slot="conversation.chat.node"]')
3940
+ if (node !== null && node !== undefined) scope = node
3941
+ } catch {
3942
+ /* keep the row as the scope */
3943
+ }
3944
+ for (const el of scope.querySelectorAll('*')) {
3945
+ if (el === scope) continue
3946
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
3947
+ try {
3948
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
3949
+ } catch {
3950
+ continue
3951
+ }
3952
+ const text = matchText(el.textContent)
3953
+ if (text !== original && !(previous !== '' && text === previous)) continue
3954
+ let deeper = false
3955
+ for (const child of el.children ?? []) {
3956
+ if (matchText(child.textContent) === text) {
3957
+ deeper = true
3958
+ break
3959
+ }
3960
+ }
3961
+ if (!deeper) return el
3962
+ }
3963
+ return null
3964
+ }
3965
+
3966
+ /**
3967
+ * The collapsed reasoning header draws its own truncation of the RECORDED
3968
+ * text ("思考 · ..."), and when the panel is closed the body is not in the
3969
+ * DOM at all - so a patch of the reasoning body alone leaves the edit
3970
+ * invisible. Rewrite that snippet too, keeping whatever the host appended
3971
+ * (its ellipsis) and its own truncation length.
3972
+ */
3973
+ function patchReasoningSummary(owner, wanted) {
3974
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return false
3975
+ const original = String(wanted.original ?? '')
3976
+ const patched = String(wanted.text ?? '')
3977
+ if (original === '' || patched === '') return false
3978
+ const head = original.slice(0, 12)
3979
+ let changed = false
3980
+ const apply = (get, set) => {
3981
+ const data = String(get() ?? '')
3982
+ if (data === '' || !data.includes(head)) return
3983
+ // A node holding the whole text gets the whole replacement; a truncated
3984
+ // snippet keeps the host's length and gains an ellipsis when the new
3985
+ // text is longer.
3986
+ const next = data.length >= original.length ? patched : `${patched.slice(0, data.length)}${patched.length > data.length ? '…' : ''}`
3987
+ if (next === data) return
3988
+ set(next)
3989
+ changed = true
3990
+ }
3991
+ const walk = (node) => {
3992
+ for (const child of node.childNodes ?? []) {
3993
+ const type = child.nodeType
3994
+ if (type === 3) {
3995
+ apply(() => child.data, (value) => { child.data = value })
3996
+ continue
3997
+ }
3998
+ if (type !== 1) continue
3999
+ try {
4000
+ if (typeof child.closest === 'function' && child.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4001
+ } catch {
4002
+ /* a node without closest is walked anyway */
4003
+ }
4004
+ const kids = child.childNodes ?? []
4005
+ if (kids.length === 0) {
4006
+ apply(() => child.textContent, (value) => { child.textContent = value })
4007
+ continue
4008
+ }
4009
+ walk(child)
4010
+ }
4011
+ }
4012
+ walk(owner)
4013
+ return changed
4014
+ }
4015
+
4016
+ /**
4017
+ * The reasoning body on this host is its own row (the flow key carries a
4018
+ * `,reasoning` group part), so neither the answer row nor the first chat node
4019
+ * the shared fallback picks contains it. Search the whole conversation for
4020
+ * the deepest element whose text is the recorded original (or what we last
4021
+ * painted there).
4022
+ */
4023
+ /**
4024
+ * EVERY element that shows this reasoning text.
4025
+ *
4026
+ * Plural on purpose: the host keeps more than one copy of a turn's reasoning.
4027
+ * The collapsed process row stays in the DOM with `display: none`, and opening
4028
+ * the panel renders a second, visible body. Patching only the first one left
4029
+ * the copy the user was looking at showing the recorded text.
4030
+ *
4031
+ * @param root - the conversation root to search.
4032
+ * @param wanted - the patch (`original` text, new `text`).
4033
+ * @param previousText - what the patch showed before this pass, if anything.
4034
+ * @returns the deepest matching elements, best match first.
4035
+ */
4036
+ function findReasoningElements(root, wanted, previousText) {
4037
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return []
4038
+ const original = matchText(wanted.original)
4039
+ const previous = matchText(previousText)
4040
+ if (original === '' && previous === '') return []
4041
+ const found = []
4042
+ for (const el of root.querySelectorAll('*')) {
4043
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4044
+ try {
4045
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4046
+ } catch {
4047
+ /* a detached node is still a candidate */
4048
+ }
4049
+ const text = matchText(el.textContent)
4050
+ if (text === '') continue
4051
+ if (text !== original && !(previous !== '' && text === previous)) continue
4052
+ let deeper = false
4053
+ for (const child of el.children ?? []) {
4054
+ if (matchText(child.textContent) === text) {
4055
+ deeper = true
4056
+ break
4057
+ }
4058
+ }
4059
+ if (deeper) continue
4060
+ found.push({ el, score: Math.abs(text.length - original.length) })
4061
+ }
4062
+ found.sort((a, b) => a.score - b.score)
4063
+ return found.map((entry) => entry.el)
4064
+ }
4065
+
4066
+ function findReasoningElement(root, wanted, previousText) {
4067
+ if (root === null || root === undefined || typeof root.querySelectorAll !== 'function') return null
4068
+ const original = matchText(wanted.original)
4069
+ const previous = matchText(previousText)
4070
+ if (original === '' && previous === '') return null
4071
+ let best = null
4072
+ let bestScore = Infinity
4073
+ for (const el of root.querySelectorAll('*')) {
4074
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4075
+ try {
4076
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, button, time') !== null) continue
4077
+ } catch {
4078
+ /* a detached node is still a candidate */
4079
+ }
4080
+ const text = matchText(el.textContent)
4081
+ if (text === '') continue
4082
+ if (text !== original && !(previous !== '' && text === previous)) continue
4083
+ let deeper = false
4084
+ for (const child of el.children ?? []) {
4085
+ if (matchText(child.textContent) === text) {
4086
+ deeper = true
4087
+ break
4088
+ }
4089
+ }
4090
+ if (deeper) continue
4091
+ const score = Math.abs(text.length - original.length)
4092
+ if (score < bestScore) {
4093
+ best = el
4094
+ bestScore = score
4095
+ }
4096
+ }
4097
+ return best
4098
+ }
4099
+
4100
+ function applyPatchDisplay(snapshot, view, t) {
4101
+ // Map a reply seq to the host row that draws it, so a short answer like
4102
+ // "好的" cannot be matched in a different row. Fall back to a global
4103
+ // search when the row is not mounted (virtualised histories).
4104
+ const rowBySeq = new Map()
4105
+ // A turn-process row also covers the turn's assistant seq (seqsFor reads
4106
+ // its anchor), and it comes before the assistant row in document order.
4107
+ // First-win mapping therefore pointed the patch at the process row, whose
4108
+ // subtree has no message body: zero matches. Prefer the row that actually
4109
+ // draws the message; the process/tail rows are only a last resort.
4110
+ const rowRank = new Map()
4111
+ try {
4112
+ for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
4113
+ const key = row.getAttribute('data-chat-flow-key')
4114
+ if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
4115
+ const node = snapshot.nodes.get(key)
4116
+ if (!node) continue
4117
+ const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
4118
+ for (const seq of seqsFor(node)) {
4119
+ const previous = rowRank.get(seq)
4120
+ if (previous === undefined || rank < previous) {
4121
+ rowBySeq.set(seq, row)
4122
+ rowRank.set(seq, rank)
4123
+ }
4124
+ }
4125
+ }
4126
+ } catch {
4127
+ /* a host shape this pass cannot read falls back below */
4128
+ }
4129
+ const root = conversationRootForPatches()
4130
+ if (root === null) return
4131
+ const replies = view.replies instanceof Map ? view.replies : new Map()
4132
+ const wantedText = new Map()
4133
+ const wantedReasoning = new Map()
4134
+ for (const patch of Array.isArray(view.patches) ? view.patches : []) {
4135
+ // Find the block by (seq, index) AND the entry that carries it.
4136
+ //
4137
+ // One reply entry carries its whole TURN's blocks, so several blocks
4138
+ // share an index - the first step's reasoning and the fourth step's
4139
+ // reasoning are both "index 0" - and a later step's block lives in the
4140
+ // entry keyed by the ANSWER's seq, not by its own. Keying the lookup by
4141
+ // `patch.seq` alone found no entry, and `find(index === ...)` alone found
4142
+ // the wrong block: either way the patch matched nothing in the DOM and
4143
+ // the reasoning body kept the recorded text until a reload (reported from
4144
+ // the field for the 思维链 page; a save on another step's reasoning wrote
4145
+ // the record and /state reported it, yet the transcript did not move).
4146
+ let reply = replies.get(patch.seq)
4147
+ let block = Array.isArray(reply?.blocks)
4148
+ ? reply.blocks.find(
4149
+ (entry) =>
4150
+ entry.index === patch.blockIndex &&
4151
+ (entry.seq === undefined || patch.seq === undefined || entry.seq === patch.seq),
4152
+ )
4153
+ : undefined
4154
+ if (block === undefined) {
4155
+ for (const candidate of replies.values()) {
4156
+ if (!Array.isArray(candidate?.blocks)) continue
4157
+ const found = candidate.blocks.find(
4158
+ (entry) => entry.index === patch.blockIndex && entry.seq === patch.seq,
4159
+ )
4160
+ if (found !== undefined) {
4161
+ reply = candidate
4162
+ block = found
4163
+ break
4164
+ }
4165
+ }
4166
+ }
4167
+ if (block === undefined) continue
4168
+ if (patch.kind === 'text') {
4169
+ wantedText.set(`${patch.seq}:${patch.blockIndex}`, {
4170
+ key: `${patch.seq}:${patch.blockIndex}`,
4171
+ seq: patch.seq,
4172
+ original: String(block.original ?? ''),
4173
+ text: String(patch.text ?? ''),
4174
+ })
4175
+ } else if (patch.kind === 'reasoning') {
4176
+ wantedReasoning.set(`${patch.seq}:${patch.blockIndex}`, {
4177
+ key: `${patch.seq}:${patch.blockIndex}`,
4178
+ seq: patch.seq,
4179
+ original: String(block.original ?? ''),
4180
+ text: String(patch.text ?? ''),
4181
+ reply,
4182
+ block,
4183
+ })
4184
+ }
4185
+ }
4186
+
4187
+ // A patch that no longer exists must give the host's recorded text back,
4188
+ // even when a re-render already stripped our marker from the element.
4189
+ for (const [key, el] of [...patchedElements]) {
4190
+ if (wantedText.has(key) || wantedReasoning.has(key)) continue
4191
+ patchedElements.delete(key)
4192
+ if (el.isConnected === true) restorePatchedBlock(el)
4193
+ }
4194
+ // Remove overrides whose patch is gone (or was superseded by a prompt
4195
+ // rollback). The original HTML is what the host rendered.
4196
+ for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
4197
+ const key = el.getAttribute(PATCH_ATTR)
4198
+ const wanted = wantedText.get(key)
4199
+ if (wanted === undefined) {
4200
+ restorePatchedBlock(el)
4201
+ continue
4202
+ }
4203
+ const shown = displayText(wanted.text)
4204
+ if (normalizedText(el.textContent) !== normalizedText(shown)) {
4205
+ el.textContent = shown
4206
+ }
4207
+ }
4208
+ if (wantedText.size === 0 && wantedReasoning.size === 0) return
4209
+
4210
+ const nodes = [...root.querySelectorAll('[data-slot="conversation.chat.node"]')]
4211
+ const containers = []
4212
+ for (const node of nodes) {
4213
+ for (const el of node.querySelectorAll('*')) {
4214
+ try {
4215
+ if (isMarkdownContainer(el) && el.closest('.dshet-editor, .dshet-pick') === null) containers.push(el)
4216
+ } catch {
4217
+ /* a detached node is skipped */
4218
+ }
4219
+ }
4220
+ }
4221
+ for (const [key, wanted] of wantedText) {
4222
+ const owner = rowBySeq.get(wanted.seq)
4223
+ const remembered = patchedElements.get(key)
4224
+ if (remembered !== undefined) {
4225
+ const stillThere = remembered.isConnected === true &&
4226
+ (owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
4227
+ if (!stillThere) {
4228
+ patchedElements.delete(key)
4229
+ } else {
4230
+ const shown = displayText(wanted.text)
4231
+ if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
4232
+ remembered.textContent = shown
4233
+ }
4234
+ if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
4235
+ remembered.style.whiteSpace = 'pre-wrap'
4236
+ continue
4237
+ }
4238
+ }
4239
+ const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
4240
+ if (done !== undefined) {
4241
+ patchedElements.set(key, done)
4242
+ if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
4243
+ continue
4244
+ }
4245
+ const original = matchText(wanted.original)
4246
+ const previous = matchText(patchedTexts.get(key) ?? '')
4247
+ if (original === '' && previous === '') continue
4248
+ let best = null
4249
+ let bestScore = Infinity
4250
+ for (const el of containers) {
4251
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4252
+ if (owner !== undefined) {
4253
+ try {
4254
+ if (el.closest('[data-chat-flow-key]') !== owner) continue
4255
+ } catch {
4256
+ /* no closest on this node: keep the global match */
4257
+ }
4258
+ }
4259
+ const text = matchText(el.textContent)
4260
+ if (text === '') continue
4261
+ const exact = text === original || (previous !== '' && text === previous)
4262
+ const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
4263
+ if (!exact && !prefix) continue
4264
+ // Prefer the exact match, then the container closest in length: a
4265
+ // nested paragraph and the whole markdown body can both match a
4266
+ // prefix, and the paragraph is the one to rewrite.
4267
+ const score = (exact ? 0 : 1000) + Math.abs(text.length - original.length)
4268
+ if (score < bestScore) {
4269
+ best = el
4270
+ bestScore = score
4271
+ }
4272
+ }
4273
+ if (best === null) best = findPatchTextFallback(owner, wanted, patchedTexts.get(key) ?? '')
4274
+ if (best === null) continue
4275
+ patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
4276
+ best.textContent = displayText(wanted.text)
4277
+ best.setAttribute(PATCH_ATTR, key)
4278
+ best.style.whiteSpace = 'pre-wrap'
4279
+ patchedElements.set(key, best)
4280
+ patchedTexts.set(key, wanted.text)
4281
+ }
4282
+
4283
+ // A reasoning patch is drawn in place when the host renders that
4284
+ // reasoning body; the note is only the fallback for a panel that is not
4285
+ // in the DOM (or is drawn as a short summary without the full text).
4286
+ for (const [key, wanted] of wantedReasoning) {
4287
+ const owner = rowBySeq.get(wanted.seq)
4288
+ const shown = displayText(wanted.text)
4289
+ const previousText = patchedTexts.get(key) ?? ''
4290
+ const original = matchText(wanted.original)
4291
+ const previous = matchText(previousText)
4292
+ // EVERY copy of this reasoning, not just the one this pass remembers.
4293
+ //
4294
+ // The host keeps the collapsed process row in the DOM with `display:
4295
+ // none` and renders a SECOND body when the panel is opened. Trusting the
4296
+ // remembered element because it is still `isConnected` meant the copy the
4297
+ // user was looking at kept the recorded text until a reload - which is
4298
+ // exactly the reported "思维链页保存后不能马上渲染".
4299
+ const targets = new Set()
4300
+ const remembered = patchedElements.get(key)
4301
+ if (remembered !== undefined) {
4302
+ if (remembered.isConnected === true) targets.add(remembered)
4303
+ else patchedElements.delete(key)
4304
+ }
4305
+ for (const el of root.querySelectorAll(`[${PATCH_ATTR}]`)) {
4306
+ if (el.getAttribute(PATCH_ATTR) === key) targets.add(el)
4307
+ }
4308
+ if (original !== '' || previous !== '') {
4309
+ // The owning row first (a host that draws the reasoning inside the
4310
+ // answer row), then the whole conversation: on this host the reasoning
4311
+ // is its own row, so the answer row holds no reasoning text at all.
4312
+ // EVERY scope is merged, never just the first one that matched: the
4313
+ // point of this pass is to catch all copies at once.
4314
+ for (const scope of owner === undefined ? [null] : [owner, null]) {
4315
+ let foundInScope = false
4316
+ for (const el of containers) {
4317
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
4318
+ if (scope !== null) {
4319
+ try {
4320
+ if (el.closest('[data-chat-flow-key]') !== scope) continue
4321
+ } catch {
4322
+ /* keep the global match */
4323
+ }
4324
+ }
4325
+ const text = matchText(el.textContent)
4326
+ if (text === '') continue
4327
+ if (text !== original && !(previous !== '' && text === previous)) continue
4328
+ targets.add(el)
4329
+ foundInScope = true
4330
+ }
4331
+ if (foundInScope) break
4332
+ }
4333
+ const fallback = findPatchTextFallback(owner ?? null, wanted, previousText)
4334
+ if (fallback !== null) targets.add(fallback)
4335
+ for (const el of findReasoningElements(root, wanted, previousText)) targets.add(el)
4336
+ }
4337
+ if (targets.size === 0) {
4338
+ // No body in the DOM (the panel is closed): the header snippet is the
4339
+ // only place the edit can show.
4340
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
4341
+ patchReasoningSummary(root, wanted)
4342
+ continue
4343
+ }
4344
+ for (const el of targets) {
4345
+ if (!patchOriginals.has(el)) patchOriginals.set(el, { html: el.innerHTML, text: el.textContent })
4346
+ if (normalizedText(el.textContent) !== normalizedText(shown)) el.textContent = shown
4347
+ if (el.getAttribute(PATCH_ATTR) !== key) el.setAttribute(PATCH_ATTR, key)
4348
+ el.style.whiteSpace = 'pre-wrap'
4349
+ patchedElements.set(key, el)
4350
+ }
4351
+ patchedTexts.set(key, wanted.text)
4352
+ if (owner !== undefined) patchReasoningSummary(owner, wanted)
4353
+ patchReasoningSummary(root, wanted)
4354
+ }
4355
+ }
4356
+
2258
4357
  function applyDom(snapshot, view, controller, t) {
2259
4358
  if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
2260
4359
  const animate = controller.consumeAnimate()
2261
4360
  const rows = document.querySelectorAll('[data-chat-flow-key]')
2262
4361
  syncEditableSet(controller, rows.length)
4362
+ let editorCandidate = null
4363
+ let editorRank = -1
2263
4364
  for (const row of rows) {
2264
4365
  if (!(row instanceof HTMLElement)) continue
2265
4366
  const key = row.getAttribute('data-chat-flow-key')
@@ -2312,7 +4413,20 @@ window.__ModuleLoader__.load({
2312
4413
  (seqsFor(node).includes(editing.seq) ||
2313
4414
  seqsFor(node).some((seq) => headRevision(seq, view.revisions) === editing.seq))
2314
4415
  if (coversEditing && (!hidden || row.style.display !== 'none')) {
2315
- renderEditor(row, controller, view, t)
4416
+ const rank = editorRowRank(row, editing.mode)
4417
+ if (rank > editorRank) {
4418
+ editorRank = rank
4419
+ editorCandidate = row
4420
+ }
4421
+ }
4422
+ }
4423
+ if (view.editing !== null) {
4424
+ if (editorCandidate !== null) {
4425
+ renderEditor(editorCandidate, controller, view, t)
4426
+ } else {
4427
+ clearEditor()
4428
+ controller.editorBox = null
4429
+ controller.editorRow = null
2316
4430
  }
2317
4431
  }
2318
4432
  if (view.editing === null) {
@@ -2320,6 +4434,13 @@ window.__ModuleLoader__.load({
2320
4434
  controller.editorBox = null
2321
4435
  controller.editorRow = null
2322
4436
  }
4437
+ // Draw the projected reply text last: the host has just rebuilt the rows,
4438
+ // and an override applied before that would be wiped by the rebuild.
4439
+ try {
4440
+ applyPatchDisplay(snapshot, view, t)
4441
+ } catch (error) {
4442
+ console.error('[dsh-edit-turn] patch display failed:', error)
4443
+ }
2323
4444
  // Whatever of ours no row of this pass claimed is a ghost of an earlier
2324
4445
  // apply: the host rebuilt the row under it, or the bundle was reloaded
2325
4446
  // over it. Contract §5 - a button nobody can update must not pile up.
@@ -2383,16 +4504,18 @@ window.__ModuleLoader__.load({
2383
4504
  event.preventDefault()
2384
4505
  event.stopPropagation()
2385
4506
  controller.open({
4507
+ anchor: event.currentTarget,
2386
4508
  seq: entry.seq,
2387
4509
  mode: 'reply',
2388
4510
  turn: entry.turn,
2389
4511
  messageId: entry.messageId,
2390
4512
  text: typeof entry.text === 'string' ? entry.text : '',
2391
4513
  attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
2392
- // A reply edits text only on purpose: the tool calls and reasoning it
2393
- // carries stop being true the moment it is replaced, so they are not
2394
- // offered as chips (the editor warns about them instead).
2395
- blocks: [],
4514
+ // On the in-place path the reply keeps its tool calls and other
4515
+ // blocks, so the editor can offer each editable block (output text
4516
+ // and reasoning). On the replay path there is no block picker: the
4517
+ // whole reply is replaced.
4518
+ ...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
2396
4519
  })
2397
4520
  }
2398
4521
  // The config object is always an object: this runtime's `jsx` reads
@@ -2432,23 +4555,51 @@ window.__ModuleLoader__.load({
2432
4555
  && target.closest('.dshet-editor') !== null
2433
4556
  if (!inside) controller.editorFocus = false
2434
4557
  }
4558
+ let placementScheduled = false
4559
+ let lastPlacementKey = ''
2435
4560
  const keepPlaced = () => {
2436
- const row = controller.editorRow
2437
- const box = controller.editorBox
2438
- if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
4561
+ if (placementScheduled) return
4562
+ placementScheduled = true
4563
+ const run = () => {
4564
+ placementScheduled = false
4565
+ const row = controller.editorRow
4566
+ const box = controller.editorBox
4567
+ if (row === null || box === null || box.isConnected !== true) return
4568
+ const rect = row.getBoundingClientRect()
4569
+ const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : 0
4570
+ const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' ? window.innerHeight : 0
4571
+ 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' : ''}`
4572
+ if (key === lastPlacementKey) return
4573
+ lastPlacementKey = key
4574
+ placeEditor(row, box, controller.editorMode, controller.editorStyle, controller.editorBlockKind, controller.editorBlocks, controller)
4575
+ }
4576
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
4577
+ else window.setTimeout(run, 16)
4578
+ }
4579
+ const onResize = () => {
4580
+ // A window resize (or a fullscreen toggle / zoom) moves the composer
4581
+ // and the conversation edges; the cached band must not survive it.
4582
+ conversationBoundsCache = null
4583
+ keepPlaced()
2439
4584
  }
2440
4585
  document.addEventListener('pointerdown', onPointerDown, true)
2441
4586
  // Scroll events do not bubble; capture reaches the inner scroller too.
2442
4587
  document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
2443
- window.addEventListener('resize', keepPlaced)
4588
+ window.addEventListener('resize', onResize)
4589
+ let resizeObserver = null
4590
+ if (typeof ResizeObserver === 'function') {
4591
+ resizeObserver = new ResizeObserver(onResize)
4592
+ try { resizeObserver.observe(document.body) } catch {}
4593
+ }
2444
4594
  return () => {
2445
4595
  document.removeEventListener('pointerdown', onPointerDown, true)
2446
4596
  document.removeEventListener('scroll', keepPlaced, true)
2447
- window.removeEventListener('resize', keepPlaced)
4597
+ window.removeEventListener('resize', onResize)
4598
+ if (resizeObserver !== null) resizeObserver.disconnect()
2448
4599
  }
2449
4600
  }, [controller])
2450
4601
 
2451
- react.useEffect(() => {
4602
+ useBrowserLayoutEffect(() => {
2452
4603
  if (snapshot === undefined) return undefined
2453
4604
  let scheduled = false
2454
4605
  const run = () => {
@@ -2460,7 +4611,33 @@ window.__ModuleLoader__.load({
2460
4611
  applyDom(snapshot, live, controller, t)
2461
4612
  }
2462
4613
  run()
2463
- const observer = new MutationObserver(() => {
4614
+ const observer = new MutationObserver((records) => {
4615
+ // Our own editor writes attributes and text while the user types;
4616
+ // feeding those back into a full document pass is what made typing
4617
+ // and streaming compete for the same frames.
4618
+ let relevant = false
4619
+ let lostOurAction = false
4620
+ for (const record of records) {
4621
+ const target = record.target
4622
+ if (target !== null && target !== undefined && typeof target.closest === 'function' && target.closest('.dshet-layer') !== null) continue
4623
+ relevant = true
4624
+ if (record.removedNodes && record.removedNodes.length > 0) {
4625
+ for (const node of record.removedNodes) {
4626
+ if (node === null || node === undefined) continue
4627
+ if (typeof node.closest === 'function' && node.closest('.dshet-action') !== null) lostOurAction = true
4628
+ else if (typeof node.querySelector === 'function' && node.querySelector('.dshet-action') !== null) lostOurAction = true
4629
+ }
4630
+ }
4631
+ break
4632
+ }
4633
+ if (!relevant) return
4634
+ // The host rebuilt a row and our button went with it: put it back in
4635
+ // the same task instead of waiting a frame for the rAF pass.
4636
+ if (lostOurAction) {
4637
+ scheduled = false
4638
+ run()
4639
+ return
4640
+ }
2464
4641
  if (scheduled) return
2465
4642
  scheduled = true
2466
4643
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
@@ -2558,18 +4735,212 @@ window.__ModuleLoader__.load({
2558
4735
  row.style.paddingTop = ''
2559
4736
  row.style.paddingBottom = ''
2560
4737
  })
2561
- // 2. Every node this half created goes with it: the injected pencils, the
2562
- // bubbles standing in for rewritten prompts, the editor and the layer
2563
- // it is hosted on. React's own nodes are deliberately not in the
2564
- // selector - the host unmounts those, and pulling one out from under
2565
- // React makes it throw on the way out.
2566
- each(OWN_NODE_SELECTOR, (node) => node.remove())
2567
- editorLayer = null
4738
+ // 2. Every node this half created goes with it: the injected pencils, the
4739
+ // bubbles standing in for rewritten prompts, the editor and the layer
4740
+ // it is hosted on. React's own nodes are deliberately not in the
4741
+ // selector - the host unmounts those, and pulling one out from under
4742
+ // React makes it throw on the way out.
4743
+ each(OWN_NODE_SELECTOR, (node) => node.remove())
4744
+ editorLayer = null
4745
+ }
4746
+
4747
+ // --- editor-style preference ----------------------------------------------
4748
+ //
4749
+ // The style is a browser preference so the manager page and the editor read
4750
+ // one value with no host round-trip. The host config's editorStyle is the
4751
+ // default when nothing has been chosen here.
4752
+ const EDITOR_STYLE_KEY = 'dsh-edit-turn/editorStyle'
4753
+ const EDITOR_STYLES = ['frame', 'bubble', 'center']
4754
+
4755
+ function readStoredEditorStyle() {
4756
+ try {
4757
+ if (typeof localStorage === 'undefined') return null
4758
+ const value = localStorage.getItem(EDITOR_STYLE_KEY)
4759
+ return EDITOR_STYLES.includes(value) ? value : null
4760
+ } catch {
4761
+ return null
4762
+ }
4763
+ }
4764
+
4765
+ let editorStylePreference = readStoredEditorStyle() ?? 'frame'
4766
+ const EDITOR_PLACEMENT_KEY = 'dsh-edit-turn/editorPlacement'
4767
+ const EDITOR_PLACEMENTS = ['overlay', 'inline', 'legacy']
4768
+
4769
+ function readStoredEditorPlacement() {
4770
+ try {
4771
+ if (typeof localStorage === 'undefined') return null
4772
+ const value = localStorage.getItem(EDITOR_PLACEMENT_KEY)
4773
+ return EDITOR_PLACEMENTS.includes(value) ? value : null
4774
+ } catch {
4775
+ return null
4776
+ }
4777
+ }
4778
+
4779
+ let editorPlacementPreference = readStoredEditorPlacement() ?? 'overlay'
4780
+ const EDITOR_HEIGHT_KEY = 'dsh-edit-turn/editorHeight'
4781
+ const EDITOR_HEIGHTS = ['max', 'content', 'fill']
4782
+
4783
+ function readStoredEditorHeight() {
4784
+ try {
4785
+ if (typeof localStorage === 'undefined') return null
4786
+ const value = localStorage.getItem(EDITOR_HEIGHT_KEY)
4787
+ return EDITOR_HEIGHTS.includes(value) ? value : null
4788
+ } catch {
4789
+ return null
4790
+ }
4791
+ }
4792
+
4793
+ let editorHeightPreference = readStoredEditorHeight() ?? 'fill'
4794
+ const editorHeightListeners = new Set()
4795
+
4796
+ function subscribeEditorHeight(listener) {
4797
+ editorHeightListeners.add(listener)
4798
+ return () => editorHeightListeners.delete(listener)
4799
+ }
4800
+
4801
+ function setEditorHeightPreference(mode) {
4802
+ if (!EDITOR_HEIGHTS.includes(mode)) return
4803
+ editorHeightPreference = mode
4804
+ try {
4805
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_HEIGHT_KEY, mode)
4806
+ } catch {}
4807
+ for (const listener of [...editorHeightListeners]) {
4808
+ try { listener() } catch {}
4809
+ }
4810
+ }
4811
+
4812
+ const editorPlacementListeners = new Set()
4813
+
4814
+ function subscribeEditorPlacement(listener) {
4815
+ editorPlacementListeners.add(listener)
4816
+ return () => editorPlacementListeners.delete(listener)
4817
+ }
4818
+
4819
+ function setEditorPlacementPreference(placement) {
4820
+ if (!EDITOR_PLACEMENTS.includes(placement)) return
4821
+ editorPlacementPreference = placement
4822
+ try {
4823
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_PLACEMENT_KEY, placement)
4824
+ } catch {
4825
+ /* private mode keeps the in-memory choice */
4826
+ }
4827
+ for (const listener of [...editorPlacementListeners]) {
4828
+ try {
4829
+ listener()
4830
+ } catch {}
4831
+ }
4832
+ }
4833
+
4834
+ const editorStyleListeners = new Set()
4835
+
4836
+ function subscribeEditorStyle(listener) {
4837
+ editorStyleListeners.add(listener)
4838
+ return () => editorStyleListeners.delete(listener)
4839
+ }
4840
+
4841
+ function setEditorStylePreference(style) {
4842
+ if (!EDITOR_STYLES.includes(style)) return
4843
+ editorStylePreference = style
4844
+ try {
4845
+ if (typeof localStorage !== 'undefined') localStorage.setItem(EDITOR_STYLE_KEY, style)
4846
+ } catch {
4847
+ /* private mode keeps the in-memory choice */
4848
+ }
4849
+ for (const listener of [...editorStyleListeners]) {
4850
+ try {
4851
+ listener()
4852
+ } catch {}
4853
+ }
4854
+ }
4855
+
4856
+ /**
4857
+ * The component row's configure page (`plugins.row.config`), the same slot
4858
+ * dsh-delete-turn uses. The manager's summary asks for a one-liner; the page
4859
+ * itself renders the three-way picker.
4860
+ */
4861
+ function EditorStyleOption({ view, t }) {
4862
+ const tr = typeof t === 'function' ? t : (key) => key
4863
+ const [style, setStyle] = react.useState(editorStylePreference)
4864
+ const [placement, setPlacement] = react.useState(editorPlacementPreference)
4865
+ const [heightMode, setHeightMode] = react.useState(editorHeightPreference)
4866
+ react.useEffect(() => subscribeEditorStyle(() => setStyle(editorStylePreference)), [])
4867
+ react.useEffect(() => subscribeEditorPlacement(() => setPlacement(editorPlacementPreference)), [])
4868
+ react.useEffect(() => subscribeEditorHeight(() => setHeightMode(editorHeightPreference)), [])
4869
+ const summaryKey = style === 'bubble' ? 'config.summary.bubble' : style === 'center' ? 'config.summary.center' : 'config.summary.frame'
4870
+ 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')}`
4871
+ const hintKey = style === 'bubble' ? 'config.hint.bubble' : style === 'center' ? 'config.hint.center' : 'config.hint.frame'
4872
+ const placementHint = placement === 'inline' ? 'config.hint.inline' : placement === 'legacy' ? 'config.hint.legacy' : 'config.hint.overlay'
4873
+ const heightHint = heightMode === 'content' ? 'config.hint.height.content' : heightMode === 'fill' ? 'config.hint.height.fill' : 'config.hint.height.max'
4874
+ const choices = [
4875
+ ['frame', 'style.frame'],
4876
+ ['bubble', 'style.bubble'],
4877
+ ['center', 'style.center'],
4878
+ ]
4879
+ return jsxs('div', {
4880
+ className: 'dshet-config',
4881
+ children: [
4882
+ jsxs('label', {
4883
+ className: 'dshet-config-row',
4884
+ children: [
4885
+ jsx('span', { children: tr('config.style') }),
4886
+ jsx('select', {
4887
+ className: 'dshet-select',
4888
+ value: style,
4889
+ onChange: (event) => setEditorStylePreference(event.target.value),
4890
+ children: choices.map(([value, key]) => jsx('option', { value, children: tr(key) }, value)),
4891
+ }),
4892
+ ],
4893
+ }),
4894
+ jsx('p', { className: 'dshet-config-hint', children: tr(hintKey) }),
4895
+ jsxs('label', {
4896
+ className: 'dshet-config-row',
4897
+ children: [
4898
+ jsx('span', { children: tr('config.placement') }),
4899
+ jsx('select', {
4900
+ className: 'dshet-select',
4901
+ value: placement,
4902
+ onChange: (event) => setEditorPlacementPreference(event.target.value),
4903
+ children: [
4904
+ jsx('option', { value: 'overlay', children: tr('placement.overlay') }),
4905
+ jsx('option', { value: 'inline', children: tr('placement.inline') }),
4906
+ jsx('option', { value: 'legacy', children: tr('placement.legacy') }),
4907
+ ],
4908
+ }),
4909
+ ],
4910
+ }),
4911
+ jsx('p', { className: 'dshet-config-hint', children: tr(placementHint) }),
4912
+ jsxs('label', {
4913
+ className: 'dshet-config-row',
4914
+ children: [
4915
+ jsx('span', { children: tr('config.height') }),
4916
+ jsx('select', {
4917
+ className: 'dshet-select',
4918
+ value: heightMode,
4919
+ onChange: (event) => setEditorHeightPreference(event.target.value),
4920
+ children: [
4921
+ jsx('option', { value: 'max', children: tr('height.max') }),
4922
+ jsx('option', { value: 'content', children: tr('height.content') }),
4923
+ jsx('option', { value: 'fill', children: tr('height.fill') }),
4924
+ ],
4925
+ }),
4926
+ ],
4927
+ }),
4928
+ jsx('p', { className: 'dshet-config-hint', children: tr(heightHint) }),
4929
+ ],
4930
+ })
2568
4931
  }
2569
4932
 
2570
4933
  // --- plugin ---------------------------------------------------------------
2571
4934
 
2572
4935
  function apply(ctx) {
4936
+ // Diagnostic only: the host restart does not reload the page's JS, so a
4937
+ // stale client bundle and a live host can coexist. This marker says which
4938
+ // browser half actually ran (the old bundle has none).
4939
+ try {
4940
+ document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
4941
+ } catch {
4942
+ /* a non-DOM host is not worth failing over */
4943
+ }
2573
4944
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
2574
4945
 
2575
4946
  const controllers = new Map()
@@ -2597,6 +4968,31 @@ window.__ModuleLoader__.load({
2597
4968
  // A reply's edit entry lives in the official assistant-actions strip, one
2598
4969
  // entry per assistant message. order 5 puts it ahead of the feedback
2599
4970
  // entries (order 10), so the pencil sits with the leading icons.
4971
+ // The active skin repaints its surfaces at any time (switching one
4972
+ // rewrites body's attributes and inline variables), so the palette is
4973
+ // resolved once and then again on every theme or skin change.
4974
+ ctx.effect(() => {
4975
+ const run = () => {
4976
+ try {
4977
+ resolveSkinPalette()
4978
+ } catch {
4979
+ /* the stylesheet defaults stand */
4980
+ }
4981
+ }
4982
+ run()
4983
+ const observer =
4984
+ typeof MutationObserver === 'function' && document && document.body
4985
+ ? new MutationObserver(run)
4986
+ : null
4987
+ if (observer !== null) {
4988
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-ds-dark-theme'] })
4989
+ if (document.documentElement) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
4990
+ }
4991
+ return () => {
4992
+ if (observer !== null) observer.disconnect()
4993
+ }
4994
+ }, 'dsh-edit-turn:skin-palette')
4995
+
2600
4996
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
2601
4997
  ctx.slots.register(
2602
4998
  {
@@ -2613,6 +5009,20 @@ window.__ModuleLoader__.load({
2613
5009
  ),
2614
5010
  )
2615
5011
 
5012
+ // The row's configure arrow in the plugin manager. Without this the
5013
+ // component page is a read-only overview (the card cannot be opened); the
5014
+ // key is the manager's `<bundle package>#<row id>` contract.
5015
+ ctx.slots.inject('plugins.row.config', () =>
5016
+ ctx.slots.register(
5017
+ {
5018
+ name: 'plugins.row.config',
5019
+ key: 'dsh-edit-turn#dsh-edit-turn',
5020
+ locale: NS,
5021
+ },
5022
+ EditorStyleOption,
5023
+ ),
5024
+ )
5025
+
2616
5026
  ctx.slots.inject('conversation.input.overlay', () =>
2617
5027
  ctx.slots.register(
2618
5028
  {
@@ -2628,6 +5038,10 @@ window.__ModuleLoader__.load({
2628
5038
  }
2629
5039
 
2630
5040
  exports.apply = apply
5041
+ // The settings card, exported so a composing bundle (dsh-as-aistudio) can
5042
+ // render THE SAME component on its own page instead of re-implementing it:
5043
+ // one source of truth for 编辑与重跑 settings.
5044
+ exports.ConfigCard = EditorStyleOption
2631
5045
  exports.inject = ['slots', 'locale']
2632
5046
  exports.PLUGIN_VERSION = PLUGIN_VERSION
2633
5047
  return module.exports
@@ -2642,22 +5056,27 @@ window.__ModuleLoader__.load({
2642
5056
  const react = require('react')
2643
5057
  const jsxRuntime = require('react/jsx-runtime')
2644
5058
  const { jsx, jsxs, Fragment } = jsxRuntime
5059
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
2645
5060
 
2646
5061
  const NS = 'dsh-rerun-turn'
2647
5062
  const ROUTE_PREFIX = '/dsh-rerun-turn'
2648
5063
 
2649
5064
  /** Keep in sync with package.json and lib/index.js. */
2650
- const PLUGIN_VERSION = '0.1.27'
5065
+ const PLUGIN_VERSION = '0.2.5'
2651
5066
 
2652
5067
  // --- copy -----------------------------------------------------------------
2653
5068
 
2654
5069
  const zh = {
2655
5070
  'action.rerun': '重跑这一轮',
5071
+ 'action.resend.below': '顺补:重跑下面第一个有 O 的可见轮',
5072
+ 'action.resend.inplace': '自补:重新生成这一轮的回答',
2656
5073
  'confirm.title': '重跑这一轮?',
2657
- 'confirm.body': '这条回答连同它之后的内容会先从模型上下文移除,再用原提示词重新生成;之后的轮次会原样重放。后续调用将读到 A B C1 D E F G。',
5074
+ 'confirm.body': '这条回答连同它之后的内容会先从模型上下文移除,再用原提示词重新生成;之后的轮次会原样重放。',
5075
+ 'confirm.note': '重跑只影响模型后续看到的内容,不会改写历史日志。',
2658
5076
  'confirm.yes': '重跑',
2659
5077
  'confirm.no': '取消',
2660
5078
  'notice.deferred': '重跑已中断:后续轮次的回放将在会话空闲时自动完成。',
5079
+ 'notice.dismiss': '关闭这条提示',
2661
5080
  'notice.loadError': '状态加载失败,稍后重试。',
2662
5081
  'error.invalid': '请求无效,请刷新后重试。',
2663
5082
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再重跑。',
@@ -2675,16 +5094,53 @@ window.__ModuleLoader__.load({
2675
5094
  'error.method': '请求方式不被接受,请刷新后重试。',
2676
5095
  'error.generic': '重跑失败,请重试。',
2677
5096
  'error.internal': '服务器内部错误,请稍后重试。',
5097
+ 'config.ask': '重跑时询问',
5098
+ 'config.hint.ask': '每次重跑前都会先弹出确认,说明这一轮会怎么重放。',
5099
+ 'config.hint.auto': '点一下就直接重跑,不再询问。',
5100
+ 'config.summary.ask': '重跑前询问',
5101
+ 'config.resend': '缺失 O 的可见轮',
5102
+ 'config.terms.title': '本插件约定',
5103
+ 'config.terms.short': 'Q = 用户输入;O = 模型输出的所有内容块(O = C + A + T),其中 C = 思维链,A = 正文,T = 工具调用',
5104
+ 'config.terms.log': '日志轮',
5105
+ 'config.terms.log.def': '日志里记录的轮次',
5106
+ 'config.terms.visible': '可见轮',
5107
+ 'config.terms.visible.def': '对话视窗里、经插件操作之后应该看到的那些轮次',
5108
+ // The option is the WORD; what it does is the hint right below it.
5109
+ 'config.resend.below': '顺补',
5110
+ 'config.resend.inplace': '自补',
5111
+ 'config.hint.resend.below': '重跑它下面第一个有 O 的可见轮;下面没有,就重发最新那条缺 O 的。',
5112
+ 'config.hint.resend.inplace': '重跑这一可见轮自己的 O,后面的轮次不动。',
5113
+ 'config.summary.auto': '直接重跑',
2678
5114
  }
2679
5115
 
2680
5116
  const en = {
2681
5117
  'action.rerun': 'Rerun this turn',
5118
+ 'action.resend.below': 'Shunbu: rerun the first visible turn with an O below',
5119
+ 'action.resend.inplace': 'Zibu: regenerate this turn\'s reply',
2682
5120
  'confirm.title': 'Rerun this turn?',
2683
- 'confirm.body': "The reply and everything after it leave the model context first; the turn's own prompt is sent again, and the later turns are replayed verbatim. The next call will read A B C1 D E F G.",
5121
+ 'confirm.body': "The reply and everything after it leave the model context first; the turn's own prompt is sent again, and the later turns are replayed verbatim.",
5122
+ 'confirm.note': 'A rerun only changes what the model reads next; the session log is never rewritten.',
2684
5123
  'confirm.yes': 'Rerun',
2685
5124
  'confirm.no': 'Cancel',
2686
5125
  'notice.deferred': 'Rerun interrupted: the replay of the later turns will resume automatically once the session is idle.',
5126
+ 'notice.dismiss': 'Dismiss this notice',
2687
5127
  'notice.loadError': 'Loading the state failed; try again shortly.',
5128
+ 'config.ask': 'Ask before rerunning',
5129
+ 'config.hint.ask': 'Show the confirmation step before every rerun.',
5130
+ 'config.hint.auto': 'Rerun right away without asking.',
5131
+ 'config.summary.ask': 'Ask before rerunning',
5132
+ 'config.resend': 'Visible turns missing an O',
5133
+ 'config.terms.title': 'Conventions this plugin uses',
5134
+ 'config.terms.short': 'Q = the user input; O = every content block the model outputs (O = C + A + T): C = reasoning, A = answer text, T = tool calls',
5135
+ 'config.terms.log': 'Log turn',
5136
+ 'config.terms.log.def': 'a turn recorded in the log',
5137
+ 'config.terms.visible': 'Visible turn',
5138
+ 'config.terms.visible.def': 'one of the turns the transcript should show after the plugins have done their work',
5139
+ 'config.resend.below': 'Shunbu',
5140
+ 'config.resend.inplace': 'Zibu',
5141
+ 'config.hint.resend.below': 'Reruns the first visible turn below it that has an O; if none, resends the newest one missing an O.',
5142
+ 'config.hint.resend.inplace': "Reruns this visible turn's own O; the turns after it are left alone.",
5143
+ 'config.summary.auto': 'Rerun without asking',
2688
5144
  'error.invalid': 'Invalid request; refresh and try again.',
2689
5145
  'error.session-not-active': 'This session is not open in DSH; open it first.',
2690
5146
  'error.session-not-found': 'No session log was found for this id.',
@@ -2711,6 +5167,110 @@ window.__ModuleLoader__.load({
2711
5167
  return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
2712
5168
  }
2713
5169
 
5170
+ // A notice derived from live progress is dismissed per phase, rerun and
5171
+ // text: the same interruption stays closed, while a new one still shows.
5172
+ function progressKeyOf(progress) {
5173
+ if (progress === null || typeof progress !== 'object') return null
5174
+ const phase = typeof progress.phase === 'string' ? progress.phase : ''
5175
+ const rerunId = typeof progress.rerunId === 'string' ? progress.rerunId : ''
5176
+ const error = typeof progress.error === 'string' ? progress.error : ''
5177
+ const promptError = typeof progress.promptError === 'string' ? progress.promptError : ''
5178
+ return `${phase}|${rerunId}|${error}|${promptError}`
5179
+ }
5180
+
5181
+ // --- preferences ----------------------------------------------------------
5182
+
5183
+ // One client-side preference: whether a rerun opens its confirmation step
5184
+ // first. It lives in localStorage - the store the official client plugins
5185
+ // keep their view options in - so it survives reloads with no host
5186
+ // round-trip, and a subscription keeps the settings page and the rerun
5187
+ // action on one value. Nothing stored means "follow the host config".
5188
+ const PREFS_KEY = 'dsh-rerun-turn:prefs'
5189
+
5190
+ /** The two answers the "which turn does the button address" setting allows. */
5191
+ const RESEND_MODES = ['below', 'inplace']
5192
+
5193
+ function readStoredPrefs() {
5194
+ try {
5195
+ if (typeof window === 'undefined' || !window.localStorage) return { ask: null, resend: null }
5196
+ const raw = window.localStorage.getItem(PREFS_KEY)
5197
+ if (raw === null) return { ask: null, resend: null }
5198
+ const parsed = JSON.parse(raw)
5199
+ return {
5200
+ ask: parsed && typeof parsed.ask === 'boolean' ? parsed.ask : null,
5201
+ resend: parsed && RESEND_MODES.includes(parsed.resend) ? parsed.resend : null,
5202
+ }
5203
+ } catch {
5204
+ // A missing or malformed entry falls back to the host config rather than
5205
+ // breaking the plugin.
5206
+ return { ask: null, resend: null }
5207
+ }
5208
+ }
5209
+
5210
+ const storedPrefs = readStoredPrefs()
5211
+ let storedAsk = storedPrefs.ask
5212
+ let storedResend = storedPrefs.resend
5213
+ let hostConfirm = false
5214
+ const prefListeners = new Set()
5215
+
5216
+ /**
5217
+ * @returns the effective settings: the stored choices, else the defaults.
5218
+ *
5219
+ * `resend` decides what the button on an UNANSWERED turn addresses:
5220
+ * `below` reruns the reply of the first VISIBLE turn below it that has an A
5221
+ * (and sends the newest unanswered prompt when nothing below has one),
5222
+ * `inplace` regenerates the reply of the visible turn that Q belongs to.
5223
+ * The host replays the turns in between either way. The default is `below`.
5224
+ */
5225
+ function getPrefs() {
5226
+ return {
5227
+ ask: storedAsk === null ? hostConfirm : storedAsk,
5228
+ resend: storedResend === null ? 'below' : storedResend,
5229
+ }
5230
+ }
5231
+
5232
+ function writePrefs() {
5233
+ try {
5234
+ if (typeof window !== 'undefined' && window.localStorage) {
5235
+ // Only the choices that are set: an untouched preference stays absent
5236
+ // so "follow the host config" keeps meaning what it says.
5237
+ const payload = {}
5238
+ if (storedAsk !== null) payload.ask = storedAsk
5239
+ if (storedResend !== null) payload.resend = storedResend
5240
+ window.localStorage.setItem(PREFS_KEY, JSON.stringify(payload))
5241
+ }
5242
+ } catch {
5243
+ // Storage that refuses writes (private mode) must not break the toggle.
5244
+ }
5245
+ for (const listener of prefListeners) {
5246
+ try {
5247
+ listener()
5248
+ } catch {}
5249
+ }
5250
+ }
5251
+
5252
+ function setResendMode(value) {
5253
+ if (!RESEND_MODES.includes(value) || storedResend === value) return
5254
+ storedResend = value
5255
+ writePrefs()
5256
+ }
5257
+
5258
+ function setAskBeforeRerun(value) {
5259
+ const ask = value === true
5260
+ if (storedAsk === ask) return
5261
+ storedAsk = ask
5262
+ // Through the shared writer: writing `{ ask }` alone would drop the other
5263
+ // stored choice (the two live under one key).
5264
+ writePrefs()
5265
+ }
5266
+
5267
+ function subscribePrefs(listener) {
5268
+ prefListeners.add(listener)
5269
+ return () => {
5270
+ prefListeners.delete(listener)
5271
+ }
5272
+ }
5273
+
2714
5274
  // --- style ----------------------------------------------------------------
2715
5275
 
2716
5276
  // Same policy as the sibling plugins: the plugin paints its own surfaces
@@ -2723,8 +5283,8 @@ window.__ModuleLoader__.load({
2723
5283
  'body[data-ds-dark-theme]{--dsrr-panel:rgba(22,28,44,.96);--dsrr-field:rgba(14,19,33,.98);--dsrr-line:rgba(255,255,255,.18);--dsrr-ink:#eef2f8;--dsrr-ink-dim:#a6b1c6;--dsrr-hover:rgba(255,255,255,.11);--dsrr-shadow:0 12px 32px rgba(0,0,0,.5)}',
2724
5284
  // The rerun icon sits among the platform's own action icons and takes
2725
5285
  // their colour (the fallback is what the default theme resolves to).
2726
- '.dsrr-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
2727
- '.dsrr-action:hover:not(:disabled){background:var(--dsrr-field);box-shadow:inset 0 0 0 1px var(--dsrr-line);color:var(--dsw-alias-label-primary,var(--dsrr-ink))}',
5286
+ '.dsrr-action{width:28px;height:28px;padding:6px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
5287
+ '.dsrr-action:hover:not(:disabled){background:var(--dsrr-field);color:var(--dsw-alias-label-primary,var(--dsrr-ink))}',
2728
5288
  '.dsrr-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:2px}',
2729
5289
  '.dsrr-action:disabled{cursor:default;opacity:.4}',
2730
5290
  '.dsrr-action svg{width:15px;height:15px}',
@@ -2739,11 +5299,33 @@ window.__ModuleLoader__.load({
2739
5299
  // edge, so its children would spill over the input. The stack lifts
2740
5300
  // itself by its own height, landing fully above the composer.
2741
5301
  '.dsrr-stack{transform:translateY(-100%)}',
2742
- '.dsrr-notice{margin:4px 0;padding:6px 10px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dsrr-shadow);color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
5302
+ '.dsrr-notice{display:flex;align-items:flex-start;gap:8px;margin:4px 0;padding:6px 10px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dsrr-shadow);color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
5303
+ '.dsrr-notice-text{flex:1;min-width:0}',
5304
+ '.dsrr-notice-close{flex:none;width:18px;height:18px;margin:0 -2px 0 0;padding:0;border:none;border-radius:50%;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:18px;cursor:pointer;opacity:.7}',
5305
+ '.dsrr-notice-close:hover{opacity:1;background:var(--dsrr-hover)}',
5306
+ '.dsrr-notice-close:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:1px}',
2743
5307
  '.dsrr-notice.dsrr-notice-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
2744
- '.dsrr-confirm{margin:4px 0;padding:8px 10px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dsrr-shadow);color:var(--dsrr-ink);font-size:12px;line-height:19px}',
2745
- '.dsrr-confirm-title{font-weight:600;margin-bottom:2px}',
2746
- '.dsrr-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:6px}',
5308
+ // The confirmation is the platform's own modal (the sibling delete plugin
5309
+ // draws its dialog the same way), so the only copy style here is the body
5310
+ // paragraph - the same 14/22 the platform's dialog text uses.
5311
+ '.dsrr-dialog-text{margin:0;color:var(--dsw-alias-label-primary,var(--dsrr-ink));font-size:14px;line-height:22px}',
5312
+ '.dsrr-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,var(--dsrr-ink-dim));font-size:13px;line-height:20px}',
5313
+ '.dsrr-config{display:flex;flex-direction:column;gap:14px;width:100%;max-width:none}',
5314
+ '.dsrr-config-group{display:flex;flex-direction:column;gap:6px}',
5315
+ '.dsrr-config-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;font-weight:500}',
5316
+ // Width follows the choice: the options are two characters each, so a
5317
+ // fixed 190px floor only stretched the control across the card.
5318
+ '.dsrr-select{flex:0 0 auto;width:auto;max-width:100%;padding:5px 8px;border:1px solid var(--dsrr-line);border-radius:8px;background:var(--dsrr-panel);color:inherit;font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s ease}',
5319
+ '.dsrr-select:hover{border-color:var(--dsrr-accent)}',
5320
+ '.dsrr-select:focus-visible{outline:none;border-color:var(--dsrr-accent);box-shadow:0 0 0 2px var(--dsrr-hover)}',
5321
+ '.dsrr-config-hint{margin:0;max-width:72ch;color:var(--dsrr-ink-dim);font-size:12px;line-height:19px}',
5322
+ '.dsrr-config-divider{height:1px;background:var(--dsrr-line);opacity:.6}',
5323
+ '.dsrr-terms{display:flex;flex-direction:column;gap:8px;margin:0;padding:12px 14px;border-left:3px solid var(--dsrr-accent);border-radius:0 10px 10px 0;background:var(--dsrr-panel)}',
5324
+ '.dsrr-terms-title{margin:0;font-size:13.5px;font-weight:600;line-height:20px}',
5325
+ '.dsrr-terms-short{margin:0;max-width:88ch;font-size:13px;line-height:21px}',
5326
+ '.dsrr-terms-row{display:grid;grid-template-columns:62px 1fr;gap:4px 12px;align-items:baseline;font-size:13px;line-height:21px}',
5327
+ '.dsrr-terms-term{font-weight:600}',
5328
+ '.dsrr-terms-def{margin:0;color:var(--dsrr-ink-dim)}',
2747
5329
  '.dsrr-btn{padding:4px 12px;border:1px solid var(--dsrr-line);border-radius:999px;background:var(--dsrr-field);color:var(--dsrr-ink);font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s}',
2748
5330
  '.dsrr-btn:hover{background:var(--dsrr-hover)}',
2749
5331
  '.dsrr-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dsrr-accent));outline-offset:2px}',
@@ -2809,11 +5391,14 @@ window.__ModuleLoader__.load({
2809
5391
  progress: null,
2810
5392
  pending: false,
2811
5393
  failure: null,
5394
+ failureDetail: null,
2812
5395
  notice: null,
2813
5396
  noticeError: false,
5397
+ dismissedKey: null,
2814
5398
  confirming: false,
2815
5399
  loaded: false,
2816
5400
  loadError: false,
5401
+ loadErrorDetail: null,
2817
5402
  confirm: false,
2818
5403
  revision: 0,
2819
5404
  })
@@ -2916,6 +5501,7 @@ window.__ModuleLoader__.load({
2916
5501
  }
2917
5502
  }
2918
5503
  }
5504
+ hostConfirm = Boolean(data.config && data.config.confirm)
2919
5505
  this.publish({
2920
5506
  hidden,
2921
5507
  replies,
@@ -2929,6 +5515,7 @@ window.__ModuleLoader__.load({
2929
5515
  progress: data.progress || null,
2930
5516
  loaded: true,
2931
5517
  loadError: false,
5518
+ loadErrorDetail: null,
2932
5519
  confirm: Boolean(data.config && data.config.confirm),
2933
5520
  })
2934
5521
  // While a rerun runs in the host, keep asking; one last refresh
@@ -2936,8 +5523,12 @@ window.__ModuleLoader__.load({
2936
5523
  if (rerunning) this.ensurePolling()
2937
5524
  else this.stopPolling()
2938
5525
  })
2939
- .catch(() => {
2940
- this.publish({ loadError: true })
5526
+ .catch((error) => {
5527
+ // The host says WHY (`invalid session id`, `no session log for this
5528
+ // id`, a 500, a network failure). Swallowing it left the panel with
5529
+ // "状态加载失败,稍后重试。" for every cause alike - including the
5530
+ // causes a user can act on.
5531
+ this.publish({ loadError: true, loadErrorDetail: error instanceof Error ? error.message : String(error) })
2941
5532
  })
2942
5533
  .finally(() => {
2943
5534
  this.inflight = null
@@ -2974,7 +5565,10 @@ window.__ModuleLoader__.load({
2974
5565
  rerun(entry) {
2975
5566
  if (this.disposed === true) return
2976
5567
  if (this.view.pending) return
2977
- if (this.view.confirm) {
5568
+ // The settings-page checkbox (stored) wins over the host config; with
5569
+ // asking off the click goes straight to the host, and a failure still
5570
+ // surfaces through the notice path.
5571
+ if (getPrefs().ask === true) {
2978
5572
  this.confirmTarget = entry
2979
5573
  this.publish({ confirming: true, failure: null })
2980
5574
  return
@@ -2995,7 +5589,11 @@ window.__ModuleLoader__.load({
2995
5589
  }
2996
5590
 
2997
5591
  dismissNotice() {
2998
- this.publish({ notice: null, noticeError: false })
5592
+ // A transient notice (view.notice) is not derived from progress;
5593
+ // closing it must not also dismiss the progress notice underneath.
5594
+ const transient = this.view.notice !== null && this.view.notice !== undefined
5595
+ const key = transient ? this.view.dismissedKey : progressKeyOf(this.view.progress)
5596
+ this.publish({ notice: null, noticeError: false, dismissedKey: key })
2999
5597
  }
3000
5598
 
3001
5599
  async send(entry) {
@@ -3015,11 +5613,11 @@ window.__ModuleLoader__.load({
3015
5613
  // the rerun that just completed, clicked once more before the
3016
5614
  // client refreshed. Not an error: refresh silently and let the
3017
5615
  // stale row disappear.
3018
- this.publish({ pending: false, failure: null })
5616
+ this.publish({ pending: false, failure: null, failureDetail: null })
3019
5617
  this.load(true)
3020
5618
  return
3021
5619
  }
3022
- this.publish({ pending: false, failure: code })
5620
+ this.publish({ pending: false, failure: code, failureDetail: typeof data.error === 'string' && data.error !== '' ? data.error : null })
3023
5621
  return
3024
5622
  }
3025
5623
  // Do NOT hide the retired rows here: the rerun's replacement (fresh
@@ -3210,7 +5808,6 @@ window.__ModuleLoader__.load({
3210
5808
  }
3211
5809
  const focus = view.rerunning === true && view.rerunFocus ? view.rerunFocus : null
3212
5810
  const infos = []
3213
- let hasFresh = false
3214
5811
  for (const row of rows) {
3215
5812
  if (!(row instanceof HTMLElement)) continue
3216
5813
  const key = row.getAttribute('data-chat-flow-key')
@@ -3234,11 +5831,6 @@ window.__ModuleLoader__.load({
3234
5831
  const freshBySeq = info.seqs.some((seq) => seq > focus.carrierSeq)
3235
5832
  const freshByTurn = info.keyTurn !== null && info.keyTurn > focus.turn
3236
5833
  info.fresh = !info.inTarget && !info.inTail && (freshBySeq || freshByTurn)
3237
- // Only a real answer row proves the replacement is on screen. The
3238
- // fresh turn's process/tail strips mount the moment the rerun starts,
3239
- // so counting them would retire the old answer while it is still
3240
- // generating - leaving a hole where the answer was.
3241
- if (info.fresh && (info.kind === 'assistant-step' || info.kind === 'tool-call')) hasFresh = true
3242
5834
  }
3243
5835
  infos.push(info)
3244
5836
  }
@@ -3277,9 +5869,11 @@ window.__ModuleLoader__.load({
3277
5869
  } else {
3278
5870
  order = '1'
3279
5871
  }
3280
- } else if (info.inTarget && info.kind !== 'user' && hasFresh) {
3281
- // The old answer retires only once its replacement is on screen -
3282
- // the streaming row occupies the same spot.
5872
+ } else if (info.inTarget && info.kind !== 'user') {
5873
+ // The old answer retires the moment the rerun starts, so the fresh
5874
+ // "thinking" row streams where the answer was instead of below it.
5875
+ // Waiting for an assistant-step row left the old answer on screen
5876
+ // for the whole generation.
3283
5877
  decision = true
3284
5878
  }
3285
5879
  }
@@ -3299,12 +5893,69 @@ window.__ModuleLoader__.load({
3299
5893
  }
3300
5894
  return found
3301
5895
  }
5896
+ // Every user row whose turn has no live reply gets the prompt-side button:
5897
+ // a stopped turn, a failed one, one whose reply was deleted, or the prompt
5898
+ // nothing has answered yet. What the button ADDRESSES is the setting
5899
+ // (`getPrefs().resend`):
5900
+ // below - the reply of the first VISIBLE turn below it that has an A
5901
+ // (a hidden reply is not visible, so it is skipped); when
5902
+ // nothing below has one, the newest unanswered prompt.
5903
+ // inplace - the reply of the visible turn this Q belongs to.
5904
+ // The turns in between are replayed by the host either way.
5905
+ const liveReplyOf = (turn) => {
5906
+ if (!Number.isInteger(turn)) return null
5907
+ const reply = lastReplyForTurn(turn)
5908
+ if (reply === null || view.hidden.has(reply.seq)) return null
5909
+ return reply
5910
+ }
5911
+ let newestUnansweredSeq = null
3302
5912
  for (const info of infos) {
5913
+ if (info.kind !== 'user') continue
5914
+ if (liveReplyOf(Number(info.row.dataset.chatTurn)) !== null) continue
5915
+ const seq = Array.isArray(info.seqs) ? info.seqs[0] : undefined
5916
+ if (typeof seq === 'number') newestUnansweredSeq = seq
5917
+ }
5918
+ const resendMode = getPrefs().resend
5919
+ for (let index = 0; index < infos.length; index += 1) {
5920
+ const info = infos[index]
3303
5921
  if (info.kind !== 'user') continue
3304
5922
  const row = info.row
3305
5923
  let entry = promptActions.get(row)
3306
5924
  const reply = lastReplyForTurn(Number(row.dataset.chatTurn))
3307
- if (reply === null || view.hidden.has(reply.seq)) {
5925
+ // A reply that exists but is hidden (delete-turn owns the row, or an
5926
+ // earlier rerun retired it) leaves nothing to rerun.
5927
+ if (reply !== null && view.hidden.has(reply.seq)) {
5928
+ if (entry !== undefined) {
5929
+ entry.host.remove()
5930
+ promptActions.delete(row)
5931
+ }
5932
+ continue
5933
+ }
5934
+ // No live reply at all: a turn the user stopped, one that failed, or the
5935
+ // prompt just sent that nothing has answered yet. The host accepts a
5936
+ // PROMPT target for exactly that shape - planRerun: "a prompt target is
5937
+ // an address, never a licence ... accepted only for a turn that has no
5938
+ // rerunnable reply of its own" - and sends it again as a fresh request.
5939
+ // So the button stays and addresses the prompt instead of a reply.
5940
+ const promptSeq = Array.isArray(info.seqs) ? info.seqs[0] : undefined
5941
+ let unansweredTarget = null
5942
+ if (resendMode === 'inplace') {
5943
+ // The visible turn this Q belongs to: its own prompt is the anchor.
5944
+ if (typeof promptSeq === 'number') unansweredTarget = { seq: promptSeq }
5945
+ } else {
5946
+ // The first visible turn BELOW this Q that has an A.
5947
+ for (let next = index + 1; next < infos.length && unansweredTarget === null; next += 1) {
5948
+ if (infos[next].kind !== 'assistant-step') continue
5949
+ const below = liveReplyOf(infos[next].keyTurn)
5950
+ if (below !== null) unansweredTarget = below
5951
+ }
5952
+ // Nothing below has an A: send the newest unanswered prompt instead.
5953
+ if (unansweredTarget === null && typeof newestUnansweredSeq === 'number') {
5954
+ unansweredTarget = { seq: newestUnansweredSeq }
5955
+ }
5956
+ }
5957
+ const target = reply !== null ? reply : unansweredTarget
5958
+ if (target === null) {
3308
5959
  if (entry !== undefined) {
3309
5960
  entry.host.remove()
3310
5961
  promptActions.delete(row)
@@ -3341,10 +5992,10 @@ window.__ModuleLoader__.load({
3341
5992
  host.appendChild(button)
3342
5993
  }
3343
5994
  if (entry === undefined || entry.host !== host || entry.button !== button) {
3344
- entry = { host, button, reply, controller }
5995
+ entry = { host, button, reply: target, controller }
3345
5996
  promptActions.set(row, entry)
3346
5997
  }
3347
- entry.reply = reply
5998
+ entry.reply = target
3348
5999
  entry.controller = controller
3349
6000
  if (!wiredButtons.has(button)) {
3350
6001
  // Wired ONCE per button per instance: the pass runs on every snapshot.
@@ -3360,7 +6011,12 @@ window.__ModuleLoader__.load({
3360
6011
  current.controller.rerun(current.reply)
3361
6012
  })
3362
6013
  }
3363
- const label = t === null ? '' : t('action.rerun')
6014
+ // The prompt-side button of an unanswered turn says what it will do:
6015
+ // send the newest unanswered prompt again, or regenerate this very turn.
6016
+ const label =
6017
+ t === null
6018
+ ? ''
6019
+ : t(reply === null ? (resendMode === 'inplace' ? 'action.resend.inplace' : 'action.resend.below') : 'action.rerun')
3364
6020
  if (entry.button.getAttribute('aria-label') !== label) {
3365
6021
  entry.button.setAttribute('aria-label', label)
3366
6022
  entry.button.setAttribute('title', label)
@@ -3381,6 +6037,93 @@ window.__ModuleLoader__.load({
3381
6037
  bar.insertBefore(entry.host, next)
3382
6038
  }
3383
6039
  }
6040
+ syncTurnRail(view)
6041
+ }
6042
+
6043
+ // --- turn rail ------------------------------------------------------------
6044
+
6045
+ /** The turn a rail mark addresses: its label carries the real turn number. */
6046
+ function turnOfRailMark(mark) {
6047
+ const label = mark.getAttribute('aria-label') || mark.getAttribute('title') || ''
6048
+ const match = /(\d+)/.exec(label)
6049
+ return match === null ? null : Number(match[1])
6050
+ }
6051
+
6052
+ /**
6053
+ * Every turn that has a row in the DOM, mapped to whether any of its rows is
6054
+ * visible. A turn with no row at all is absent here: it is not empty, just
6055
+ * not loaded, and the rail may still jump to it.
6056
+ */
6057
+ function turnRowVisibility() {
6058
+ const seen = new Map()
6059
+ let rows
6060
+ try {
6061
+ rows = document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')
6062
+ } catch {
6063
+ return seen
6064
+ }
6065
+ for (const row of rows) {
6066
+ const turn = Number(row.getAttribute('data-chat-turn'))
6067
+ if (!Number.isInteger(turn)) continue
6068
+ const style = row.style
6069
+ const hidden =
6070
+ row.dataset.dsrrHidden === '1' ||
6071
+ row.dataset.dshetHidden === '1' ||
6072
+ row.dataset.dshdtHidden === '1' ||
6073
+ (style !== undefined && style !== null && style.display === 'none') ||
6074
+ (typeof row.getBoundingClientRect === 'function' && row.getBoundingClientRect().height === 0)
6075
+ if (seen.get(turn) !== true) seen.set(turn, hidden !== true)
6076
+ }
6077
+ return seen
6078
+ }
6079
+
6080
+ /**
6081
+ * The official rail keeps one mark per turn of the session - including the
6082
+ * turns this plugin retired, whose rows are hidden. So a rerun left marks
6083
+ * for turns the conversation no longer shows, and their slots left a gap.
6084
+ * Drop the marks of turns with no visible row, re-space the rest inside the
6085
+ * host's own ladder and centre them; the container keeps the host's height,
6086
+ * because that height is what the host's virtualisation measures: shrinking
6087
+ * it unmounts the marks past the new window (a ten-turn transcript showed
6088
+ * six marks).
6089
+ */
6090
+ function syncTurnRail(view) {
6091
+ const rows = turnRowVisibility()
6092
+ const hiddenTurns = []
6093
+ for (const [turn, visible] of rows) if (visible !== true) hiddenTurns.push(turn)
6094
+ let navs
6095
+ try {
6096
+ navs = document.querySelectorAll('nav')
6097
+ } catch {
6098
+ return
6099
+ }
6100
+ for (const nav of navs) {
6101
+ let marks
6102
+ try {
6103
+ marks = [...nav.querySelectorAll('button[data-index]')]
6104
+ } catch {
6105
+ continue
6106
+ }
6107
+ if (marks.length === 0) continue
6108
+ const container = marks[0].parentElement
6109
+ if (container === null || container === undefined) continue
6110
+ const observed = Math.round((Number.parseFloat(container.style.height) - 2) / 10)
6111
+ const total = Math.max(Number.isFinite(observed) && observed > 0 ? observed : 0, marks.length)
6112
+ const inRail = hiddenTurns.filter((turn) => turn <= total)
6113
+ const kept = total - inRail.length
6114
+ const offset = kept > 0 && kept < total ? Math.floor((total - kept) / 2) : 0
6115
+ for (const mark of marks) {
6116
+ const turn = turnOfRailMark(mark)
6117
+ if (turn === null) continue
6118
+ if (hiddenTurns.includes(turn)) {
6119
+ mark.style.display = 'none'
6120
+ continue
6121
+ }
6122
+ mark.style.display = ''
6123
+ const before = hiddenTurns.filter((hidden) => hidden < turn).length
6124
+ mark.style.transform = `translate3d(0px, ${(turn - 1 - before + offset) * 10 + 1}px, 0px)`
6125
+ }
6126
+ }
3384
6127
  }
3385
6128
 
3386
6129
  // --- react entries --------------------------------------------------------
@@ -3455,6 +6198,88 @@ window.__ModuleLoader__.load({
3455
6198
 
3456
6199
  // The overlay: hides retired rows, follows the background rerun, and
3457
6200
  // shows the confirm step plus any failure the host reported.
6201
+ // The plugin's own page in the manager: the row this bundle inserts
6202
+ // (`cordis.patch.yml`, id `dsh-rerun-turn`) gains a configure arrow that
6203
+ // opens this card. Same shape as the sibling plugins' cards.
6204
+ function AskBeforeRerunOption({ view, t }) {
6205
+ const tr = typeof t === 'function' ? t : (key) => key
6206
+ const [ask, setAsk] = react.useState(getPrefs().ask)
6207
+ const [resend, setResend] = react.useState(getPrefs().resend)
6208
+ react.useEffect(
6209
+ () =>
6210
+ subscribePrefs(() => {
6211
+ setAsk(getPrefs().ask)
6212
+ setResend(getPrefs().resend)
6213
+ }),
6214
+ [],
6215
+ )
6216
+ if (view === 'summary') return tr(ask ? 'config.summary.ask' : 'config.summary.auto')
6217
+ return jsxs('div', {
6218
+ className: 'dsrr-config',
6219
+ children: [
6220
+ // The convention frames both settings, so it leads.
6221
+ jsxs('div', {
6222
+ className: 'dsrr-terms',
6223
+ children: [
6224
+ jsx('p', { className: 'dsrr-terms-title', children: tr('config.terms.title') }),
6225
+ jsx('p', { className: 'dsrr-terms-short', children: tr('config.terms.short') }),
6226
+ jsxs('div', {
6227
+ className: 'dsrr-terms-row',
6228
+ children: [
6229
+ jsx('span', { className: 'dsrr-terms-term', children: tr('config.terms.log') }),
6230
+ jsx('p', { className: 'dsrr-terms-def', children: tr('config.terms.log.def') }),
6231
+ ],
6232
+ }),
6233
+ jsxs('div', {
6234
+ className: 'dsrr-terms-row',
6235
+ children: [
6236
+ jsx('span', { className: 'dsrr-terms-term', children: tr('config.terms.visible') }),
6237
+ jsx('p', { className: 'dsrr-terms-def', children: tr('config.terms.visible.def') }),
6238
+ ],
6239
+ }),
6240
+ ],
6241
+ }),
6242
+ // One setting per group: the control on the row, what it means right
6243
+ // under it, and a hairline between the groups.
6244
+ jsxs('div', {
6245
+ className: 'dsrr-config-group',
6246
+ children: [
6247
+ jsx(primitives.Checkbox, {
6248
+ checked: ask,
6249
+ label: tr('config.ask'),
6250
+ onChange: (checked) => setAskBeforeRerun(checked),
6251
+ }),
6252
+ jsx('p', { className: 'dsrr-config-hint', children: tr(ask ? 'config.hint.ask' : 'config.hint.auto') }),
6253
+ ],
6254
+ }),
6255
+ jsx('div', { className: 'dsrr-config-divider' }),
6256
+ // What the button on an UNANSWERED turn addresses: the first visible
6257
+ // turn with an A below it, or the visible turn that Q belongs to.
6258
+ jsxs('div', {
6259
+ className: 'dsrr-config-group',
6260
+ children: [
6261
+ jsxs('label', {
6262
+ className: 'dsrr-config-row',
6263
+ children: [
6264
+ jsx('span', { children: tr('config.resend') }),
6265
+ jsx('select', {
6266
+ className: 'dsrr-select',
6267
+ value: resend,
6268
+ onChange: (event) => setResendMode(event.target.value),
6269
+ children: [
6270
+ jsx('option', { value: 'below', children: tr('config.resend.below') }),
6271
+ jsx('option', { value: 'inplace', children: tr('config.resend.inplace') }),
6272
+ ],
6273
+ }),
6274
+ ],
6275
+ }),
6276
+ jsx('p', { className: 'dsrr-config-hint', children: tr(resend === 'inplace' ? 'config.hint.resend.inplace' : 'config.hint.resend.below') }),
6277
+ ],
6278
+ }),
6279
+ ],
6280
+ })
6281
+ }
6282
+
3458
6283
  function OverlayEntry({ useChat, useRerunTurn, controller, t }) {
3459
6284
  const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
3460
6285
  const view = useRerunTurn((state) => state)
@@ -3499,63 +6324,100 @@ window.__ModuleLoader__.load({
3499
6324
  }, [view.rerunning, controller])
3500
6325
 
3501
6326
  const progress = view.progress
6327
+ const progressKey = progressKeyOf(progress)
6328
+ const progressDismissed = progressKey !== null && view.dismissedKey === progressKey
6329
+ // `error` is only meaningful while the rerun is interrupted or failed.
6330
+ // Older hosts merged progress records, so a finished rerun could still
6331
+ // carry the interruption text; trusting the phase keeps that stale text
6332
+ // off the screen even before the host is upgraded.
6333
+ const progressError =
6334
+ progressDismissed !== true && progress !== null && typeof progress === 'object' &&
6335
+ (progress.phase === 'interrupted' || progress.phase === 'failed') &&
6336
+ typeof progress.error === 'string' && progress.error !== ''
6337
+ ? progress.error
6338
+ : ''
6339
+ const progressPromptError =
6340
+ progressDismissed !== true && progress !== null && typeof progress === 'object' && typeof progress.promptError === 'string' && progress.promptError !== ''
6341
+ ? progress.promptError
6342
+ : ''
3502
6343
  const notice =
3503
6344
  view.notice !== null && view.notice !== undefined
3504
6345
  ? { text: view.notice, error: view.noticeError === true }
3505
6346
  : view.loadError === true
3506
- ? { text: t('notice.loadError'), error: true }
3507
- : progress && typeof progress.error === 'string' && progress.error !== ''
3508
- ? { text: `${t('notice.deferred')} (${progress.error})`, error: true }
3509
- : progress && typeof progress.promptError === 'string' && progress.promptError !== ''
3510
- ? { text: progress.promptError, error: true }
6347
+ ? {
6348
+ text:
6349
+ typeof view.loadErrorDetail === 'string' && view.loadErrorDetail !== ''
6350
+ ? `${t('notice.loadError')} (${view.loadErrorDetail})`
6351
+ : t('notice.loadError'),
6352
+ error: true,
6353
+ }
6354
+ : progressError !== ''
6355
+ ? { text: `${t('notice.deferred')} (${progressError})`, error: true }
6356
+ : progressPromptError !== ''
6357
+ ? { text: progressPromptError, error: true }
3511
6358
  : null
3512
6359
 
3513
6360
  return jsxs('div', {
3514
6361
  className: 'dsrr-stack',
3515
6362
  'data-dsrr-overlay': '1',
3516
6363
  children: [
3517
- view.confirming === true
3518
- ? jsxs('div', {
3519
- className: 'dsrr-confirm',
3520
- role: 'alertdialog',
3521
- 'aria-label': t('confirm.title'),
3522
- children: [
3523
- jsx('div', { className: 'dsrr-confirm-title', children: t('confirm.title') }),
3524
- jsx('div', { children: t('confirm.body') }),
3525
- jsxs('div', {
3526
- className: 'dsrr-confirm-actions',
3527
- children: [
3528
- jsx('button', {
3529
- type: 'button',
3530
- className: 'dsrr-btn',
3531
- onClick: () => controller.confirmNo(),
3532
- children: t('confirm.no'),
3533
- }),
3534
- jsx('button', {
3535
- type: 'button',
3536
- className: 'dsrr-btn dsrr-btn-primary',
3537
- onClick: () => controller.confirmYes(),
3538
- children: t('confirm.yes'),
3539
- }),
3540
- ],
3541
- }),
3542
- ],
3543
- })
3544
- : null,
6364
+ jsx(primitives.Modal, {
6365
+ open: view.confirming === true,
6366
+ title: t('confirm.title'),
6367
+ closeLabel: t('confirm.no'),
6368
+ onClose: () => controller.confirmNo(),
6369
+ footer: jsxs(Fragment, {
6370
+ children: [
6371
+ jsx(primitives.Button, {
6372
+ variant: 'ghost',
6373
+ size: 'md',
6374
+ onClick: () => controller.confirmNo(),
6375
+ children: t('confirm.no'),
6376
+ }),
6377
+ jsx(primitives.Button, {
6378
+ variant: 'primary',
6379
+ size: 'md',
6380
+ onClick: () => controller.confirmYes(),
6381
+ children: t('confirm.yes'),
6382
+ }),
6383
+ ],
6384
+ }),
6385
+ children: jsxs(Fragment, {
6386
+ children: [
6387
+ jsx('p', { className: 'dsrr-dialog-text', children: t('confirm.body') }),
6388
+ jsx('p', { className: 'dsrr-dialog-note', children: t('confirm.note') }),
6389
+ ],
6390
+ }),
6391
+ }),
3545
6392
  view.failure !== null && view.failure !== undefined
3546
6393
  ? jsx('div', {
3547
6394
  className: 'dsrr-notice dsrr-notice-error',
3548
6395
  role: 'status',
3549
- onClick: () => controller.publish({ failure: null }),
3550
- children: errorText(t, view.failure),
6396
+ onClick: () => controller.publish({ failure: null, failureDetail: null }),
6397
+ children: (view.failure === 'internal' || view.failure === 'generic') && typeof view.failureDetail === 'string' && view.failureDetail !== ''
6398
+ ? `${errorText(t, view.failure)}(${view.failureDetail.slice(0, 300)})`
6399
+ : errorText(t, view.failure),
3551
6400
  })
3552
6401
  : null,
3553
6402
  notice !== null
3554
- ? jsx('div', {
6403
+ ? jsxs('div', {
3555
6404
  className: notice.error ? 'dsrr-notice dsrr-notice-error' : 'dsrr-notice',
3556
6405
  role: 'status',
3557
6406
  onClick: () => controller.dismissNotice(),
3558
- children: notice.text,
6407
+ children: [
6408
+ jsx('span', { className: 'dsrr-notice-text', children: notice.text }),
6409
+ jsx('button', {
6410
+ type: 'button',
6411
+ className: 'dsrr-notice-close',
6412
+ 'aria-label': t('notice.dismiss'),
6413
+ title: t('notice.dismiss'),
6414
+ onClick: (event) => {
6415
+ event.stopPropagation()
6416
+ controller.dismissNotice()
6417
+ },
6418
+ children: '×',
6419
+ }),
6420
+ ],
3559
6421
  })
3560
6422
  : null,
3561
6423
  ],
@@ -3633,9 +6495,27 @@ window.__ModuleLoader__.load({
3633
6495
  OverlayEntry,
3634
6496
  ),
3635
6497
  )
6498
+
6499
+ // The plugin's own page in the manager. The key is the manager's
6500
+ // `<bundle package>#<row id>` contract (the row this bundle inserts in
6501
+ // cordis.patch.yml), exactly as the sibling plugins declare it.
6502
+ ctx.slots.inject('plugins.row.config', () =>
6503
+ ctx.slots.register(
6504
+ {
6505
+ name: 'plugins.row.config',
6506
+ key: 'dsh-rerun-turn#dsh-rerun-turn',
6507
+ locale: NS,
6508
+ },
6509
+ AskBeforeRerunOption,
6510
+ ),
6511
+ )
3636
6512
  }
3637
6513
 
3638
6514
  exports.apply = apply
6515
+ // The settings card, exported so a composing bundle (dsh-as-aistudio) can
6516
+ // render THE SAME component on its own page instead of re-implementing it:
6517
+ // one source of truth for 重跑 settings.
6518
+ exports.ConfigCard = AskBeforeRerunOption
3639
6519
  exports.inject = ['slots', 'locale']
3640
6520
  exports.PLUGIN_VERSION = PLUGIN_VERSION
3641
6521
  return module.exports
@@ -3732,6 +6612,9 @@ window.__ModuleLoader__.load({
3732
6612
  'dialog.desc.step': '这一步的回复与它请求的工具结果将一并从模型上下文中移除,同一回合的其它步骤保留。',
3733
6613
  'dialog.desc.reply': '这条回复连同它的思考、工具调用与注入上下文将从模型上下文中移除,你的提问会保留。',
3734
6614
  'dialog.note': '删除只影响模型后续看到的内容,不会改写历史日志。',
6615
+ 'dialog.busyWarning': '当前回复正在生成;删除会先停止它,再继续。',
6616
+ 'dialog.extraWarning': '这段范围里还夹着别的内容;确认后会一并移除。',
6617
+ 'dialog.confirmExtra': '一并删除',
3735
6618
  'dialog.cancel': '取消',
3736
6619
  'dialog.confirm': '删除',
3737
6620
  'dialog.pending': '删除中…',
@@ -3744,7 +6627,8 @@ window.__ModuleLoader__.load({
3744
6627
  'error.invalid': '请求无效,请刷新后重试。',
3745
6628
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再删除。',
3746
6629
  'error.session-not-found': '找不到该会话的日志。',
3747
- 'error.busy': '该会话正在进行中,请等回复结束后再删除。',
6630
+ 'error.busy': '该会话正在进行中,且当前无法自动停止(可能正在压缩),请稍后重试。',
6631
+ 'error.stop-timeout': '已请求停止当前生成,但该回合没有及时停下;请稍后再试。',
3748
6632
  'error.already-deleted': '这条内容已经从上下文中删除了。',
3749
6633
  'error.not-deletable': '这个位置不支持删除。',
3750
6634
  'error.range-not-clean': '目标区间包含其它内容,已取消删除。',
@@ -3768,6 +6652,9 @@ window.__ModuleLoader__.load({
3768
6652
  'dialog.desc.step': 'This step and the tool results it requested leave the model context together; other steps in the same turn stay.',
3769
6653
  'dialog.desc.reply': 'This reply leaves the model context together with its reasoning, tool calls and injected context; your prompt stays.',
3770
6654
  'dialog.note': 'Deletion only changes what the model sees next; the append-only log is never rewritten.',
6655
+ 'dialog.busyWarning': 'A reply is still generating; deleting will stop it first.',
6656
+ 'dialog.extraWarning': 'This range also covers other content; confirming removes it as well.',
6657
+ 'dialog.confirmExtra': 'Delete all of it',
3771
6658
  'dialog.cancel': 'Cancel',
3772
6659
  'dialog.confirm': 'Delete',
3773
6660
  'dialog.pending': 'Deleting...',
@@ -3780,7 +6667,8 @@ window.__ModuleLoader__.load({
3780
6667
  'error.invalid': 'Invalid request; refresh and try again.',
3781
6668
  'error.session-not-active': 'This session is not open in DSH; open it first.',
3782
6669
  'error.session-not-found': 'No session log was found for this id.',
3783
- 'error.busy': 'This session is still working; wait for the reply to finish.',
6670
+ 'error.busy': 'This session is working and cannot be stopped right now (a compaction may be running); try again in a moment.',
6671
+ 'error.stop-timeout': 'The running turn did not stop in time; try again in a moment.',
3784
6672
  'error.already-deleted': 'This content is already removed from the context.',
3785
6673
  'error.not-deletable': 'This position cannot be deleted.',
3786
6674
  'error.range-not-clean': 'The target window contains unrelated content; the delete was cancelled.',
@@ -3820,6 +6708,7 @@ window.__ModuleLoader__.load({
3820
6708
  '[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
3821
6709
  '.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
3822
6710
  '.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
6711
+ '.dshdt-dialog-warn{margin:10px 0 0;color:var(--dsw-alias-state-warn-primary,#a15c00);font-size:13px;line-height:20px}',
3823
6712
  '.dshdt-dialog-error{margin:10px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:13px;line-height:20px}',
3824
6713
  '.dshdt-config{display:flex;flex-direction:column;gap:8px}',
3825
6714
  '.dshdt-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
@@ -3905,11 +6794,17 @@ window.__ModuleLoader__.load({
3905
6794
  markerTurns: new Set(),
3906
6795
  surfaceReady: false,
3907
6796
  surfaceThrough: -1,
6797
+ // The host reports a running turn (or a compaction) so the dialog can
6798
+ // say that a delete will stop the generation first.
6799
+ busy: false,
3908
6800
  loaded: false,
3909
6801
  loadError: false,
3910
6802
  dialog: null,
3911
6803
  pending: false,
3912
6804
  failure: null,
6805
+ // How many unrelated nodes the last attempt reported it would also
6806
+ // remove (the forced-confirmation prompt).
6807
+ foreignCount: 0,
3913
6808
  revision: 0,
3914
6809
  })
3915
6810
  }
@@ -3979,7 +6874,7 @@ window.__ModuleLoader__.load({
3979
6874
  if (typeof turn === 'number') markerTurns.add(turn)
3980
6875
  }
3981
6876
  const surfaceThrough = typeof data.lastSeq === 'number' ? data.lastSeq : -1
3982
- this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
6877
+ this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, busy: data.busy === true, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
3983
6878
  })
3984
6879
  .catch(() => {
3985
6880
  this.publish({ loadError: true })
@@ -3998,11 +6893,14 @@ window.__ModuleLoader__.load({
3998
6893
 
3999
6894
  close() {
4000
6895
  if (this.view.pending) return
4001
- this.publish({ dialog: null, failure: null })
6896
+ this.publish({ dialog: null, failure: null, foreignCount: 0 })
4002
6897
  }
4003
6898
 
4004
- async confirm() {
4005
- await this.perform(this.view.dialog)
6899
+ // `force` re-submits after the "the window also swallows N unrelated
6900
+ // nodes" report, which is the only refusal the dialog turns into a second
6901
+ // question instead of a dead end.
6902
+ async confirm(options) {
6903
+ await this.perform(this.view.dialog, { force: options && options.force === true })
4006
6904
  }
4007
6905
 
4008
6906
  // The entry every action uses. With "ask" on, it opens the confirmation
@@ -4019,9 +6917,10 @@ window.__ModuleLoader__.load({
4019
6917
  if (result.failure !== null) this.publish({ dialog: target, pending: false, failure: result.failure })
4020
6918
  }
4021
6919
 
4022
- async perform(requested) {
6920
+ async perform(requested, options) {
4023
6921
  if (requested === null || this.view.pending) return { failure: null }
4024
- this.publish({ pending: true, failure: null })
6922
+ const force = options !== undefined && options.force === true
6923
+ this.publish({ pending: true, failure: null, foreignCount: 0 })
4025
6924
  try {
4026
6925
  // The live node can move while the dialog sits open: another producer
4027
6926
  // (dsh-edit-turn) rewrites the message in place, and the replacement
@@ -4030,17 +6929,18 @@ window.__ModuleLoader__.load({
4030
6929
  // first attempt reports content that has already left the context.
4031
6930
  await this.load(true)
4032
6931
  let target = this.resolveTarget(requested)
4033
- let result = await this.deleteOnce(target)
6932
+ let result = await this.deleteOnce(target, force)
4034
6933
  if (!result.ok && result.code === 'already-deleted') {
4035
6934
  await this.load(true)
4036
6935
  const next = this.resolveTarget(target)
4037
6936
  if (next.seq !== target.seq) {
4038
6937
  target = next
4039
- result = await this.deleteOnce(next)
6938
+ result = await this.deleteOnce(next, force)
4040
6939
  }
4041
6940
  }
4042
6941
  if (!result.ok) {
4043
- this.publish({ pending: false, failure: result.code })
6942
+ const foreignCount = result.details && typeof result.details.foreign === 'number' ? result.details.foreign : 0
6943
+ this.publish({ pending: false, failure: result.code, foreignCount })
4044
6944
  return { failure: result.code }
4045
6945
  }
4046
6946
  const data = result.data
@@ -4068,15 +6968,19 @@ window.__ModuleLoader__.load({
4068
6968
  return seq === target.seq ? target : { ...target, seq }
4069
6969
  }
4070
6970
 
4071
- async deleteOnce(target) {
6971
+ async deleteOnce(target, force) {
4072
6972
  const res = await fetch(`${ROUTE_PREFIX}/delete`, {
4073
6973
  method: 'POST',
4074
6974
  headers: { 'content-type': 'application/json' },
4075
- body: JSON.stringify({ sessionId: this.sessionId, ...target }),
6975
+ body: JSON.stringify({ sessionId: this.sessionId, ...target, ...(force === true ? { force: true } : {}) }),
4076
6976
  })
4077
6977
  const data = await res.json().catch(() => ({}))
4078
6978
  if (res.ok && data.ok) return { ok: true, data }
4079
- return { ok: false, code: data && data.code ? String(data.code) : 'generic' }
6979
+ return {
6980
+ ok: false,
6981
+ code: data && data.code ? String(data.code) : 'generic',
6982
+ details: data && data.details ? data.details : undefined,
6983
+ }
4080
6984
  }
4081
6985
 
4082
6986
  dispose() {
@@ -4511,11 +7415,38 @@ window.__ModuleLoader__.load({
4511
7415
  return Boolean(closing && closing.finalNode && closing.finalNode.messageId !== undefined)
4512
7416
  }
4513
7417
 
7418
+ // Node kinds that carry visible conversation content, and the turn-level
7419
+ // disclosure rows that must go with them. A failure card ("处理失败"), a
7420
+ // process disclosure ("已思考", "用时 N 秒"), a retry notice or a turn tail is
7421
+ // drawn from the TURN, not from a surface node, so shadowing the nodes a
7422
+ // delete aimed at leaves it behind - a deleted failed turn keeps an orphaned
7423
+ // card. A turn is gone once none of its content rows is visible, and its
7424
+ // disclosure rows leave with it.
7425
+ const CONTENT_KINDS = new Set(['user', 'context', 'steering', 'tool-call', 'assistant-step'])
7426
+ const DISCLOSURE_KINDS = new Set(['turn-error', 'model-retry', 'turn-tail', 'turn-process'])
7427
+
4514
7428
  function applyDom(snapshot, view, controller, t) {
4515
7429
  if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
4516
7430
  const animate = controller.consumeAnimate()
4517
7431
  const rows = document.querySelectorAll('[data-chat-flow-key]')
4518
7432
  let maxSeq = -1
7433
+
7434
+ // Turns that still have a visible content row. Computed from the ledger
7435
+ // (not from DOM heights) so it is stable across the pass.
7436
+ const contentTurns = new Set()
7437
+ for (const row of rows) {
7438
+ const key = row.getAttribute('data-chat-flow-key')
7439
+ if (!key) continue
7440
+ const node = snapshot.nodes.get(key)
7441
+ if (!node || !CONTENT_KINDS.has(node.kind)) continue
7442
+ const turn = row.getAttribute('data-chat-turn')
7443
+ if (turn === null || turn === '') continue
7444
+ if (row.dataset.dshdtHidden === '1' || foreignHideOn(row, 'dshdt')) continue
7445
+ const seqs = expandSeqs(seqsFor(node), view.edits)
7446
+ if (isRowHidden(view.hidden, view.hiddenVia, seqs)) continue
7447
+ contentTurns.add(turn)
7448
+ }
7449
+
4519
7450
  for (const row of rows) {
4520
7451
  if (!(row instanceof HTMLElement)) continue
4521
7452
  const key = row.getAttribute('data-chat-flow-key')
@@ -4545,7 +7476,10 @@ window.__ModuleLoader__.load({
4545
7476
  }
4546
7477
  const seqs = expandSeqs(seqsFor(node), view.edits)
4547
7478
  for (const seq of seqs) if (typeof seq === 'number' && seq > maxSeq) maxSeq = seq
4548
- const hidden = isRowHidden(view.hidden, view.hiddenVia, seqs)
7479
+ // A disclosure row follows its turn: with no visible content row left,
7480
+ // it is an orphan and leaves too.
7481
+ const orphaned = DISCLOSURE_KINDS.has(node.kind) && turn !== null && turn !== '' && !contentTurns.has(turn)
7482
+ const hidden = orphaned || isRowHidden(view.hidden, view.hiddenVia, seqs)
4549
7483
  setRowHidden(row, hidden, animate)
4550
7484
  const target = hidden ? null : liveTarget(targetFor(node), view.edits)
4551
7485
  const covered = target !== null || slotCoversRow(node)
@@ -4579,41 +7513,66 @@ window.__ModuleLoader__.load({
4579
7513
  updateTurnNavigation()
4580
7514
  }
4581
7515
 
4582
- // The host's turn-navigation rail lists loaded turns by number. A turn
4583
- // whose rows are all gone (deleted by us, or collapsed by the host itself)
4584
- // must not keep a jump mark, or an emptied transcript still shows a rail of
4585
- // entries pointing at nothing. Turns without any loaded row are left alone:
4586
- // an unmounted window is not an empty one.
4587
- function updateTurnNavigation() {
4588
- const marks = document.querySelectorAll('nav button[data-index]')
4589
- if (marks.length === 0) return
7516
+ /** The turn a rail mark addresses: its label carries the real turn number. */
7517
+ function turnOfRailMark(mark) {
7518
+ const label = mark.getAttribute('aria-label') || mark.getAttribute('title') || ''
7519
+ const match = /(\d+)/.exec(label)
7520
+ return match === null ? null : Number(match[1])
7521
+ }
7522
+
7523
+ /**
7524
+ * Every turn with a row in the DOM, mapped to whether any row is visible. A
7525
+ * turn with no row at all is absent: not empty, just not loaded.
7526
+ */
7527
+ function turnRowVisibility() {
4590
7528
  const seen = new Map()
4591
7529
  for (const row of document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')) {
4592
- const turn = row.getAttribute('data-chat-turn')
4593
- if (turn === null || turn === '') continue
4594
- // A row another component declared hidden is not visible content either.
4595
- const visible =
4596
- row.dataset.dshdtHidden !== '1' && !foreignHideOn(row, 'dshdt') && row.getBoundingClientRect().height > 0
4597
- if (seen.get(turn) !== true) seen.set(turn, visible)
4598
- }
4599
- let visibleMarks = 0
4600
- for (const mark of marks) {
4601
- const label = mark.getAttribute('aria-label') || ''
4602
- const turn = (label.match(/\d+/) || [])[0]
4603
- const state = turn === undefined ? undefined : seen.get(turn)
4604
- const hasContent = state === undefined ? true : state
4605
- if (hasContent) {
7530
+ const turn = Number(row.getAttribute('data-chat-turn'))
7531
+ if (!Number.isInteger(turn)) continue
7532
+ const hidden =
7533
+ row.dataset.dshdtHidden === '1' ||
7534
+ foreignHideOn(row, 'dshdt') ||
7535
+ (row.style !== undefined && row.style !== null && row.style.display === 'none') ||
7536
+ (typeof row.getBoundingClientRect === 'function' && row.getBoundingClientRect().height === 0)
7537
+ if (seen.get(turn) !== true) seen.set(turn, hidden !== true)
7538
+ }
7539
+ return seen
7540
+ }
7541
+
7542
+ /**
7543
+ * The official rail keeps one mark per turn of the session, including the
7544
+ * turns whose rows are gone, so deleting a turn left a mark pointing at
7545
+ * nothing and an empty slot. Drop the marks of turns with no visible row and
7546
+ * re-space the rest inside the host's own ladder, centred; a turn that is
7547
+ * merely not loaded keeps its mark. The container keeps the host's height:
7548
+ * that height is what the host's virtualisation measures, so shrinking it
7549
+ * unmounts the marks past the new window.
7550
+ */
7551
+ function updateTurnNavigation() {
7552
+ const rows = turnRowVisibility()
7553
+ const hiddenTurns = []
7554
+ for (const [turn, visible] of rows) if (visible !== true) hiddenTurns.push(turn)
7555
+ for (const nav of document.querySelectorAll('nav')) {
7556
+ const marks = [...nav.querySelectorAll('button[data-index]')]
7557
+ if (marks.length === 0) continue
7558
+ const container = marks[0].parentElement
7559
+ if (container === null || container === undefined) continue
7560
+ const observed = Math.round((Number.parseFloat(container.style.height) - 2) / 10)
7561
+ const total = Math.max(Number.isFinite(observed) && observed > 0 ? observed : 0, marks.length)
7562
+ const kept = total - hiddenTurns.filter((turn) => turn <= total).length
7563
+ const offset = kept > 0 && kept < total ? Math.floor((total - kept) / 2) : 0
7564
+ for (const mark of marks) {
7565
+ const turn = turnOfRailMark(mark)
7566
+ if (turn === null) continue
7567
+ if (hiddenTurns.includes(turn)) {
7568
+ mark.dataset.dshdtHidden = '1'
7569
+ continue
7570
+ }
4606
7571
  delete mark.dataset.dshdtHidden
4607
- visibleMarks += 1
4608
- } else {
4609
- mark.dataset.dshdtHidden = '1'
7572
+ const before = hiddenTurns.filter((hidden) => hidden < turn).length
7573
+ mark.style.transform = `translate3d(0px, ${(turn - 1 - before + offset) * 10 + 1}px, 0px)`
4610
7574
  }
4611
7575
  }
4612
- const nav = marks[0].closest('nav')
4613
- if (nav !== null) {
4614
- if (visibleMarks === 0) nav.dataset.dshdtHidden = '1'
4615
- else delete nav.dataset.dshdtHidden
4616
- }
4617
7576
  }
4618
7577
 
4619
7578
  // --- react entries --------------------------------------------------------
@@ -4636,6 +7595,10 @@ window.__ModuleLoader__.load({
4636
7595
  const target = view.dialog
4637
7596
  const mode = target && typeof target.mode === 'string' ? target.mode : 'message'
4638
7597
  const failure = view.failure
7598
+ // The one refusal that is a question rather than a dead end: the window
7599
+ // also swallows N unrelated nodes. The button becomes an explicit
7600
+ // "delete them too", and confirming re-submits with `force`.
7601
+ const forced = failure === 'range-not-clean'
4639
7602
  return jsx(primitives.Modal, {
4640
7603
  open: target !== null,
4641
7604
  title: t(`dialog.title.${mode}`),
@@ -4655,8 +7618,14 @@ window.__ModuleLoader__.load({
4655
7618
  size: 'md',
4656
7619
  className: 'dshdt-danger',
4657
7620
  disabled: view.pending,
4658
- onClick: () => controller.confirm(),
4659
- children: view.pending ? t('dialog.pending') : failure ? t('dialog.retry') : t('dialog.confirm'),
7621
+ onClick: () => controller.confirm(forced ? { force: true } : undefined),
7622
+ children: view.pending
7623
+ ? t('dialog.pending')
7624
+ : forced
7625
+ ? t('dialog.confirmExtra')
7626
+ : failure
7627
+ ? t('dialog.retry')
7628
+ : t('dialog.confirm'),
4660
7629
  }),
4661
7630
  ],
4662
7631
  }),
@@ -4664,7 +7633,14 @@ window.__ModuleLoader__.load({
4664
7633
  children: [
4665
7634
  jsx('p', { className: 'dshdt-dialog-text', children: t(`dialog.desc.${mode}`) }),
4666
7635
  jsx('p', { className: 'dshdt-dialog-note', children: t('dialog.note') }),
4667
- failure === null
7636
+ view.busy ? jsx('p', { className: 'dshdt-dialog-warn', children: t('dialog.busyWarning') }) : null,
7637
+ forced
7638
+ ? jsx('p', {
7639
+ className: 'dshdt-dialog-warn',
7640
+ children: t('dialog.extraWarning') + (view.foreignCount > 0 ? ' ×' + String(view.foreignCount) : ''),
7641
+ })
7642
+ : null,
7643
+ failure === null || forced
4668
7644
  ? null
4669
7645
  : jsx('p', { className: 'dshdt-dialog-error', role: 'status', children: t(`error.${failure}`) }),
4670
7646
  ],
@@ -4805,6 +7781,10 @@ window.__ModuleLoader__.load({
4805
7781
  }
4806
7782
 
4807
7783
  exports.apply = apply
7784
+ // The settings card, exported so a composing bundle (dsh-as-aistudio) can
7785
+ // render THE SAME component on its own page instead of re-implementing it:
7786
+ // one source of truth for 删除 settings.
7787
+ exports.ConfigCard = AskBeforeDeleteOption
4808
7788
  exports.inject = ['slots', 'locale']
4809
7789
  return module.exports
4810
7790
  }
@@ -4822,7 +7802,7 @@ window.__ModuleLoader__.load({
4822
7802
  const NS = 'dsh-markdown-bubble'
4823
7803
 
4824
7804
  /** Keep in sync with package.json and src/index.js. */
4825
- const PLUGIN_VERSION = '0.1.4'
7805
+ const PLUGIN_VERSION = '0.1.5'
4826
7806
 
4827
7807
  /** Presence marker the live verifier reads. */
4828
7808
  const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
@@ -4962,7 +7942,27 @@ window.__ModuleLoader__.load({
4962
7942
  * @param text - raw logged message text.
4963
7943
  * @returns markdown source for the renderer.
4964
7944
  */
7945
+ // Prepared markdown source, keyed by the raw text. A bubble repaints on
7946
+ // every transcript update (a streaming turn re-renders constantly) and the
7947
+ // preparation is a split/join plus a regex per line, so the same message was
7948
+ // re-walked dozens of times while an unrelated row streamed. Cleared whole
7949
+ // when it grows past the cap: the entries are strings, and a long transcript
7950
+ // has far fewer distinct message texts than it has repaints.
7951
+ const markdownSourceCache = new Map()
7952
+ const MARKDOWN_CACHE_LIMIT = 64
7953
+
4965
7954
  function toMarkdown(text) {
7955
+ const key = typeof text === 'string' ? text : ''
7956
+ const cached = markdownSourceCache.get(key)
7957
+ if (cached !== undefined) return cached
7958
+ const source = prepareMarkdown(key)
7959
+ if (markdownSourceCache.size >= MARKDOWN_CACHE_LIMIT) markdownSourceCache.clear()
7960
+ markdownSourceCache.set(key, source)
7961
+ return source
7962
+ }
7963
+
7964
+ /** The uncached preparation: CRLF, session wire links, soft breaks. */
7965
+ function prepareMarkdown(text) {
4966
7966
  const normalized = text.replace(/\r\n?/gu, '\n')
4967
7967
  return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
4968
7968
  }
@@ -5040,12 +8040,24 @@ window.__ModuleLoader__.load({
5040
8040
  * @param text - the message text.
5041
8041
  * @param referenceLabels - session recall labels.
5042
8042
  * @param skillNames - skill names.
5043
- * @param references - file/skill open callbacks.
8043
+ * @param renderFactorySlot - the seat's Factory binding (chips + plain runs).
5044
8044
  * @returns the degraded body, never null and never a throw.
5045
8045
  */
5046
- function degradedBody(text, referenceLabels, skillNames, references) {
8046
+ function degradedBody(text, referenceLabels, skillNames, renderFactorySlot) {
5047
8047
  try {
5048
- return primitives.projectUserText(text, referenceLabels, skillNames, 'skill', references)
8048
+ if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
8049
+ // The projection moved into the host's own Factory in 0.2.1-rc.2
8050
+ // (`conversation.user.text`, registered by dsh-client-ui-conversation;
8051
+ // it owns the keyed child slot that renders a reference chip). The old
8052
+ // public primitive `primitives.projectUserText` was REMOVED in the same
8053
+ // release, which is why this call used to throw and chips degraded to
8054
+ // plain text.
8055
+ return renderFactorySlot('conversation.user.text', {
8056
+ text,
8057
+ facts: { sessionLabels: referenceLabels, skillNames },
8058
+ interactive: true,
8059
+ ellipsis: false,
8060
+ })
5049
8061
  } catch (error) {
5050
8062
  warnOnce('the host user-text projection failed; showing raw text', error)
5051
8063
  return h('span', { className: 'dshmb-plain' }, text)
@@ -5069,13 +8081,12 @@ window.__ModuleLoader__.load({
5069
8081
  * @param t - the injected chat translator.
5070
8082
  * @param referenceLabels - session recall labels.
5071
8083
  * @param skillNames - skill names.
5072
- * @param references - file/skill open callbacks.
8084
+ * @param renderFactorySlot - the seat's Factory binding.
5073
8085
  * @returns the composed body, or null when markdown cannot be produced.
5074
8086
  */
5075
- function safeMarkdownBody(text, t, referenceLabels, skillNames, references) {
8087
+ function safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels) {
5076
8088
  try {
5077
- const labels = buildMarkdownLabels(t)
5078
- return composeUserMarkdown(text, labels, referenceLabels, skillNames, references)
8089
+ return composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot)
5079
8090
  } catch (error) {
5080
8091
  warnOnce('markdown composition failed; showing the degraded projection', error)
5081
8092
  return null
@@ -5114,14 +8125,14 @@ window.__ModuleLoader__.load({
5114
8125
  * @param labels - markdown chrome labels.
5115
8126
  * @param referenceLabels - session recall labels the host associates with the message.
5116
8127
  * @param skillNames - skill names the host loaded for the message.
5117
- * @param references - file/skill open callbacks for clickable chips.
8128
+ * @param renderFactorySlot - the seat's Factory binding.
5118
8129
  * @returns the composed body, or null for empty text.
5119
8130
  */
5120
- function composeUserMarkdown(text, labels, referenceLabels, skillNames, references) {
8131
+ function composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot) {
5121
8132
  if (typeof text !== 'string' || text === '') return null
5122
8133
  // The same single degraded projection, so a projection that throws inside
5123
8134
  // composition degrades exactly as one that throws on the healthy path.
5124
- const projected = degradedBody(text, referenceLabels, skillNames, references)
8135
+ const projected = degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
5125
8136
  const pieces =
5126
8137
  react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
5127
8138
  ? projected.props.children
@@ -5298,7 +8309,12 @@ window.__ModuleLoader__.load({
5298
8309
  }
5299
8310
 
5300
8311
  render() {
5301
- return this.state.failed ? this.props.fallback : this.props.children
8312
+ if (!this.state.failed) return this.props.children
8313
+ const { fallback } = this.props
8314
+ // A thunk is called only when it is actually needed: building the
8315
+ // degraded projection on every healthy render is wasted work (and, on a
8316
+ // host whose projection is gone, a caught throw every time).
8317
+ return typeof fallback === 'function' ? fallback() : fallback
5302
8318
  }
5303
8319
  }
5304
8320
 
@@ -5388,9 +8404,24 @@ window.__ModuleLoader__.load({
5388
8404
  }
5389
8405
  }
5390
8406
 
5391
- function renderAttachmentEntry({ attachment, renderMessageImages, compactImages }) {
8407
+ function renderAttachmentEntry({ attachment, renderFactorySlot, loadImage, compactImages }) {
5392
8408
  if (attachment.type === 'image') {
5393
- return h(Fragment, null, renderMessageImages({ images: [attachment.image], align: 'end', compact: compactImages }))
8409
+ // The host's image gallery, through the SAME Factory seam as the text
8410
+ // projection. The three routes that look obvious are all closed on
8411
+ // 0.2.1-rc.2: the `renderMessageImages` seat prop is gone, the child
8412
+ // slot `conversation.chat.user.images` cannot be declared by a plugin
8413
+ // ("already declared (by factory conversation.chat.user.content)",
8414
+ // verified live), and `@deepseek-ai/dsh-client-ui-attachment` exports
8415
+ // only apply/inject. The gallery Factory is the one that works: the
8416
+ // attachment plugin registers `conversation.images.content` at root
8417
+ // scope and its own `MessageImages` is nothing but a call to it.
8418
+ if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
8419
+ return h(Fragment, null, renderFactorySlot('conversation.images.content', {
8420
+ images: [attachment.image],
8421
+ loadImage,
8422
+ align: 'end',
8423
+ compact: compactImages,
8424
+ }))
5394
8425
  }
5395
8426
  const file = attachment.file ?? {}
5396
8427
  const name = typeof file.name === 'string' ? file.name : ''
@@ -5441,23 +8472,31 @@ window.__ModuleLoader__.load({
5441
8472
  function renderMarkdownBubble(props) {
5442
8473
  const safeProps = props !== null && typeof props === 'object' ? props : {}
5443
8474
  const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
5444
- const renderMessageImages = safeProps.renderMessageImages
5445
- const openFile = safeProps.openFile
5446
- const openSkill = safeProps.openSkill
8475
+ // The seat's props on 0.2.1-rc.2: the two renderer bindings and the image
8476
+ // loader. The old `renderMessageImages` / `openFile` / `openSkill` pair is
8477
+ // gone; images now go through the declared child slot and reference chips
8478
+ // through the `conversation.user.text` Factory.
8479
+ const renderFactorySlot = safeProps.renderFactorySlot
8480
+ const renderSlot = safeProps.renderSlot
8481
+ const loadImage = safeProps.loadImage
5447
8482
  const t = safeProps.t
5448
8483
  const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
5449
8484
  const { text, attachments, rest } = contentParts(Array.isArray(data.content) ? data.content : [])
5450
8485
  const referenceLabels = Array.isArray(data.referenceLabels) ? data.referenceLabels : []
5451
8486
  const skillNames = Array.isArray(data.skillNames) ? data.skillNames : []
5452
- const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
5453
8487
  const compactImages = attachments.length > 1
5454
8488
  const showBubble = text !== '' || rest.length > 0
8489
+ // The host memoizes its own label object before handing it to MarkdownText
8490
+ // (`ReasoningContent` does exactly this); a fresh object every render
8491
+ // defeats that memoization and re-renders the whole markdown tree.
8492
+ const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
5455
8493
 
5456
8494
  // Never let composition throw out of this render — see safeMarkdownBody.
5457
- // One degraded body per render, shared by the composition guard and the
5458
- // boundary's own fallback; the last-resort net calls it too.
5459
- const body = safeMarkdownBody(text, t, referenceLabels, skillNames, references)
5460
- const degraded = degradedBody(text, referenceLabels, skillNames, references)
8495
+ // ONE degraded body per render: it is the composition guard's projection
8496
+ // and the boundary's fallback, so building it eagerly (as this used to)
8497
+ // ran the projection twice per render and, on 0.2.1-rc.2, threw twice.
8498
+ const body = safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels)
8499
+ const degraded = () => degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
5461
8500
 
5462
8501
  // The action strip leads the row in DOM order and is pushed below the
5463
8502
  // bubble by flex `order`. Sibling plugins pick their bar with
@@ -5481,7 +8520,8 @@ window.__ModuleLoader__.load({
5481
8520
  h(AttachmentEntry, {
5482
8521
  key: `${attachment.type}:${index}`,
5483
8522
  attachment,
5484
- renderMessageImages,
8523
+ renderFactorySlot,
8524
+ loadImage,
5485
8525
  compactImages,
5486
8526
  }),
5487
8527
  ),
@@ -5490,7 +8530,7 @@ window.__ModuleLoader__.load({
5490
8530
  h(
5491
8531
  'div',
5492
8532
  { className: 'dshmb-bubble' },
5493
- h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded : body),
8533
+ h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded() : body),
5494
8534
  rest.map((block, index) =>
5495
8535
  h(primitives.JsonBlock, {
5496
8536
  key: 'rest' + index,
@@ -5535,7 +8575,7 @@ window.__ModuleLoader__.load({
5535
8575
  text,
5536
8576
  Array.isArray(data.referenceLabels) ? data.referenceLabels : [],
5537
8577
  Array.isArray(data.skillNames) ? data.skillNames : [],
5538
- { openFile: safeProps.openFile, openSkill: safeProps.openSkill },
8578
+ safeProps.renderFactorySlot,
5539
8579
  )
5540
8580
  return h(
5541
8581
  'div',
@@ -5692,7 +8732,7 @@ window.__ModuleLoader__.load({
5692
8732
  const NS = 'dsh-as-aistudio'
5693
8733
 
5694
8734
  /** Keep in sync with package.json and src/components.js. */
5695
- const PLUGIN_VERSION = '0.2.13'
8735
+ const PLUGIN_VERSION = '0.3.0'
5696
8736
 
5697
8737
  /** Presence marker the live verifier reads. */
5698
8738
  const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
@@ -5706,6 +8746,20 @@ window.__ModuleLoader__.load({
5706
8746
  /** The four vendored factory names, in mount order. */
5707
8747
  const VENDORED = ['dsh-edit-turn', 'dsh-rerun-turn', 'dsh-delete-turn', 'dsh-markdown-bubble']
5708
8748
 
8749
+ /**
8750
+ * Which components export a settings card, and under what name.
8751
+ *
8752
+ * The components own their settings; this package only has to show them. A
8753
+ * component exports its card (`exports.ConfigCard`) and the studio renders
8754
+ * THAT component - never a copy - so the two surfaces cannot drift. A
8755
+ * component with no settings simply is not in this table.
8756
+ */
8757
+ const CONFIG_CARDS = [
8758
+ { id: 'dsh-edit-turn', title: { zh: '编辑与重跑', en: 'Edit and re-run' } },
8759
+ { id: 'dsh-rerun-turn', title: { zh: '重跑', en: 'Re-run' } },
8760
+ { id: 'dsh-delete-turn', title: { zh: '删除', en: 'Delete' } },
8761
+ ]
8762
+
5709
8763
  const h = react.createElement
5710
8764
 
5711
8765
  // --- copy ------------------------------------------------------------------
@@ -5733,6 +8787,8 @@ window.__ModuleLoader__.load({
5733
8787
  host: '宿主 DSH {version}',
5734
8788
  hostUnknown: '宿主版本未知',
5735
8789
  note: '四个组件由这个包的四个补丁行挂载(dsh-as-aistudio/edit 等),插件列表里仍然只有 AI Studio 一行;它们仍是独立 npm 包,单独安装时自己出现一行。宿主未挂载的组件不会画到界面上。',
8790
+ summaryShort: '改词重跑 · 原位重跑 · 删这一轮 · 提示词渲染 Markdown',
8791
+ settingsHeading: '组件设置',
5736
8792
  }
5737
8793
 
5738
8794
  const en = {
@@ -5758,6 +8814,8 @@ window.__ModuleLoader__.load({
5758
8814
  host: 'host DSH {version}',
5759
8815
  hostUnknown: 'host version unknown',
5760
8816
  note: 'The four components are mounted by the four patch rows of this package (dsh-as-aistudio/edit and friends), so the plugin list still shows only AI Studio; they remain independent npm packages that show their own row when installed standalone. A component whose host half is not mounted is not drawn at all.',
8817
+ summaryShort: 'revise and re-run, re-run in place, delete the turn, render prompts as Markdown',
8818
+ settingsHeading: 'Component settings',
5761
8819
  }
5762
8820
 
5763
8821
  const COMPONENT_COPY = {
@@ -5829,7 +8887,11 @@ window.__ModuleLoader__.load({
5829
8887
  '.dsas-error{color:var(--dsw-alias-state-danger-primary,#d94040);font-size:var(--dsh-content-font-size-secondary,13px);margin:0}',
5830
8888
  '.dsas-interop{border-top:.5px solid var(--dsw-alias-border-l2,#0000001f);flex-direction:column;gap:6px;display:flex;padding-top:12px}',
5831
8889
  '.dsas-interopTitle{color:var(--dsw-alias-label-primary);font-size:var(--dsh-content-font-size-secondary,13px);font-weight:600;margin:0}',
5832
- '.dsas-note{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.7;margin:0}',
8890
+ '.dsas-settings{display:flex;flex-direction:column;gap:10px;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--dsas-line,rgba(128,128,128,.25))}',
8891
+ '.dsas-settingsTitle{margin:0;font-size:13px;font-weight:600;line-height:20px}',
8892
+ '.dsas-settingCard{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--dsas-line,rgba(128,128,128,.25));border-radius:10px}',
8893
+ '.dsas-settingName{margin:0;font-size:12.5px;font-weight:600;line-height:18px;opacity:.75}',
8894
+ '.dsas-note{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.7;margin:0}',
5833
8895
  ].join('')
5834
8896
 
5835
8897
  function injectStyles() {
@@ -5872,6 +8934,30 @@ window.__ModuleLoader__.load({
5872
8934
  return typeof factory === 'function' ? factory : null
5873
8935
  }
5874
8936
 
8937
+ /**
8938
+ * The settings card a vendored component exports, or null.
8939
+ *
8940
+ * Read from the component's own module namespace: the factory runs once per
8941
+ * read here (it is cheap - it only builds the module object), and `ConfigCard`
8942
+ * is the component's own component function, so its state, storage keys and
8943
+ * copy are the ones its own page uses.
8944
+ *
8945
+ * @param id - component id.
8946
+ * @returns the component function, or null when it exports none.
8947
+ */
8948
+ function vendoredConfigCard(id) {
8949
+ try {
8950
+ const factory = vendoredFactory(id)
8951
+ if (factory === null) return null
8952
+ const plugin = factory(require)
8953
+ const card = plugin === null || plugin === undefined ? null : plugin.ConfigCard
8954
+ return typeof card === 'function' ? card : null
8955
+ } catch (error) {
8956
+ console.error('[' + NS + '] vendored ' + id + " 's settings card could not be read:", error)
8957
+ return null
8958
+ }
8959
+ }
8960
+
5875
8961
  function applyVendored(ctx, id) {
5876
8962
  try {
5877
8963
  const factory = vendoredFactory(id)
@@ -5996,7 +9082,12 @@ window.__ModuleLoader__.load({
5996
9082
  * The studio tab: one row per component, plus the interop allocation.
5997
9083
  * @param props - slot props: t (locale-bound) and currentLocale.
5998
9084
  */
5999
- function StudioPanel({ t, currentLocale }) {
9085
+ function StudioPanel({ view: slotView, t, currentLocale, bindComponentLocale }) {
9086
+ // `plugins.row.config` asks twice: once for the row's one-liner and once
9087
+ // for the page. The one-liner must not fetch - it renders on every plugin
9088
+ // row paint - so it is the static description of what the four components
9089
+ // do, and the page below carries the live state and the settings.
9090
+ if (slotView === 'summary') return h('span', null, t('summaryShort'))
6000
9091
  const [request, setRequest] = react.useState(0)
6001
9092
  const [view, setView] = react.useState({ status: 'loading' })
6002
9093
 
@@ -6047,6 +9138,15 @@ window.__ModuleLoader__.load({
6047
9138
  ? t('hostUnknown')
6048
9139
  : ''
6049
9140
  const rows = ready ? view.body.components.map((component) => renderRow(component, copy, t)) : []
9141
+ // The settings each component owns, rendered from the component itself.
9142
+ // Only for components the host reports as up: a card whose host half is off
9143
+ // would write preferences nothing reads.
9144
+ const mountedIds = new Set(
9145
+ ready ? view.body.components.filter((component) => stateOf(component) === 'mounted').map((component) => component.id) : [],
9146
+ )
9147
+ const cards = CONFIG_CARDS.filter((entry) => mountedIds.has(entry.id))
9148
+ .map((entry) => ({ entry, Card: vendoredConfigCard(entry.id) }))
9149
+ .filter((pair) => pair.Card !== null)
6050
9150
  return h('div', { className: 'dsas-root' }, [
6051
9151
  h('p', { className: 'dsas-intro', key: 'intro' }, t('intro')),
6052
9152
  h('div', { className: 'dsas-bar', key: 'bar' }, [
@@ -6068,6 +9168,22 @@ window.__ModuleLoader__.load({
6068
9168
  ? h('p', { className: 'dsas-error', key: 'error', role: 'status' }, fill(t('error'), { reason: view.reason }))
6069
9169
  : null,
6070
9170
  ready ? h('ul', { className: 'dsas-list', key: 'list' }, rows) : null,
9171
+ cards.length === 0
9172
+ ? null
9173
+ : h('div', { className: 'dsas-settings', key: 'settings' }, [
9174
+ h('p', { className: 'dsas-settingsTitle', key: 'title' }, t('settingsHeading')),
9175
+ ...cards.map((pair) =>
9176
+ h('div', { className: 'dsas-settingCard', key: pair.entry.id, 'data-component': pair.entry.id }, [
9177
+ h('p', { className: 'dsas-settingName', key: 'name' }, pair.entry.title[chinese ? 'zh' : 'en']),
9178
+ h(pair.Card, {
9179
+ key: 'card',
9180
+ view: 'page',
9181
+ // The card's own words come from the card's own namespace.
9182
+ t: typeof bindComponentLocale === 'function' ? bindComponentLocale(pair.entry.id) : t,
9183
+ }),
9184
+ ]),
9185
+ ),
9186
+ ]),
6071
9187
  h('p', { className: 'dsas-note', key: 'note' }, t('note')),
6072
9188
  renderInterop(t),
6073
9189
  ])
@@ -6134,6 +9250,24 @@ window.__ModuleLoader__.load({
6134
9250
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-as-aistudio: dictionaries')
6135
9251
 
6136
9252
  const t = ctx.locale.bind(NS)
9253
+ /**
9254
+ * The translator for a COMPONENT's own namespace.
9255
+ *
9256
+ * A component's settings card is written against its own dictionary
9257
+ * (`config.style`, `config.ask`, ...), which its own apply() registers under
9258
+ * its package name. Rendering that card with this package's `t` would spell
9259
+ * the keys out - the card is not ours, so neither are its words.
9260
+ *
9261
+ * @param ns - the component's locale namespace (its package name).
9262
+ * @returns a translator bound to it, or this package's own as a fallback.
9263
+ */
9264
+ const bindComponentLocale = (ns) => {
9265
+ try {
9266
+ return ctx.locale.bind(ns)
9267
+ } catch {
9268
+ return t
9269
+ }
9270
+ }
6137
9271
  const currentLocale = () => {
6138
9272
  try {
6139
9273
  const snapshot = ctx.locale.getLocale()
@@ -6145,15 +9279,18 @@ window.__ModuleLoader__.load({
6145
9279
  }
6146
9280
  }
6147
9281
 
6148
- ctx.slots.inject('settings.plugins.tab', () =>
9282
+ // The row's configure control, keyed the way the plugin manager dispatches
9283
+ // it: `<package name>#<row id>`. This package's own row is the bare package
9284
+ // name (see cordis.patch.yml), so the key is the package name twice, and the
9285
+ // configure arrow appears on OUR row - the same surface every other plugin
9286
+ // in this profile has, instead of a separate settings tab.
9287
+ ctx.slots.inject('plugins.row.config', () =>
6149
9288
  ctx.slots.register(
6150
9289
  {
6151
- name: 'settings.plugins.tab',
6152
- id: 'as-aistudio',
6153
- order: 100,
6154
- label: () => t('tab'),
9290
+ name: 'plugins.row.config',
9291
+ key: 'dsh-as-aistudio#dsh-as-aistudio',
6155
9292
  locale: NS,
6156
- inject: () => ({ currentLocale }),
9293
+ inject: () => ({ currentLocale, bindComponentLocale }),
6157
9294
  },
6158
9295
  StudioPanel,
6159
9296
  ),