dsh-edit-turn 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +432 -64
- package/lib/client.js +709 -98
- package/lib/index.js +247 -60
- package/package.json +6 -2
package/lib/client.js
CHANGED
|
@@ -30,27 +30,25 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
31
31
|
|
|
32
32
|
/** Keep in sync with package.json and lib/index.js. */
|
|
33
|
-
const PLUGIN_VERSION = '0.2.
|
|
33
|
+
const PLUGIN_VERSION = '0.2.4'
|
|
34
34
|
|
|
35
35
|
// --- copy -----------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
const zh = {
|
|
38
|
-
'action.edit': '
|
|
38
|
+
'action.edit': '编辑这条消息',
|
|
39
39
|
'action.edit-reply': '编辑这条回答',
|
|
40
40
|
'editor.title': '编辑这条消息',
|
|
41
41
|
'editor.title-reply': '编辑这条回答',
|
|
42
|
-
'editor.placeholder': '
|
|
42
|
+
'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
|
|
43
43
|
'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
|
|
44
|
-
'editor.save': '
|
|
45
|
-
'editor.save-
|
|
44
|
+
'editor.save': '保存',
|
|
45
|
+
'editor.save-rerun': '保存并重跑',
|
|
46
|
+
'editor.save-reply': '保存',
|
|
46
47
|
'editor.review': '确认执行',
|
|
47
48
|
'editor.cancel': '取消',
|
|
48
|
-
'editor.pending': '
|
|
49
|
+
'editor.pending': '正在保存…',
|
|
49
50
|
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
50
51
|
'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
|
|
51
|
-
'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
|
|
52
|
-
'editor.hint-reply': '保存后,这条回答会被替换为你写的内容,它之后的内容一并移除。模型会把你写的内容当成自己说过的话,对话可以继续下去。原始会话日志不会改写。',
|
|
53
|
-
'confirm.title': '确认执行?',
|
|
54
52
|
'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
|
|
55
53
|
'error.invalid': '请求无效,请刷新后重试。',
|
|
56
54
|
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
@@ -62,29 +60,27 @@ window.__ModuleLoader__.load({
|
|
|
62
60
|
'error.forbidden': '请求来源不被允许。',
|
|
63
61
|
'error.method': '请求方式不被接受,请刷新后重试。',
|
|
64
62
|
'error.generic': '编辑失败,请重试。',
|
|
65
|
-
'error.prompt': '
|
|
63
|
+
'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
|
|
66
64
|
'error.reply': '已回退,但替换没有落地。请重试。',
|
|
67
65
|
'error.empty': '改写后的内容不能为空。',
|
|
68
66
|
'error.internal': '服务器内部错误,请稍后重试。',
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
const en = {
|
|
72
|
-
'action.edit': 'Edit this message
|
|
70
|
+
'action.edit': 'Edit this message',
|
|
73
71
|
'action.edit-reply': 'Edit this reply',
|
|
74
72
|
'editor.title': 'Edit this message',
|
|
75
73
|
'editor.title-reply': 'Edit this reply',
|
|
76
74
|
'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
|
|
77
75
|
'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
|
|
78
|
-
'editor.save': 'Save
|
|
79
|
-
'editor.save-
|
|
76
|
+
'editor.save': 'Save',
|
|
77
|
+
'editor.save-rerun': 'Save and re-run',
|
|
78
|
+
'editor.save-reply': 'Save',
|
|
80
79
|
'editor.review': 'Confirm',
|
|
81
80
|
'editor.cancel': 'Cancel',
|
|
82
81
|
'editor.pending': 'Working...',
|
|
83
82
|
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
84
83
|
'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
|
|
85
|
-
'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
|
|
86
|
-
'editor.hint-reply': 'Saving replaces this reply with your text and removes everything after it. The model goes on treating your words as its own, so the conversation can continue from here. The original session log is never rewritten.',
|
|
87
|
-
'confirm.title': 'Confirm?',
|
|
88
84
|
'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
|
|
89
85
|
'error.invalid': 'Invalid request; refresh and try again.',
|
|
90
86
|
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
@@ -129,28 +125,77 @@ window.__ModuleLoader__.load({
|
|
|
129
125
|
const CSS = [
|
|
130
126
|
':root{--dshet-panel:rgba(255,255,255,.96);--dshet-field:rgba(244,247,252,.98);--dshet-chip:rgba(255,255,255,.82);--dshet-line:rgba(16,24,40,.16);--dshet-ink:#0f1524;--dshet-ink-dim:#4b5872;--dshet-hover:rgba(16,24,40,.07);--dshet-shadow:0 12px 32px rgba(9,18,40,.22);--dshet-accent:#4d6bfe;--dshet-on-accent:#fff}',
|
|
131
127
|
'body[data-ds-dark-theme]{--dshet-panel:rgba(22,28,44,.96);--dshet-field:rgba(14,19,33,.98);--dshet-chip:rgba(22,28,44,.82);--dshet-line:rgba(255,255,255,.18);--dshet-ink:#eef2f8;--dshet-ink-dim:#a6b1c6;--dshet-hover:rgba(255,255,255,.11);--dshet-shadow:0 12px 32px rgba(0,0,0,.5)}',
|
|
132
|
-
'
|
|
133
|
-
'
|
|
128
|
+
// The pencil sits among the platform's own action icons, so it takes the
|
|
129
|
+
// platform's icon colour: our own `--dshet-ink-dim` is a blue-grey and
|
|
130
|
+
// read as a different, "active" icon next to the neutral grey ones. The
|
|
131
|
+
// variable resolves on the action bar (and everywhere above it); the
|
|
132
|
+
// fallback is the value it resolves to in the default theme.
|
|
133
|
+
'.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
|
|
134
|
+
'.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dsw-alias-label-primary,var(--dshet-ink))}',
|
|
134
135
|
'.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
135
136
|
'.dshet-action:disabled{cursor:default;opacity:.4}',
|
|
136
137
|
'.dshet-action svg{width:15px;height:15px}',
|
|
137
|
-
|
|
138
|
+
// The reply pencil leads its strip. The host draws the copy button ahead of
|
|
139
|
+
// the slot this entry is rendered into, and the strip lays its children out
|
|
140
|
+
// in DOM order, so in DOM position the pencil can only ever come second -
|
|
141
|
+
// behind the copy button, which is where the user does not look for it.
|
|
142
|
+
// `order` reorders the flex line without touching the host's own tree.
|
|
143
|
+
'.dshet-reply-action{order:-1}',
|
|
144
|
+
// A prompt this plugin rewrote in place: the platform renders no bubble for
|
|
145
|
+
// a replacement message, so this one is ours. Styled from the plugin own
|
|
146
|
+
// namespaced variables, which follow the active theme without borrowing
|
|
147
|
+
// CSS-module hashes that change between builds.
|
|
148
|
+
'.dshet-revision{box-sizing:border-box;align-self:flex-end;width:fit-content;max-width:min(72ch,86%);margin:6px 0 6px auto;padding:9px 14px;border-radius:16px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;line-height:inherit;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}',
|
|
149
|
+
'.dshet-revision:empty{display:none}',
|
|
150
|
+
'.dshet-revision{position:relative}',
|
|
151
|
+
// Fallback only: a row with no action bar to keep is displayed away
|
|
152
|
+
// entirely, and then its pencil has nowhere but the bubble. It sits in a
|
|
153
|
+
// gutter the bubble reserves for it, never over the text - an absolutely
|
|
154
|
+
// positioned button inside the bubble used to land on the last line, which
|
|
155
|
+
// is how the rewritten prompt got covered up.
|
|
156
|
+
'.dshet-revision.dshet-revision-action{padding-inline-end:40px}',
|
|
157
|
+
'.dshet-revision .dshet-action-host{position:absolute;inset-inline-end:6px;top:6px;opacity:0;transition:opacity .12s ease}',
|
|
158
|
+
'.dshet-revision:hover .dshet-action-host,.dshet-revision:focus-within .dshet-action-host{opacity:1}',
|
|
159
|
+
// A sibling plugin can append its own button (dsh-delete-turn's bin) into
|
|
160
|
+
// the same bar at any time, which left this pencil one slot short of the
|
|
161
|
+
// right end: DOM insertion order can never promise that position, so flex
|
|
162
|
+
// order does. The bar is a flex container (`display:flex`) and every
|
|
163
|
+
// platform item in it is `order:0`; the gutter and floating placements
|
|
164
|
+
// ignore order, being absolutely positioned or in a block box.
|
|
165
|
+
'.dshet-action-host{display:inline-flex;align-items:center;justify-content:center;order:9}',
|
|
138
166
|
'.dshet-row{position:relative}',
|
|
139
167
|
'.dshet-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
|
|
140
168
|
'.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
|
|
141
169
|
'.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
|
|
145
|
-
'
|
|
170
|
+
// A row this plugin rolled back loses its message, not its action strip:
|
|
171
|
+
// the platform's time / copy / delete buttons (and every other plugin's)
|
|
172
|
+
// stay reachable, so only rows with nothing worth keeping go fully away.
|
|
173
|
+
'[data-dshet-hidden="1"]:not([data-dshet-keep-actions]){display:none!important}',
|
|
174
|
+
// One rounded container, the way the platform's own composer box is
|
|
175
|
+
// built: the input is transparent and borderless inside it, the actions
|
|
176
|
+
// sit at its bottom-right, and focus is shown on the container rather
|
|
177
|
+
// than on a second box inside it. The input grows with its text (the
|
|
178
|
+
// render/input paths call `fitEditorHeight`); `display:block` removes the
|
|
179
|
+
// inline-block baseline gap a textarea would otherwise leave.
|
|
180
|
+
// The editor lives OUTSIDE the message row, on a layer of its own pinned
|
|
181
|
+
// to the body: the host re-renders that row whenever it likes, and an
|
|
182
|
+
// input inside a subtree it rebuilds loses its drag selection, its IME
|
|
183
|
+
// composition and its clicks a piece at a time. The layer covers the
|
|
184
|
+
// viewport but only the box itself takes pointer events, and the box is
|
|
185
|
+
// positioned under its row by `placeEditor`.
|
|
186
|
+
'.dshet-layer{position:fixed;left:0;top:0;right:0;bottom:0;pointer-events:none;z-index:40}',
|
|
187
|
+
'.dshet-layer .dshet-editor{position:absolute;pointer-events:auto}',
|
|
188
|
+
'.dshet-editor{margin:0;border:1px solid var(--dshet-line);border-radius:16px;background:var(--dshet-panel);backdrop-filter:blur(18px) saturate(1.2);box-shadow:var(--dshet-shadow);color:var(--dshet-ink);transition:border-color .12s}',
|
|
189
|
+
'.dshet-editor:focus-within{border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
190
|
+
'.dshet-editor textarea{display:block;width:100%;min-height:47px;max-height:46vh;overflow-y:auto;resize:none;box-sizing:border-box;padding:11px 14px 0;border:0;background:transparent;color:inherit;font:inherit;font-size:15px;line-height:25px;outline:none}',
|
|
146
191
|
'.dshet-editor textarea::placeholder{color:var(--dshet-ink-dim)}',
|
|
147
|
-
'.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,var(--dshet-accent))}',
|
|
148
192
|
'.dshet-editor textarea:disabled{opacity:.65}',
|
|
149
|
-
'.dshet-note{margin:
|
|
150
|
-
'.dshet-
|
|
151
|
-
'.dshet-
|
|
152
|
-
'.dshet-
|
|
153
|
-
'.dshet-
|
|
193
|
+
'.dshet-note,.dshet-warn,.dshet-error{margin:4px 14px 0;font-size:12px;line-height:19px}',
|
|
194
|
+
'.dshet-note{color:var(--dshet-ink-dim)}',
|
|
195
|
+
'.dshet-warn{color:var(--dsw-alias-state-warning-primary,#a2650a)}',
|
|
196
|
+
'.dshet-error{color:var(--dsw-alias-state-error-primary,#c93a31)}',
|
|
197
|
+
'.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:4px 10px 10px}',
|
|
198
|
+
'.dshet-btn{padding:6px 16px;border:1px solid var(--dshet-line);border-radius:999px;background:var(--dshet-field);color:var(--dshet-ink);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
|
|
154
199
|
'.dshet-btn:hover:not(:disabled){background:var(--dshet-hover)}',
|
|
155
200
|
'.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
|
|
156
201
|
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
@@ -208,14 +253,25 @@ window.__ModuleLoader__.load({
|
|
|
208
253
|
this.animateOnce = false
|
|
209
254
|
this.rowCount = -1
|
|
210
255
|
this.editor = null
|
|
256
|
+
// The live textarea and whether it currently holds the user's focus.
|
|
257
|
+
// Focus is only ever taken on open, or handed back to a REBUILT box
|
|
258
|
+
// when the user was typing in the one that was just replaced - never
|
|
259
|
+
// taken from somewhere else. Rebuilding used to call `focus()`
|
|
260
|
+
// unconditionally, so any host re-render of the row pulled the caret
|
|
261
|
+
// out of whatever the user was typing in (the composer included).
|
|
211
262
|
this.editorElement = null
|
|
263
|
+
this.editorBox = null
|
|
264
|
+
this.editorRow = null
|
|
265
|
+
this.editorFocus = false
|
|
212
266
|
this.editorDraft = ''
|
|
213
267
|
this.confirming = false
|
|
214
268
|
this.noticeTimer = null
|
|
215
269
|
this.view = Object.freeze({
|
|
216
270
|
hidden: new Map(),
|
|
271
|
+
revisions: new Map(),
|
|
217
272
|
editable: new Map(),
|
|
218
273
|
replies: new Map(),
|
|
274
|
+
repliesByMessage: new Map(),
|
|
219
275
|
editing: null,
|
|
220
276
|
draft: '',
|
|
221
277
|
confirming: false,
|
|
@@ -228,6 +284,7 @@ window.__ModuleLoader__.load({
|
|
|
228
284
|
// Matches the host's default: one click applies. Only an explicit
|
|
229
285
|
// `confirm: true` in the profile turns the second step back on.
|
|
230
286
|
confirmStep: false,
|
|
287
|
+
rerun: false,
|
|
231
288
|
revision: 0,
|
|
232
289
|
})
|
|
233
290
|
}
|
|
@@ -259,7 +316,11 @@ window.__ModuleLoader__.load({
|
|
|
259
316
|
}
|
|
260
317
|
|
|
261
318
|
load(force) {
|
|
262
|
-
|
|
319
|
+
// A refresh asked for while one is already running has to land *after*
|
|
320
|
+
// it: handing back the in-flight response would answer with a snapshot
|
|
321
|
+
// the host may have taken before a save, wiping out the very state that
|
|
322
|
+
// save just published.
|
|
323
|
+
if (this.inflight !== null) return this.inflight.then(() => this.load(force))
|
|
263
324
|
if (this.view.loaded && force !== true) return Promise.resolve()
|
|
264
325
|
const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
|
|
265
326
|
const pending = fetch(url, { headers: { accept: 'application/json' } })
|
|
@@ -267,8 +328,14 @@ window.__ModuleLoader__.load({
|
|
|
267
328
|
const data = await res.json().catch(() => ({}))
|
|
268
329
|
if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
|
|
269
330
|
const hidden = new Map()
|
|
331
|
+
// seq -> the seq of the message that now stands in its place. A prompt
|
|
332
|
+
// this plugin rewrote travels as a replacement carrier, so its revised
|
|
333
|
+
// text has to be shown exactly where the old message was shown.
|
|
334
|
+
const revisions = new Map()
|
|
270
335
|
for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
|
|
271
|
-
if (item
|
|
336
|
+
if (!item || typeof item.seq !== 'number') continue
|
|
337
|
+
hidden.set(item.seq, item.turn)
|
|
338
|
+
if (typeof item.replacement === 'number') revisions.set(item.seq, item.replacement)
|
|
272
339
|
}
|
|
273
340
|
const editable = new Map()
|
|
274
341
|
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
@@ -278,17 +345,27 @@ window.__ModuleLoader__.load({
|
|
|
278
345
|
// rewritten rather than re-run, so the editor words the consequence
|
|
279
346
|
// differently.
|
|
280
347
|
const replies = new Map()
|
|
348
|
+
const repliesByMessage = new Map()
|
|
281
349
|
for (const reply of Array.isArray(data.replies) ? data.replies : []) {
|
|
282
|
-
if (reply
|
|
350
|
+
if (!reply || typeof reply.seq !== 'number') continue
|
|
351
|
+
replies.set(reply.seq, reply)
|
|
352
|
+
// The official assistant-actions strip hands us a messageId, so
|
|
353
|
+
// replies are indexed by it too.
|
|
354
|
+
if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
|
|
283
355
|
}
|
|
284
356
|
this.publish({
|
|
285
357
|
hidden,
|
|
358
|
+
revisions,
|
|
286
359
|
editable,
|
|
287
360
|
replies,
|
|
361
|
+
repliesByMessage,
|
|
288
362
|
surfaceReady: true,
|
|
289
363
|
loaded: true,
|
|
290
364
|
loadError: false,
|
|
291
365
|
confirmStep: !(data.config && data.config.confirm === false),
|
|
366
|
+
// Off by default: saving writes the revision into the context and waits
|
|
367
|
+
// for the next message rather than spending a model call.
|
|
368
|
+
rerun: Boolean(data.config && data.config.rerun === true),
|
|
292
369
|
})
|
|
293
370
|
})
|
|
294
371
|
.catch(() => {
|
|
@@ -302,16 +379,38 @@ window.__ModuleLoader__.load({
|
|
|
302
379
|
}
|
|
303
380
|
|
|
304
381
|
open(target) {
|
|
382
|
+
// Pressing the pencil again for the SAME message re-focuses the open
|
|
383
|
+
// box instead of starting over: resetting the draft to the original
|
|
384
|
+
// text silently threw away whatever had been typed.
|
|
385
|
+
const same = this.editor !== null && this.editor.seq === target.seq && this.editor.mode === target.mode
|
|
386
|
+
const previous = this.editorElement
|
|
305
387
|
this.load()
|
|
306
388
|
this.editor = target
|
|
307
|
-
this.editorDraft = target.text
|
|
389
|
+
if (!same) this.editorDraft = target.text
|
|
308
390
|
this.confirming = false
|
|
309
391
|
this.editorElement = null
|
|
310
|
-
|
|
392
|
+
// The intent to keep the user's caret in this box. It is set when the
|
|
393
|
+
// editor opens and cleared only when the user presses somewhere
|
|
394
|
+
// outside it - never by a blur: the host rebuilding the row (or a
|
|
395
|
+
// temporary disable while saving) removes or disables the node, and
|
|
396
|
+
// treating that as "the user left" dropped the caret for good.
|
|
397
|
+
this.editorFocus = true
|
|
398
|
+
if (same && previous !== null && typeof previous.focus === 'function') {
|
|
399
|
+
try {
|
|
400
|
+
previous.focus()
|
|
401
|
+
} catch {
|
|
402
|
+
/* focus is best-effort */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
this.publish({ editing: target, draft: this.editorDraft, confirming: false, failure: null })
|
|
311
406
|
}
|
|
312
407
|
|
|
313
408
|
close() {
|
|
314
409
|
if (this.view.pending) return
|
|
410
|
+
this.editorElement = null
|
|
411
|
+
this.editorBox = null
|
|
412
|
+
this.editorRow = null
|
|
413
|
+
this.editorFocus = false
|
|
315
414
|
this.editor = null
|
|
316
415
|
this.editorDraft = ''
|
|
317
416
|
this.confirming = false
|
|
@@ -367,12 +466,31 @@ window.__ModuleLoader__.load({
|
|
|
367
466
|
this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
|
|
368
467
|
return
|
|
369
468
|
}
|
|
469
|
+
const shadowed = Array.isArray(data.shadowed) ? data.shadowed : []
|
|
370
470
|
const hidden = new Map(this.view.hidden)
|
|
371
|
-
for (const seq of
|
|
471
|
+
for (const seq of shadowed) hidden.set(seq, target.turn)
|
|
472
|
+
// A rewritten prompt lands as the rollback's replacement carrier, so
|
|
473
|
+
// its revised text has to be shown exactly where the old message was
|
|
474
|
+
// shown - and it has to be shown right away, because the row is hidden
|
|
475
|
+
// in this very pass. A rewritten reply is appended as its own new
|
|
476
|
+
// reply instead, so it gets no standing-in entry here: that text
|
|
477
|
+
// arrives with the refresh below, on a row of its own.
|
|
478
|
+
const standsIn = typeof data.replacementSeq === 'number' && data.kind !== 'reply'
|
|
479
|
+
const revisions = new Map(this.view.revisions)
|
|
480
|
+
if (standsIn) for (const seq of shadowed) revisions.set(seq, data.replacementSeq)
|
|
372
481
|
const editable = new Map(this.view.editable)
|
|
373
482
|
for (const entry of editable.values()) {
|
|
374
483
|
if (hidden.has(entry.seq)) editable.delete(entry.seq)
|
|
375
484
|
}
|
|
485
|
+
if (standsIn) {
|
|
486
|
+
editable.set(data.replacementSeq, {
|
|
487
|
+
seq: data.replacementSeq,
|
|
488
|
+
turn: target.turn,
|
|
489
|
+
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
490
|
+
text,
|
|
491
|
+
attachments: typeof target.attachments === 'number' ? target.attachments : 0,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
376
494
|
// A reply edit shadows its own reply too, so it leaves the editable
|
|
377
495
|
// replies here and re-enters through the next /state refresh (the
|
|
378
496
|
// appended correction is itself an editable reply).
|
|
@@ -384,12 +502,16 @@ window.__ModuleLoader__.load({
|
|
|
384
502
|
this.editor = null
|
|
385
503
|
this.editorDraft = ''
|
|
386
504
|
this.editorElement = null
|
|
505
|
+
this.editorBox = null
|
|
506
|
+
this.editorRow = null
|
|
507
|
+
this.editorFocus = false
|
|
387
508
|
this.confirming = false
|
|
388
509
|
this.publish({
|
|
389
510
|
pending: false,
|
|
390
511
|
editing: null,
|
|
391
512
|
confirming: false,
|
|
392
513
|
hidden,
|
|
514
|
+
revisions,
|
|
393
515
|
editable,
|
|
394
516
|
replies,
|
|
395
517
|
failure: null,
|
|
@@ -397,7 +519,14 @@ window.__ModuleLoader__.load({
|
|
|
397
519
|
// The rollback has landed, so the editor is gone with its row: a
|
|
398
520
|
// failed second half has to be reported somewhere that survives that.
|
|
399
521
|
if (data.kind === 'reply' && data.applied === false) this.notify('reply')
|
|
400
|
-
if (data.kind !== 'reply' && data.
|
|
522
|
+
if (data.kind !== 'reply' && data.applied === false) this.notify('prompt')
|
|
523
|
+
// The view above is complete enough to render, but the host is the
|
|
524
|
+
// authority on what the session now looks like: only its answer knows
|
|
525
|
+
// how many rows the platform decided to draw, and what the appended
|
|
526
|
+
// correction or the re-run actually contains. Without this the client
|
|
527
|
+
// kept the optimistic snapshot until something else happened to move
|
|
528
|
+
// the row count, which for a rewritten message was never.
|
|
529
|
+
await this.load(true)
|
|
401
530
|
} catch {
|
|
402
531
|
this.publish({ pending: false, failure: 'generic' })
|
|
403
532
|
}
|
|
@@ -423,6 +552,9 @@ window.__ModuleLoader__.load({
|
|
|
423
552
|
if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
|
|
424
553
|
this.noticeTimer = null
|
|
425
554
|
this.editorElement = null
|
|
555
|
+
this.editorBox = null
|
|
556
|
+
this.editorRow = null
|
|
557
|
+
this.editorFocus = false
|
|
426
558
|
}
|
|
427
559
|
}
|
|
428
560
|
|
|
@@ -431,34 +563,26 @@ window.__ModuleLoader__.load({
|
|
|
431
563
|
// The editable target of a Chat view node, or null when the row stands for
|
|
432
564
|
// something that cannot be rewritten.
|
|
433
565
|
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// so both candidates are tried rather than assumed.
|
|
566
|
+
// Only human prompts come through here: a reply's entry comes from the
|
|
567
|
+
// official assistant-actions strip instead, which is where the host puts
|
|
568
|
+
// every other action for that row. Injecting one here used to put the pencil
|
|
569
|
+
// in a different place from every other reply action.
|
|
439
570
|
function targetFor(node, view) {
|
|
440
|
-
if (!node) return null
|
|
571
|
+
if (!node || node.kind !== 'user') return null
|
|
441
572
|
const data = node.data || {}
|
|
442
573
|
const candidates = []
|
|
443
574
|
const push = (value) => {
|
|
444
575
|
if (typeof value === 'number' && !candidates.includes(value)) candidates.push(value)
|
|
445
576
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
push(node.anchorSeq)
|
|
449
|
-
} else if (node.kind === 'assistant-step') {
|
|
450
|
-
push(data.finalNode && data.finalNode.seq)
|
|
451
|
-
push(node.anchorSeq)
|
|
452
|
-
} else {
|
|
453
|
-
return null
|
|
454
|
-
}
|
|
577
|
+
push(data.seq)
|
|
578
|
+
push(node.anchorSeq)
|
|
455
579
|
for (const seq of candidates) {
|
|
456
580
|
if (view.hidden.has(seq)) continue
|
|
457
|
-
const entry = view.editable.get(seq)
|
|
581
|
+
const entry = view.editable.get(seq)
|
|
458
582
|
if (!entry) continue
|
|
459
583
|
return {
|
|
460
584
|
seq,
|
|
461
|
-
mode:
|
|
585
|
+
mode: 'prompt',
|
|
462
586
|
turn: entry.turn,
|
|
463
587
|
messageId: entry.messageId,
|
|
464
588
|
text: typeof entry.text === 'string' ? entry.text : '',
|
|
@@ -468,6 +592,13 @@ window.__ModuleLoader__.load({
|
|
|
468
592
|
return null
|
|
469
593
|
}
|
|
470
594
|
|
|
595
|
+
// The element that carries the turn-tail marker: either the flow-keyed row
|
|
596
|
+
// itself or a wrapper inside it. Its own action strip is a direct child.
|
|
597
|
+
function rootOfTurnTail(row) {
|
|
598
|
+
if (typeof row.matches === 'function' && row.matches('[data-turn-tail]')) return row
|
|
599
|
+
return row.querySelector('[data-turn-tail]')
|
|
600
|
+
}
|
|
601
|
+
|
|
471
602
|
// Every surface seq a row stands for, so one rollback can hide the rows it
|
|
472
603
|
// shadowed however the host UI chose to group them.
|
|
473
604
|
function seqsFor(node) {
|
|
@@ -513,10 +644,172 @@ window.__ModuleLoader__.load({
|
|
|
513
644
|
|
|
514
645
|
const rowActions = new WeakMap()
|
|
515
646
|
|
|
647
|
+
// Show a rewritten prompt where its original row is.
|
|
648
|
+
//
|
|
649
|
+
// The bubble is cloned from a real user bubble's classes at runtime - the
|
|
650
|
+
// platform's class names are CSS-module hashes that change between builds, so
|
|
651
|
+
// copying a live one keeps skins and themes working without hardcoding them.
|
|
652
|
+
//
|
|
653
|
+
// Returns whether the row may be hidden now. `false` means this is a user
|
|
654
|
+
// message with a replacement recorded for it but no replacement text on
|
|
655
|
+
// screen - a snapshot that has not caught up yet - and hiding it would leave
|
|
656
|
+
// the transcript with nothing where the message was.
|
|
657
|
+
function renderRevision(row, node, view, hidden, controller, t) {
|
|
658
|
+
let revisionSeq = null
|
|
659
|
+
for (const seq of seqsFor(node)) {
|
|
660
|
+
if (view.revisions.has(seq)) {
|
|
661
|
+
revisionSeq = seq
|
|
662
|
+
break
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const existing = ownRevisionBubble(row)
|
|
666
|
+
if (revisionSeq === null || !hidden) {
|
|
667
|
+
if (existing !== null) existing.remove()
|
|
668
|
+
return true
|
|
669
|
+
}
|
|
670
|
+
const entry = view.editable.get(view.revisions.get(revisionSeq))
|
|
671
|
+
const text = entry && typeof entry.text === 'string' ? entry.text : ''
|
|
672
|
+
if (text === '') {
|
|
673
|
+
if (existing !== null) existing.remove()
|
|
674
|
+
return node.kind !== 'user'
|
|
675
|
+
}
|
|
676
|
+
const bubble = existing === null ? cloneUserBubble() : existing
|
|
677
|
+
if (existing === null) bubble.dataset.dshetRevision = '1'
|
|
678
|
+
placeRevisionBubble(row, bubble)
|
|
679
|
+
if (bubble.textContent !== text) bubble.textContent = text
|
|
680
|
+
// The edit entry goes where every other action of this row lives: the
|
|
681
|
+
// platform's own bar, which the collapse keeps standing, at its right end
|
|
682
|
+
// behind the clock and the copy button - the row the user reads as "this
|
|
683
|
+
// message", so that is where its actions belong. The bubble's gutter is
|
|
684
|
+
// only the fallback for a row with no bar at all: that row is displayed
|
|
685
|
+
// away entirely and the bubble stands before it, so there is nothing to
|
|
686
|
+
// join. Injected after the text assignment so the host is not wiped.
|
|
687
|
+
if (entry) {
|
|
688
|
+
const editTarget = { ...entry, mode: 'prompt' }
|
|
689
|
+
if (row.querySelector('[class*="_actions"]') !== null) {
|
|
690
|
+
injectRowAction(row, editTarget, controller, t)
|
|
691
|
+
removeRowAction(bubble)
|
|
692
|
+
bubble.classList.remove('dshet-revision-action')
|
|
693
|
+
} else {
|
|
694
|
+
injectRowAction(bubble, editTarget, controller, t, { gutter: true })
|
|
695
|
+
removeRowAction(row)
|
|
696
|
+
bubble.classList.add('dshet-revision-action')
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
removeRowAction(bubble)
|
|
700
|
+
bubble.classList.remove('dshet-revision-action')
|
|
701
|
+
}
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// A copy of a live user bubble element, classes and all.
|
|
706
|
+
function cloneUserBubble() {
|
|
707
|
+
const sampleRow = document.querySelector('[data-chat-flow-kind="user"]')
|
|
708
|
+
const sample = sampleRow === null ? null : sampleRow.firstElementChild
|
|
709
|
+
const bubble = document.createElement('div')
|
|
710
|
+
bubble.className = 'dshet-revision'
|
|
711
|
+
return bubble
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// The standing-in bubble this row was given, wherever the last pass put it.
|
|
715
|
+
//
|
|
716
|
+
// It lives inside the row - the platform's own message, not a sibling of it -
|
|
717
|
+
// but a row that could not keep its action bar is displayed away entirely and
|
|
718
|
+
// takes its children with it, so the bubble is then parked before the row.
|
|
719
|
+
// Both positions have to be recognised or every pass would plant a second one.
|
|
720
|
+
function ownRevisionBubble(row) {
|
|
721
|
+
for (const child of row.children) {
|
|
722
|
+
if (child.classList && child.classList.contains('dshet-revision')) return child
|
|
723
|
+
}
|
|
724
|
+
const previous = row.previousElementSibling
|
|
725
|
+
if (previous !== null && previous.classList && previous.classList.contains('dshet-revision')) {
|
|
726
|
+
return previous
|
|
727
|
+
}
|
|
728
|
+
return null
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Plant the bubble where the message it replaces used to be: inside the row,
|
|
732
|
+
// ahead of the node that carries the action bar, so the rewritten prompt reads
|
|
733
|
+
// above the timestamp and the copy button - the order the platform draws a
|
|
734
|
+
// message and its bar in. Planting it after the row put the bar on top of the
|
|
735
|
+
// text, as if the time and the copy belonged to whatever came next.
|
|
736
|
+
//
|
|
737
|
+
// Idempotent, and only ever moves the node when the position is wrong: this
|
|
738
|
+
// runs on every DOM pass, and rewriting the tree the observer watches would
|
|
739
|
+
// never settle.
|
|
740
|
+
function placeRevisionBubble(row, bubble) {
|
|
741
|
+
const strip = row.querySelector('[class*="_actions"]')
|
|
742
|
+
let holder = strip
|
|
743
|
+
while (strip !== null && holder !== null && holder.parentElement !== row) {
|
|
744
|
+
holder = holder.parentElement
|
|
745
|
+
}
|
|
746
|
+
const inside = strip !== null && holder !== null && holder.parentElement === row
|
|
747
|
+
if (!inside && row.parentElement === null) return
|
|
748
|
+
const parent = inside ? row : row.parentElement
|
|
749
|
+
const before = inside ? holder : row
|
|
750
|
+
if (bubble.parentElement !== parent || bubble.nextElementSibling !== before) {
|
|
751
|
+
parent.insertBefore(bubble, before)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Collapse a row's message while leaving its action strip standing.
|
|
756
|
+
//
|
|
757
|
+
// The platform's own time / copy / delete bar - and every other plugin's
|
|
758
|
+
// buttons - live in the same row as the prompt this plugin rolls back.
|
|
759
|
+
// Displaying the row away took them with it, so a rewritten prompt left the
|
|
760
|
+
// user no way to copy, read or delete the original. The row therefore stays:
|
|
761
|
+
// everything that leads to the strip collapses, the strip does not, and the
|
|
762
|
+
// marker attribute tells the stylesheet that the row itself must survive.
|
|
763
|
+
//
|
|
764
|
+
// Structure-agnostic on purpose - the platform's row classes are CSS-module
|
|
765
|
+
// hashes that change between builds, so the walk starts from the strip (whose
|
|
766
|
+
// `_actions` suffix is stable) and hides that strip's ancestors' other
|
|
767
|
+
// children up to the row. Returns false when there is no strip to keep, and
|
|
768
|
+
// false is what tells the caller to hide the whole row instead.
|
|
769
|
+
function collapseRowContent(row, hide) {
|
|
770
|
+
const strip = row.querySelector('[class*="_actions"]')
|
|
771
|
+
if (strip === null) return false
|
|
772
|
+
let node = strip
|
|
773
|
+
while (node !== null && node !== row) {
|
|
774
|
+
const parent = node.parentElement
|
|
775
|
+
if (parent === null) return false
|
|
776
|
+
for (const child of parent.children) {
|
|
777
|
+
if (child === node) continue
|
|
778
|
+
// This plugin's own furniture lives beside the message too: the
|
|
779
|
+
// in-place editor, a floating pencil and the bubble standing in for
|
|
780
|
+
// the rewritten prompt are not message content. The bubble is what
|
|
781
|
+
// the transcript has left of the message once this walk is done -
|
|
782
|
+
// hiding it here would take the text away a second time.
|
|
783
|
+
if (
|
|
784
|
+
child.classList &&
|
|
785
|
+
(child.classList.contains('dshet-editor') ||
|
|
786
|
+
child.classList.contains('dshet-action-host') ||
|
|
787
|
+
child.classList.contains('dshet-revision'))
|
|
788
|
+
) {
|
|
789
|
+
continue
|
|
790
|
+
}
|
|
791
|
+
if (hide) {
|
|
792
|
+
// Written only when the value changes: this runs on every DOM pass,
|
|
793
|
+
// and an attribute write the observer can see would never settle.
|
|
794
|
+
if (child.style.display !== 'none') child.style.display = 'none'
|
|
795
|
+
if (child.dataset.dshetCollapsed !== '1') child.dataset.dshetCollapsed = '1'
|
|
796
|
+
} else if (child.dataset.dshetCollapsed === '1') {
|
|
797
|
+
child.style.display = ''
|
|
798
|
+
delete child.dataset.dshetCollapsed
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
node = parent
|
|
802
|
+
}
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
805
|
+
|
|
516
806
|
function setRowHidden(row, hide, animate) {
|
|
517
807
|
if (hide) {
|
|
808
|
+
const keepsStrip = collapseRowContent(row, true)
|
|
809
|
+
if (keepsStrip && row.dataset.dshetKeepActions !== '1') row.dataset.dshetKeepActions = '1'
|
|
518
810
|
if (row.dataset.dshetHidden === '1') return
|
|
519
811
|
row.dataset.dshetHidden = '1'
|
|
812
|
+
if (keepsStrip) return
|
|
520
813
|
if (!animate || typeof requestAnimationFrame !== 'function') {
|
|
521
814
|
row.style.display = 'none'
|
|
522
815
|
return
|
|
@@ -540,8 +833,10 @@ window.__ModuleLoader__.load({
|
|
|
540
833
|
}, 240)
|
|
541
834
|
return
|
|
542
835
|
}
|
|
836
|
+
collapseRowContent(row, false)
|
|
543
837
|
if (row.dataset.dshetHidden !== '1') return
|
|
544
838
|
delete row.dataset.dshetHidden
|
|
839
|
+
if (row.dataset.dshetKeepActions === '1') delete row.dataset.dshetKeepActions
|
|
545
840
|
row.classList.remove('dshet-collapsing')
|
|
546
841
|
row.style.display = ''
|
|
547
842
|
row.style.height = ''
|
|
@@ -559,7 +854,7 @@ window.__ModuleLoader__.load({
|
|
|
559
854
|
rowActions.delete(row)
|
|
560
855
|
}
|
|
561
856
|
|
|
562
|
-
function injectRowAction(row, target, controller, t) {
|
|
857
|
+
function injectRowAction(row, target, controller, t, options = {}) {
|
|
563
858
|
const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
|
|
564
859
|
let entry = rowActions.get(row)
|
|
565
860
|
if (!entry) {
|
|
@@ -569,25 +864,51 @@ window.__ModuleLoader__.load({
|
|
|
569
864
|
button.type = 'button'
|
|
570
865
|
button.className = 'dshet-action dshet-row-action'
|
|
571
866
|
button.innerHTML = ICON_MARKUP
|
|
867
|
+
// The marker goes on the BUTTON, not only on its wrapper: the pass that
|
|
868
|
+
// looks for the official strip's entry queries buttons, so a marker one
|
|
869
|
+
// level up made this pencil look like the strip's own and it deleted
|
|
870
|
+
// itself - then the next pass put it back, which reads as flicker.
|
|
871
|
+
if (options.fallback === true) button.dataset.dshetFallback = '1'
|
|
572
872
|
host.appendChild(button)
|
|
573
|
-
entry = { host, button }
|
|
873
|
+
entry = { host, button, target }
|
|
874
|
+
// Wired once, and the action reads the CURRENT target off the entry: the
|
|
875
|
+
// pass runs again on every snapshot, and re-wiring would reset the
|
|
876
|
+
// press guard between a pointerdown and the click it swallows.
|
|
877
|
+
pressable(button, () => controller.open(entry.target))
|
|
574
878
|
rowActions.set(row, entry)
|
|
575
879
|
}
|
|
880
|
+
entry.target = target
|
|
576
881
|
const { host, button } = entry
|
|
577
882
|
if (button.getAttribute('aria-label') !== label) {
|
|
578
883
|
button.setAttribute('aria-label', label)
|
|
579
884
|
button.setAttribute('title', label)
|
|
580
885
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
const anchor = row.querySelector('[class*="_actions"]')
|
|
886
|
+
// A caller that already picked the right bar (the turn tail's own strip)
|
|
887
|
+
// hands it over; otherwise the first actions container in the row wins.
|
|
888
|
+
const anchor =
|
|
889
|
+
options.gutter === true ? null : options.anchor ?? row.querySelector('[class*="_actions"]')
|
|
587
890
|
if (anchor) {
|
|
588
891
|
host.classList.remove('dshet-floating')
|
|
589
892
|
row.classList.remove('dshet-row')
|
|
590
|
-
|
|
893
|
+
// The clock and the usage pill trail the actions in that strip, so a
|
|
894
|
+
// plain append parks an action behind the row's trailing info. Sit after
|
|
895
|
+
// the LAST platform action icon instead - that is where the host seats
|
|
896
|
+
// its own extra actions, and where the user row keeps its pencil.
|
|
897
|
+
const lastAction = lastPlatformAction(anchor)
|
|
898
|
+
// On the second pass this pencil is already `lastAction.nextSibling`, and
|
|
899
|
+
// inserting a node before itself is not a no-op in every engine - skip
|
|
900
|
+
// past ourselves so the position stays put instead of drifting to the end.
|
|
901
|
+
let next = lastAction === null ? null : lastAction.nextSibling
|
|
902
|
+
if (next === host) next = next.nextSibling
|
|
903
|
+
if (host.parentElement !== anchor || host.nextSibling !== next) anchor.insertBefore(host, next)
|
|
904
|
+
} else if (options.gutter === true) {
|
|
905
|
+
// The revision bubble is ours, not the platform's: it has no action bar
|
|
906
|
+
// to join, so the pencil goes into the bubble's own reserved gutter
|
|
907
|
+
// (`.dshet-revision-action`). Never `.dshet-floating` - that rule is
|
|
908
|
+
// positioned against the row, which put the button on top of the text.
|
|
909
|
+
host.classList.remove('dshet-floating')
|
|
910
|
+
row.classList.remove('dshet-row')
|
|
911
|
+
if (host.parentElement !== row) row.appendChild(host)
|
|
591
912
|
} else {
|
|
592
913
|
host.classList.add('dshet-floating')
|
|
593
914
|
row.classList.add('dshet-row')
|
|
@@ -595,11 +916,89 @@ window.__ModuleLoader__.load({
|
|
|
595
916
|
}
|
|
596
917
|
}
|
|
597
918
|
|
|
919
|
+
// The platform's own action icons all carry a hashed class ending in
|
|
920
|
+
// `_action`; the clock, the usage pill and anything this plugin adds do not
|
|
921
|
+
// (ours are hyphenated). Matching that suffix avoids CSS-module hashes
|
|
922
|
+
// entirely - they change between builds.
|
|
923
|
+
function lastPlatformAction(bar) {
|
|
924
|
+
const children = Array.from(bar.children)
|
|
925
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
926
|
+
const child = children[index]
|
|
927
|
+
const className = typeof child.className === 'string' ? child.className : ''
|
|
928
|
+
if (/_action\b/.test(className)) return child
|
|
929
|
+
}
|
|
930
|
+
return null
|
|
931
|
+
}
|
|
932
|
+
|
|
598
933
|
function clearEditor() {
|
|
599
934
|
const elements = document.querySelectorAll('.dshet-editor')
|
|
600
935
|
for (const element of elements) element.remove()
|
|
601
936
|
}
|
|
602
937
|
|
|
938
|
+
// The layer the editor is hosted on. It is created once, hangs off the
|
|
939
|
+
// body - where the host's React tree cannot reach it - and survives every
|
|
940
|
+
// rebuild of the conversation.
|
|
941
|
+
let editorLayer = null
|
|
942
|
+
function ensureEditorLayer() {
|
|
943
|
+
if (editorLayer !== null && editorLayer.isConnected === true) return editorLayer
|
|
944
|
+
editorLayer = document.createElement('div')
|
|
945
|
+
editorLayer.className = 'dshet-layer'
|
|
946
|
+
document.body.appendChild(editorLayer)
|
|
947
|
+
return editorLayer
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Put the box under the row it belongs to: same width, just below its lower
|
|
951
|
+
// edge. Called on render and whenever the page scrolls or resizes, since
|
|
952
|
+
// the layer is fixed to the viewport.
|
|
953
|
+
function placeEditor(row, box) {
|
|
954
|
+
const layer = ensureEditorLayer()
|
|
955
|
+
if (box.parentElement !== layer) layer.appendChild(box)
|
|
956
|
+
const rect = row.getBoundingClientRect()
|
|
957
|
+
box.style.left = `${Math.round(rect.left)}px`
|
|
958
|
+
box.style.top = `${Math.round(rect.bottom + 6)}px`
|
|
959
|
+
box.style.width = `${Math.round(rect.width)}px`
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Grow the textarea to its content, the way a normal input box behaves.
|
|
963
|
+
// Without this it sits at a fixed `rows` height and a two-line edit floats
|
|
964
|
+
// in half an empty box. The stylesheet owns the limits (`min-height`,
|
|
965
|
+
// `max-height`); the element only ever gets the measured height, and the
|
|
966
|
+
// `+2` is the top and bottom border `scrollHeight` does not count.
|
|
967
|
+
function fitEditorHeight(area) {
|
|
968
|
+
area.style.height = 'auto'
|
|
969
|
+
const height = area.scrollHeight
|
|
970
|
+
if (typeof height === 'number' && height > 0) area.style.height = `${height + 2}px`
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Wire an action so a host re-render cannot eat the click.
|
|
974
|
+
//
|
|
975
|
+
// The host owns the row and rebuilds it freely - while a turn streams it
|
|
976
|
+
// re-renders constantly - so an injected button can be replaced between
|
|
977
|
+
// mousedown and mouseup, and a click only fires when both land on the same
|
|
978
|
+
// node. That is what "the button does nothing" looks like from outside, and
|
|
979
|
+
// it is unguessable from the state. The action therefore rides on
|
|
980
|
+
// pointerdown; the click path stays for the keyboard, de-duplicated so one
|
|
981
|
+
// press cannot run the action twice.
|
|
982
|
+
function pressable(button, action) {
|
|
983
|
+
let pressed = false
|
|
984
|
+
button.onpointerdown = (event) => {
|
|
985
|
+
if (typeof event.button === 'number' && event.button !== 0) return
|
|
986
|
+
pressed = true
|
|
987
|
+
event.preventDefault()
|
|
988
|
+
event.stopPropagation()
|
|
989
|
+
action()
|
|
990
|
+
}
|
|
991
|
+
button.onclick = (event) => {
|
|
992
|
+
event.preventDefault()
|
|
993
|
+
event.stopPropagation()
|
|
994
|
+
if (pressed) {
|
|
995
|
+
pressed = false
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
action()
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
603
1002
|
// The editor is plain DOM on purpose: the host row is a React subtree and
|
|
604
1003
|
// injecting a second controlled tree into it would fight its reconciler.
|
|
605
1004
|
// The draft is mirrored into the controller on every input event, so a
|
|
@@ -623,49 +1022,98 @@ window.__ModuleLoader__.load({
|
|
|
623
1022
|
// Every string the editor shows depends on what is being edited: a prompt is
|
|
624
1023
|
// re-run, a reply is replaced. Keeping them in one place keeps the two modes
|
|
625
1024
|
// from drifting apart in wording.
|
|
626
|
-
function editorTexts(t, target) {
|
|
1025
|
+
function editorTexts(t, target, rerun) {
|
|
627
1026
|
const reply = target.mode === 'reply'
|
|
628
1027
|
return {
|
|
629
1028
|
title: t(reply ? 'editor.title-reply' : 'editor.title'),
|
|
630
1029
|
placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
|
|
631
|
-
hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
|
|
632
1030
|
warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
|
|
633
|
-
|
|
1031
|
+
// Saving writes the revision into the context; only the opt-in config asks
|
|
1032
|
+
// the model again, and the button says which one will happen.
|
|
1033
|
+
save: t(reply ? 'editor.save-reply' : rerun === true ? 'editor.save-rerun' : 'editor.save'),
|
|
634
1034
|
}
|
|
635
1035
|
}
|
|
636
1036
|
|
|
637
1037
|
function renderEditor(row, controller, view, t) {
|
|
638
1038
|
const target = view.editing
|
|
639
|
-
const texts = editorTexts(t, target)
|
|
1039
|
+
const texts = editorTexts(t, target, view.rerun)
|
|
640
1040
|
const marker = editorMarker(target, view)
|
|
641
|
-
const existing =
|
|
642
|
-
|
|
1041
|
+
const existing = controller.editorBox !== null && controller.editorBox.isConnected === true
|
|
1042
|
+
? controller.editorBox
|
|
1043
|
+
: null
|
|
1044
|
+
if (existing && existing.dataset.dshetFor === marker) {
|
|
1045
|
+
placeEditor(row, existing)
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Focus policy. The host re-renders this row freely, so the box gets
|
|
1050
|
+
// rebuilt; taking focus at that moment is what pulled the caret out of
|
|
1051
|
+
// the composer (or whatever else the user was typing in). Focus is only
|
|
1052
|
+
// taken when the editor was just opened, or handed back to the rebuild
|
|
1053
|
+
// when the user was typing in the box that was just replaced - with the
|
|
1054
|
+
// caret where it was.
|
|
1055
|
+
const previous = controller.editorElement
|
|
1056
|
+
const fresh = previous === null
|
|
1057
|
+
const wasFocused = !fresh && (controller.editorFocus === true || document.activeElement === previous)
|
|
1058
|
+
const caret = wasFocused && typeof previous.selectionStart === 'number'
|
|
1059
|
+
? [previous.selectionStart, previous.selectionEnd]
|
|
1060
|
+
: null
|
|
643
1061
|
|
|
644
1062
|
clearEditor()
|
|
645
1063
|
const box = document.createElement('div')
|
|
646
1064
|
box.className = 'dshet-editor'
|
|
647
1065
|
box.dataset.dshetFor = marker
|
|
648
1066
|
|
|
649
|
-
const title = document.createElement('p')
|
|
650
|
-
title.className = 'dshet-editor-title'
|
|
651
|
-
title.textContent = view.confirming ? t('confirm.title') : texts.title
|
|
652
|
-
box.appendChild(title)
|
|
653
|
-
|
|
654
1067
|
const area = document.createElement('textarea')
|
|
655
|
-
area.rows =
|
|
1068
|
+
area.rows = 1
|
|
656
1069
|
area.value = controller.editorDraft
|
|
657
1070
|
area.disabled = view.pending || view.confirming
|
|
658
1071
|
area.spellcheck = false
|
|
659
1072
|
area.setAttribute('aria-label', texts.title)
|
|
660
1073
|
if (!view.confirming) area.placeholder = texts.placeholder
|
|
661
|
-
area.addEventListener('input', () =>
|
|
1074
|
+
area.addEventListener('input', (event) => {
|
|
1075
|
+
controller.setDraft(area.value)
|
|
1076
|
+
// Never relayout mid-composition: the IME owns the field until it
|
|
1077
|
+
// commits, and moving it under the candidate window is how a box
|
|
1078
|
+
// "eats" pinyin.
|
|
1079
|
+
if (event.isComposing === true) return
|
|
1080
|
+
fitEditorHeight(area)
|
|
1081
|
+
})
|
|
1082
|
+
area.addEventListener('compositionend', () => fitEditorHeight(area))
|
|
1083
|
+
area.addEventListener('focus', () => {
|
|
1084
|
+
controller.editorFocus = true
|
|
1085
|
+
})
|
|
1086
|
+
// The keyboard of every input box in this app: Enter acts (the composer
|
|
1087
|
+
// sends), Shift+Enter breaks the line, Escape leaves (one step back when
|
|
1088
|
+
// the confirmation step is showing). The guard is the platform's own
|
|
1089
|
+
// (`isComposing` OR keyCode 229): the Enter that commits a 拼音/かな
|
|
1090
|
+
// candidate can arrive with `isComposing === false` and keyCode 229 - and
|
|
1091
|
+
// missing that half is what saved the box on every candidate pick.
|
|
1092
|
+
area.addEventListener('keydown', (event) => {
|
|
1093
|
+
if (event.isComposing === true || event.keyCode === 229) return
|
|
1094
|
+
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
1095
|
+
event.preventDefault()
|
|
1096
|
+
if (view.confirming) controller.confirm()
|
|
1097
|
+
else controller.review()
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
if (event.key === 'Escape') {
|
|
1101
|
+
event.preventDefault()
|
|
1102
|
+
if (view.confirming) controller.back()
|
|
1103
|
+
else controller.close()
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
662
1106
|
box.appendChild(area)
|
|
663
1107
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1108
|
+
// Only what the user has to know right now: what the replacement will
|
|
1109
|
+
// discard, and what went wrong. The running explanation lives in the
|
|
1110
|
+
// README and in the buttons' tooltips, the way a normal input box does it.
|
|
1111
|
+
if (view.confirming) {
|
|
1112
|
+
const note = document.createElement('p')
|
|
1113
|
+
note.className = 'dshet-note'
|
|
1114
|
+
note.textContent = t('confirm.body')
|
|
1115
|
+
box.appendChild(note)
|
|
1116
|
+
}
|
|
669
1117
|
if (target.attachments > 0) {
|
|
670
1118
|
const warn = document.createElement('p')
|
|
671
1119
|
warn.className = 'dshet-warn'
|
|
@@ -687,13 +1135,12 @@ window.__ModuleLoader__.load({
|
|
|
687
1135
|
cancel.type = 'button'
|
|
688
1136
|
cancel.className = 'dshet-btn'
|
|
689
1137
|
cancel.textContent = t('editor.cancel')
|
|
1138
|
+
cancel.title = `${t('editor.cancel')} (Esc)`
|
|
690
1139
|
cancel.disabled = view.pending
|
|
691
|
-
cancel
|
|
692
|
-
event.preventDefault()
|
|
693
|
-
event.stopPropagation()
|
|
1140
|
+
pressable(cancel, () => {
|
|
694
1141
|
if (view.confirming) controller.back()
|
|
695
1142
|
else controller.close()
|
|
696
|
-
}
|
|
1143
|
+
})
|
|
697
1144
|
|
|
698
1145
|
const submit = document.createElement('button')
|
|
699
1146
|
submit.type = 'button'
|
|
@@ -703,23 +1150,28 @@ window.__ModuleLoader__.load({
|
|
|
703
1150
|
: view.confirming
|
|
704
1151
|
? t('editor.review')
|
|
705
1152
|
: texts.save
|
|
1153
|
+
submit.title = `${submit.textContent} (Enter)`
|
|
706
1154
|
submit.disabled = view.pending
|
|
707
|
-
submit
|
|
708
|
-
event.preventDefault()
|
|
709
|
-
event.stopPropagation()
|
|
1155
|
+
pressable(submit, () => {
|
|
710
1156
|
if (view.confirming) controller.confirm()
|
|
711
1157
|
else controller.review()
|
|
712
|
-
}
|
|
1158
|
+
})
|
|
713
1159
|
|
|
714
1160
|
footer.appendChild(cancel)
|
|
715
1161
|
footer.appendChild(submit)
|
|
716
1162
|
box.appendChild(footer)
|
|
717
1163
|
|
|
718
|
-
row
|
|
719
|
-
|
|
1164
|
+
placeEditor(row, box)
|
|
1165
|
+
controller.editorBox = box
|
|
1166
|
+
controller.editorRow = row
|
|
1167
|
+
controller.editorElement = area
|
|
1168
|
+
fitEditorHeight(area)
|
|
1169
|
+
if (!view.confirming && !view.pending && (fresh || wasFocused)) {
|
|
720
1170
|
try {
|
|
721
1171
|
area.focus()
|
|
722
|
-
area.
|
|
1172
|
+
const end = typeof area.value.length === 'number' ? area.value.length : 0
|
|
1173
|
+
const [start, stop] = caret === null ? [end, end] : caret
|
|
1174
|
+
area.setSelectionRange(Math.min(start, end), Math.min(stop, end))
|
|
723
1175
|
} catch {
|
|
724
1176
|
/* focus is best-effort */
|
|
725
1177
|
}
|
|
@@ -752,21 +1204,131 @@ window.__ModuleLoader__.load({
|
|
|
752
1204
|
const node = snapshot.nodes.get(key)
|
|
753
1205
|
if (!node) continue
|
|
754
1206
|
const hidden = isRowHidden(view.hidden, seqsFor(node))
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1207
|
+
// A prompt this plugin rewrote in place is not a discard: the platform keeps
|
|
1208
|
+
// the old row (it keeps transcript rows for messages that left the surface),
|
|
1209
|
+
// and the revision belongs exactly there - not at the tail the carrier's
|
|
1210
|
+
// sequence number would sort to. Our own bubble stands in for it.
|
|
1211
|
+
// Never let a rendering nicety take the whole browser half down: without
|
|
1212
|
+
// this plugin there are no edit entries at all. What it must not do is
|
|
1213
|
+
// take the message down with it either - a failed draw leaves the row
|
|
1214
|
+
// up, so the worst case is the old text showing for another refresh.
|
|
1215
|
+
let canHide = true
|
|
1216
|
+
try {
|
|
1217
|
+
canHide = renderRevision(row, node, view, hidden, controller, t)
|
|
1218
|
+
} catch {
|
|
1219
|
+
canHide = node.kind !== 'user'
|
|
1220
|
+
}
|
|
1221
|
+
const visible = hidden && canHide
|
|
1222
|
+
setRowHidden(row, visible, animate)
|
|
1223
|
+
// A collapsed row's entry is placed by `renderRevision`: the rewritten
|
|
1224
|
+
// prompt stands in for the message and the pencil goes into the bar that
|
|
1225
|
+
// survives alongside it. Tearing that down here would take back out what
|
|
1226
|
+
// that call has only just put there - so the row keeps its entry only
|
|
1227
|
+
// while something of ours is standing in for it. A row that collapsed
|
|
1228
|
+
// with no replacement on screen (a rollback this plugin knows nothing
|
|
1229
|
+
// about) is not editable any more and loses the pencil it had.
|
|
1230
|
+
if (visible) {
|
|
1231
|
+
if (ownRevisionBubble(row) === null) removeRowAction(row)
|
|
1232
|
+
} else {
|
|
1233
|
+
const target = targetFor(node, view)
|
|
1234
|
+
if (target !== null) injectRowAction(row, target, controller, t)
|
|
1235
|
+
else removeRowAction(row)
|
|
1236
|
+
}
|
|
1237
|
+
// The editor anchors to whichever row covers the seq being edited, not to
|
|
1238
|
+
// the row that owns the entry: a reply's entry now lives in the turn-tail
|
|
1239
|
+
// action strip, while its editor still belongs under the reply text.
|
|
759
1240
|
if (
|
|
760
1241
|
!hidden &&
|
|
761
1242
|
view.editing !== null &&
|
|
762
1243
|
typeof view.editing.seq === 'number' &&
|
|
763
|
-
|
|
764
|
-
target.seq === view.editing.seq
|
|
1244
|
+
seqsFor(node).includes(view.editing.seq)
|
|
765
1245
|
) {
|
|
766
1246
|
renderEditor(row, controller, view, t)
|
|
767
1247
|
}
|
|
768
1248
|
}
|
|
769
|
-
if (view.editing === null)
|
|
1249
|
+
if (view.editing === null) {
|
|
1250
|
+
clearEditor()
|
|
1251
|
+
controller.editorBox = null
|
|
1252
|
+
controller.editorRow = null
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// A snapshot this entry can read, or null when the controller cannot be
|
|
1257
|
+
// trusted to have one.
|
|
1258
|
+
//
|
|
1259
|
+
// The slot runtime abdicates an entry that throws during render - permanently
|
|
1260
|
+
// for the life of the page: the cell renders an empty placeholder and this
|
|
1261
|
+
// pencil never comes back, with no error surfaced to the user. One missing
|
|
1262
|
+
// prop therefore has to cost this button alone, so every read below is
|
|
1263
|
+
// guarded and nothing here can throw.
|
|
1264
|
+
function readReplyView(controller) {
|
|
1265
|
+
if (controller === null || controller === undefined || typeof controller.getSnapshot !== 'function') return null
|
|
1266
|
+
const view = controller.getSnapshot()
|
|
1267
|
+
if (view === null || typeof view !== 'object') return null
|
|
1268
|
+
if (!view.repliesByMessage || typeof view.repliesByMessage.get !== 'function') return null
|
|
1269
|
+
if (!view.hidden || typeof view.hidden.has !== 'function') return null
|
|
1270
|
+
return view
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// The reply edit entry, registered in the host's own assistant-actions strip.
|
|
1274
|
+
//
|
|
1275
|
+
// It has to live there rather than be injected next to the message: the host
|
|
1276
|
+
// renders that strip per assistant message, hands the entry the messageId, and
|
|
1277
|
+
// keeps the platform's own reveal behaviour (always for the latest turn, hover
|
|
1278
|
+
// for older ones). Injecting instead put the pencil in a different place from
|
|
1279
|
+
// where every other action for that row lives, which is what users noticed.
|
|
1280
|
+
function ReplyActionEntry(input) {
|
|
1281
|
+
const props = input !== null && typeof input === 'object' ? input : {}
|
|
1282
|
+
const messageId = props.messageId
|
|
1283
|
+
const controller = props.controller
|
|
1284
|
+
const t = typeof props.t === 'function' ? props.t : null
|
|
1285
|
+
const [view, setView] = react.useState(() => {
|
|
1286
|
+
try {
|
|
1287
|
+
return readReplyView(controller)
|
|
1288
|
+
} catch {
|
|
1289
|
+
return null
|
|
1290
|
+
}
|
|
1291
|
+
})
|
|
1292
|
+
react.useEffect(() => {
|
|
1293
|
+
let unsubscribe = null
|
|
1294
|
+
try {
|
|
1295
|
+
setView(readReplyView(controller))
|
|
1296
|
+
if (controller !== null && controller !== undefined && typeof controller.subscribe === 'function') {
|
|
1297
|
+
unsubscribe = controller.subscribe(() => setView(readReplyView(controller)))
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
setView(null)
|
|
1301
|
+
}
|
|
1302
|
+
return () => {
|
|
1303
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
1304
|
+
}
|
|
1305
|
+
}, [controller])
|
|
1306
|
+
const entry = view === null ? undefined : view.repliesByMessage.get(messageId)
|
|
1307
|
+
if (entry === undefined || entry === null || view.hidden.has(entry.seq)) return null
|
|
1308
|
+
const label = t === null ? '' : t('action.edit-reply')
|
|
1309
|
+
const open = (event) => {
|
|
1310
|
+
event.preventDefault()
|
|
1311
|
+
event.stopPropagation()
|
|
1312
|
+
controller.open({
|
|
1313
|
+
seq: entry.seq,
|
|
1314
|
+
mode: 'reply',
|
|
1315
|
+
turn: entry.turn,
|
|
1316
|
+
messageId: entry.messageId,
|
|
1317
|
+
text: typeof entry.text === 'string' ? entry.text : '',
|
|
1318
|
+
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
1319
|
+
})
|
|
1320
|
+
}
|
|
1321
|
+
// The config object is always an object: this runtime's `jsx` reads
|
|
1322
|
+
// `config.key` without a null check, so `jsx(PencilIcon, null)` threw and
|
|
1323
|
+
// the boundary retired the entry for the whole page.
|
|
1324
|
+
return jsx('button', {
|
|
1325
|
+
type: 'button',
|
|
1326
|
+
className: 'dshet-action dshet-row-action dshet-reply-action',
|
|
1327
|
+
'aria-label': label,
|
|
1328
|
+
title: label,
|
|
1329
|
+
onClick: open,
|
|
1330
|
+
children: jsx(PencilIcon, {}),
|
|
1331
|
+
})
|
|
770
1332
|
}
|
|
771
1333
|
|
|
772
1334
|
// --- react entries --------------------------------------------------------
|
|
@@ -779,6 +1341,36 @@ window.__ModuleLoader__.load({
|
|
|
779
1341
|
controller.load()
|
|
780
1342
|
}, [controller])
|
|
781
1343
|
|
|
1344
|
+
// Pressing outside the editor is the one signal that means "the user
|
|
1345
|
+
// left". Blur cannot be that signal: a host re-render replaces the node
|
|
1346
|
+
// (blur without a related target) and a save temporarily disables it -
|
|
1347
|
+
// both used to drop the caret for good.
|
|
1348
|
+
react.useEffect(() => {
|
|
1349
|
+
if (typeof document.addEventListener !== 'function' || typeof document.removeEventListener !== 'function') {
|
|
1350
|
+
return undefined
|
|
1351
|
+
}
|
|
1352
|
+
const onPointerDown = (event) => {
|
|
1353
|
+
const target = event.target
|
|
1354
|
+
const inside = target !== null && target !== undefined && typeof target.closest === 'function'
|
|
1355
|
+
&& target.closest('.dshet-editor') !== null
|
|
1356
|
+
if (!inside) controller.editorFocus = false
|
|
1357
|
+
}
|
|
1358
|
+
const keepPlaced = () => {
|
|
1359
|
+
const row = controller.editorRow
|
|
1360
|
+
const box = controller.editorBox
|
|
1361
|
+
if (row !== null && box !== null && box.isConnected === true) placeEditor(row, box)
|
|
1362
|
+
}
|
|
1363
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1364
|
+
// Scroll events do not bubble; capture reaches the inner scroller too.
|
|
1365
|
+
document.addEventListener('scroll', keepPlaced, { capture: true, passive: true })
|
|
1366
|
+
window.addEventListener('resize', keepPlaced)
|
|
1367
|
+
return () => {
|
|
1368
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1369
|
+
document.removeEventListener('scroll', keepPlaced, true)
|
|
1370
|
+
window.removeEventListener('resize', keepPlaced)
|
|
1371
|
+
}
|
|
1372
|
+
}, [controller])
|
|
1373
|
+
|
|
782
1374
|
react.useEffect(() => {
|
|
783
1375
|
if (snapshot === undefined) return undefined
|
|
784
1376
|
let scheduled = false
|
|
@@ -835,6 +1427,25 @@ window.__ModuleLoader__.load({
|
|
|
835
1427
|
'dsh-edit-turn: per-session controllers',
|
|
836
1428
|
)
|
|
837
1429
|
|
|
1430
|
+
// A reply's edit entry lives in the official assistant-actions strip, one
|
|
1431
|
+
// entry per assistant message. order 5 puts it ahead of the feedback
|
|
1432
|
+
// entries (order 10), so the pencil sits with the leading icons.
|
|
1433
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
1434
|
+
ctx.slots.register(
|
|
1435
|
+
{
|
|
1436
|
+
name: 'conversation.chat.assistant-actions',
|
|
1437
|
+
id: 'edit-turn-reply',
|
|
1438
|
+
order: 5,
|
|
1439
|
+
locale: NS,
|
|
1440
|
+
inject: (sessionId) => ({
|
|
1441
|
+
hooks: { editTurn: controllerFor(sessionId) },
|
|
1442
|
+
controller: controllerFor(sessionId),
|
|
1443
|
+
}),
|
|
1444
|
+
},
|
|
1445
|
+
ReplyActionEntry,
|
|
1446
|
+
),
|
|
1447
|
+
)
|
|
1448
|
+
|
|
838
1449
|
ctx.slots.inject('conversation.input.overlay', () =>
|
|
839
1450
|
ctx.slots.register(
|
|
840
1451
|
{
|