@ash-qw/dsh-theme-prts 1.3.1 → 1.4.1

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.
@@ -0,0 +1,599 @@
1
+ import { createPreviewContentCache } from './preview-markdown.js'
2
+ export function canAnswerPreview(pending) {
3
+ if (pending?.kind === 'approval') return pending.answerable === true && typeof pending.answer === 'function'
4
+ const question = pending?.questions?.[0]
5
+ return pending?.kind === 'question' && pending.questions.length === 1
6
+ && typeof pending.answer === 'function' && typeof question?.id === 'string'
7
+ && !question.intent && !question.detail && question.options?.length > 0
8
+ && !pending.getSnapshot?.().closed
9
+ }
10
+
11
+ // Pending choices are separate from assistant prose in DSH.
12
+ export function pendingQuestionPreview(pending) {
13
+ const questions = pending.questions ?? []
14
+ const text = questions.slice(0, 2).map(item => {
15
+ const question = (item.question ?? '').slice(0, 80)
16
+ const options = (item.options ?? []).slice(0, 4).map(option => option.label).filter(Boolean).join(' / ')
17
+ return [question, options].filter(Boolean).join(':')
18
+ }).join(';')
19
+ return text ? text.slice(0, 240) : '有待回答的问题,请打开会话查看'
20
+ }
21
+
22
+ // Read the host's assembled Chat view rather than interpreting raw stream events.
23
+ export function latestAssistantReply(snapshot) {
24
+ const order = snapshot?.order ?? []
25
+ const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
26
+ for (let i = order.length - 1; i >= 0; i--) {
27
+ const node = snapshot.nodes?.get?.(order[i])
28
+ // Never search past a new user input, even before turn/start arrives.
29
+ if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) return ''
30
+ if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
31
+ if (node?.kind !== 'assistant-step' || node.visibility === 'hidden') continue
32
+ let text = ''
33
+ for (const block of node.data?.blocks ?? []) {
34
+ if (block.kind === 'text') text += block.text ?? ''
35
+ }
36
+ text = text.trim()
37
+ if (text) return text
38
+ }
39
+ return ''
40
+ }
41
+
42
+ export function completedPreviewReplies(snapshot) {
43
+ const replies = []
44
+ const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
45
+ const order = snapshot?.order ?? []
46
+ for (let i = order.length - 1; i >= 0; i--) {
47
+ const key = order[i]
48
+ const node = snapshot.nodes?.get?.(key)
49
+ if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) break
50
+ if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
51
+ if (node?.kind !== 'assistant-step' || node.visibility === 'hidden' || node.data?.status !== 'settled') continue
52
+ const text = (node.data.blocks ?? []).filter(block => block.kind === 'text').map(block => block.text ?? '').join('').trim()
53
+ if (text) replies.push({key, text})
54
+ }
55
+ return replies.reverse()
56
+ }
57
+
58
+ // Ignore streaming text: only settled prose and turn boundaries affect this list.
59
+ // Cache immutable host data; mutable test/older-host data is read on each check.
60
+ export function createPreviewReplySelector() {
61
+ const textCache = new WeakMap()
62
+ let previous = [], previousTurn, previousBoundary
63
+ return (snapshot, limit = 5) => {
64
+ const order = snapshot?.order ?? []
65
+ const turn = snapshot?.timeline?.turnOrder?.at(-1)
66
+ const replies = []
67
+ let boundary
68
+ for (let i = order.length - 1; i >= 0; i--) {
69
+ const key = order[i], node = snapshot.nodes?.get?.(key)
70
+ if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) { boundary = key; break }
71
+ if (turn !== undefined && node?.location?.turn?.turn !== turn) continue
72
+ if (node?.kind !== 'assistant-step' || node.visibility === 'hidden' || node.data?.status !== 'settled') continue
73
+ let text = textCache.get(node.data)
74
+ if (text === undefined) {
75
+ text = (node.data.blocks ?? []).filter(block => block.kind === 'text').map(block => block.text ?? '').join('').trim()
76
+ if (Object.isFrozen(node.data)) textCache.set(node.data, text)
77
+ }
78
+ if (text) replies.push({key, text})
79
+ }
80
+ replies.reverse()
81
+ const selected = (limit > 0 ? replies.slice(-limit) : replies)
82
+ .map((reply, index) => ({...reply, ordinal: replies.length - (limit > 0 ? Math.min(limit, replies.length) : replies.length) + index + 1}))
83
+ if (turn === previousTurn && boundary === previousBoundary && selected.length === previous.length && selected.every((reply, i) => reply.key === previous[i].key && reply.text === previous[i].text && reply.ordinal === previous[i].ordinal)) return previous
84
+ previousTurn = turn
85
+ previousBoundary = boundary
86
+ return previous = selected
87
+ }
88
+ }
89
+
90
+ function previewStatusFields(status) {
91
+ const pending = status?.pendingInteraction
92
+ return [status?.running, status?.completionUnread, pending, pending?.answer,
93
+ JSON.stringify([pending?.kind, pending?.answerable, pending?.getSnapshot?.().closed,
94
+ pending?.questions, pending?.toolName, pending?.reason, pending?.displayReason])]
95
+ }
96
+
97
+ export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences, onPreviewClosed, onAnswerSubmitted }) {
98
+ let active
99
+ function follows(state) {
100
+ const preferences = getPreviewPreferences?.() ?? {}
101
+ return state === 'warning' ? preferences.previewFollowWarning === true : preferences.previewFollowOngoing !== false
102
+ }
103
+ // Shared across running, pending and completed previews. Active ownership is
104
+ // outside the LRU so it can never be evicted by an idle timeout.
105
+ const warm = new Map()
106
+ const cacheLimit = 5
107
+ function releaseWarm(id) {
108
+ const entry = warm.get(id)
109
+ if (!entry) return
110
+ warm.delete(id)
111
+ window.clearTimeout(entry.timer)
112
+ entry.reference.release()
113
+ }
114
+ function clearWarm() {
115
+ for (const id of warm.keys()) releaseWarm(id)
116
+ }
117
+ function trimWarm(limit) {
118
+ while (warm.size > limit) releaseWarm(warm.keys().next().value)
119
+ }
120
+ function stop(force = true, keepWarm = false) {
121
+ if (!force) return
122
+ if (!keepWarm) clearWarm()
123
+ const old = active
124
+ active = undefined
125
+ if (!old) return
126
+ window.clearTimeout(old.leaveTimer)
127
+ window.clearTimeout(old.delay)
128
+ window.clearTimeout(old.refresh)
129
+ old.unsubscribe?.()
130
+ old.unsubscribeStatus?.()
131
+ if (keepWarm && old.reference && old.source) {
132
+ releaseWarm(old.id)
133
+ const entry = { reference: old.reference, source: old.source, contentCache: old.contentCache }
134
+ entry.timer = window.setTimeout(() => {
135
+ if (warm.get(old.id) === entry) releaseWarm(old.id)
136
+ }, 30000)
137
+ warm.set(old.id, entry)
138
+ trimWarm(cacheLimit)
139
+ } else old.reference?.release()
140
+ old.panel.hidePopover?.()
141
+ old.panel.hidden = true
142
+ old.panel.removeAttribute('data-prts-preview-editing')
143
+ old.panel.setAttribute('role', 'note')
144
+ old.cleanupEditor?.()
145
+ old.cleanupHover?.()
146
+ old.detail?.hidePopover?.()
147
+ old.detail?.remove()
148
+ old.group?.removeAttribute('data-prts-preview-open')
149
+ old.anchor?.removeAttribute('data-prts-preview-active')
150
+ if (old.detached) {
151
+ old.panel.remove()
152
+ if (old.originalParent?.isConnected) old.originalParent.append(old.panel)
153
+ }
154
+ onPreviewClosed?.()
155
+ }
156
+ function show(id, panel, title, anchor) {
157
+ if (active?.id === id) {
158
+ window.clearTimeout(active.leaveTimer)
159
+ return
160
+ }
161
+ stop(true, true)
162
+ const cached = warm.get(id)
163
+ if (cached) { window.clearTimeout(cached.timer); warm.delete(id) }
164
+ // Reserve one slot for the active preview, including a pending acquisition.
165
+ trimWarm(cacheLimit - 1)
166
+ const current = { id, panel, anchor, originalParent: panel.parentElement,
167
+ group: anchor?.closest('[data-prts-session-summary-group]') }
168
+ current.group?.setAttribute('data-prts-preview-open', '')
169
+ anchor?.setAttribute('data-prts-preview-active', '')
170
+ active = current
171
+ current.contentCache = cached?.contentCache ?? createPreviewContentCache(panel.ownerDocument)
172
+ if (cached) { current.reference = cached.reference; current.source = cached.source }
173
+ const keep = () => window.clearTimeout(current.leaveTimer)
174
+ const onDoubleClick = event => {
175
+ if (active !== current || event.button !== 0 || getPreviewPreferences?.().previewDoubleClick === false) return
176
+ if (event.target.closest?.('button, a, input, textarea, select, label, summary, [role="button"], [role="link"], [contenteditable]:not([contenteditable="false"])')) return
177
+ event.preventDefault()
178
+ event.stopPropagation()
179
+ stop()
180
+ openSession?.(id)
181
+ }
182
+ const dismissSticky = event => {
183
+ if (!current.sticky) return
184
+ if (event.type === 'keydown') {
185
+ if (event.key !== 'Escape') return
186
+ event.preventDefault()
187
+ } else if (panel.contains(event.target) || current.anchor?.contains(event.target)) return
188
+ stop()
189
+ }
190
+ panel.ownerDocument.addEventListener('pointerdown', dismissSticky)
191
+ panel.ownerDocument.addEventListener('keydown', dismissSticky)
192
+ panel.addEventListener('dblclick', onDoubleClick)
193
+ panel.addEventListener('pointerenter', keep)
194
+ panel.addEventListener('pointerleave', leave)
195
+ current.cleanupHover = () => {
196
+ panel.ownerDocument.removeEventListener('pointerdown', dismissSticky)
197
+ panel.ownerDocument.removeEventListener('keydown', dismissSticky)
198
+ panel.removeEventListener('dblclick', onDoubleClick)
199
+ panel.removeEventListener('pointerenter', keep)
200
+ panel.removeEventListener('pointerleave', leave)
201
+ }
202
+ panel.removeAttribute('data-prts-preview-editing')
203
+ panel.setAttribute('role', 'note')
204
+ panel.hidden = false
205
+ if (anchor) {
206
+ const list = anchor.closest('[data-prts-session-summary-popover]').getBoundingClientRect()
207
+ const row = anchor.getBoundingClientRect()
208
+ panel.style.left = (list.right + 10) + 'px'
209
+ panel.style.top = Math.max(8, Math.min(row.top, window.innerHeight - 230)) + 'px'
210
+ }
211
+ panel.ownerDocument.body.append(panel)
212
+ current.detached = true
213
+ panel.showPopover?.()
214
+ const label = panel.querySelector('strong')
215
+ let sessionTitle = panel.querySelector('[data-prts-preview-session-title]')
216
+ if (!sessionTitle) {
217
+ sessionTitle = panel.ownerDocument.createElement('div')
218
+ sessionTitle.setAttribute('data-prts-preview-session-title', '')
219
+ panel.prepend(sessionTitle)
220
+ }
221
+ sessionTitle.textContent = title
222
+ sessionTitle.title = title
223
+ let body = panel.querySelector('[data-prts-preview-body]')
224
+ if (!body) {
225
+ body = panel.ownerDocument.createElement('div')
226
+ body.setAttribute('data-prts-preview-body', '')
227
+ panel.querySelector('p').replaceWith(body)
228
+ }
229
+ const choices = panel.ownerDocument.createElement('div')
230
+ choices.setAttribute('data-prts-preview-choices', '')
231
+ panel.querySelector('[data-prts-preview-choices]')?.remove()
232
+ panel.append(choices)
233
+ panel.querySelector('[data-prts-preview-actions]')?.remove()
234
+ const actions = panel.ownerDocument.createElement('div')
235
+ actions.setAttribute('data-prts-preview-actions', '')
236
+ panel.append(actions)
237
+ const pendingNow = () => getSessionStatus?.()?.getSnapshot().get(id)?.pendingInteraction
238
+ const button = (text, action) => {
239
+ const el = panel.ownerDocument.createElement('button')
240
+ el.type = 'button'
241
+ el.textContent = text
242
+ el.addEventListener('click', action)
243
+ return el
244
+ }
245
+ const closeSticky = button('关闭预览', () => stop())
246
+ const enter = button('进入会话', () => { stop(); openSession?.(id) })
247
+ const enableAnswer = () => {
248
+ const pending = pendingNow()
249
+ if (!canAnswerPreview(pending)) return
250
+ current.editor = true
251
+ // Detach from the hover menu before promoting again to the top layer.
252
+ panel.setAttribute('data-prts-preview-editing', '')
253
+ panel.setAttribute('role', 'dialog')
254
+
255
+ const approval = pending.kind === 'approval'
256
+ const question = approval ? {
257
+ options: [{label:'仅允许本次'}, {label:'拒绝'}]
258
+ } : pending.questions[0]
259
+ const submitLabel = approval ? '提交审批' : '提交回答'
260
+ const selected = new Set()
261
+ let busy = false
262
+ const message = panel.ownerDocument.createElement('small')
263
+ message.setAttribute('role', 'status')
264
+ const submit = button(submitLabel, async () => {
265
+ if (busy || !selected.size) return
266
+ if (pendingNow() !== pending || pending.getSnapshot?.().closed || (approval && !pending.answerable)) {
267
+ message.textContent = '问题状态已变化,请重新打开预览。'
268
+ submit.disabled = true
269
+ return
270
+ }
271
+ busy = true
272
+ submit.disabled = true
273
+ submit.textContent = '正在提交…'
274
+ try {
275
+ const channel = pending.getSnapshot?.().channel
276
+ await pending.answer(approval
277
+ ? selected.has('仅允许本次') ? 'allowed-once' : 'rejected'
278
+ : {answers:[{id:question.id,selected:question.options.map(o=>o.label).filter(value=>selected.has(value))}]})
279
+ if (active === current && !follows('warning')) {
280
+ stop(true, true)
281
+ onAnswerSubmitted?.('warning')
282
+ } else if (active === current) {
283
+ current.sticky = true
284
+ keep()
285
+ current.answeredPending = pending
286
+ current.cleanupEditor?.()
287
+ current.cleanupEditor = undefined
288
+ current.validate = undefined
289
+ current.editor = false
290
+ panel.removeAttribute('data-prts-preview-editing')
291
+ panel.setAttribute('role', 'note')
292
+ render()
293
+ }
294
+ if (channel === 'rpc') await pending.dismiss?.()
295
+ } catch {
296
+ if (active !== current) return
297
+ busy = false
298
+ submit.disabled = false
299
+ submit.textContent = submitLabel
300
+ message.textContent = '提交失败,请重试或进入会话处理。'
301
+ }
302
+ })
303
+ submit.disabled = true
304
+ choices.replaceChildren(...question.options.map(option => {
305
+ const choice = button(option.label, () => {
306
+ if (busy) return
307
+ if (question.multiSelect) {
308
+ if (selected.has(option.label)) selected.delete(option.label)
309
+ else selected.add(option.label)
310
+ } else {
311
+ selected.clear()
312
+ selected.add(option.label)
313
+ }
314
+ pending.engage?.()
315
+ for (const el of choices.children) el.setAttribute('aria-pressed', String(selected.has(el.dataset.value)))
316
+ submit.disabled = !selected.size
317
+ })
318
+ choice.dataset.value = option.label
319
+ choice.setAttribute('aria-pressed','false')
320
+ if (option.description) {
321
+ const detail = panel.ownerDocument.createElement('small')
322
+ detail.textContent = option.description
323
+ choice.append(detail)
324
+ }
325
+ return choice
326
+ }))
327
+ choices.hidden = false
328
+ actions.replaceChildren(button('取消', () => stop()), enter, submit, message)
329
+ const onKey = event => { if (event.key === 'Escape') { event.preventDefault(); stop() } }
330
+ panel.addEventListener('keydown', onKey)
331
+ const onEnter = () => pending.holdFocus?.()
332
+ const onOutside = event => { if (!panel.contains(event.target) && !anchor?.contains(event.target)) stop() }
333
+ panel.addEventListener('pointerenter', onEnter)
334
+ panel.ownerDocument.addEventListener('pointerdown', onOutside)
335
+ current.cleanupEditor = () => {
336
+ pending.releaseFocus?.()
337
+ panel.removeEventListener('keydown', onKey)
338
+ panel.removeEventListener('pointerenter', onEnter)
339
+ panel.ownerDocument.removeEventListener('pointerdown', onOutside)
340
+ }
341
+ current.validate = () => {
342
+ if (!busy && (pendingNow() !== pending || (approval && !pending.answerable))) {
343
+ message.textContent = '问题已更新或结束,请进入会话查看。'
344
+ submit.disabled = true
345
+ for (const el of choices.children) el.disabled = true
346
+ }
347
+ }
348
+ }
349
+ let lastChoices = ''
350
+ let source = cached?.source
351
+ let previousText
352
+ let previousQuestion
353
+ let historyKeys = []
354
+ const selectReplies = createPreviewReplySelector()
355
+ const replyLimit = () => getPreviewPreferences?.().previewReplyLimit ?? 5
356
+ let detailKey
357
+ let detailEntry
358
+ const hideDetail = () => {
359
+ current.detail?.hidePopover?.()
360
+ if (current.detail) current.detail.hidden = true
361
+ detailKey = undefined
362
+ detailEntry?.removeAttribute('data-prts-reply-active')
363
+ detailEntry = undefined
364
+ }
365
+ const showDetail = (reply, index, item) => {
366
+ keep()
367
+ if (detailKey === reply.key && current.detail && !current.detail.hidden) return
368
+ if (!current.detail) {
369
+ const detail = panel.ownerDocument.createElement('section')
370
+ detail.setAttribute('data-prts-summary-reply', '')
371
+ detail.setAttribute('data-prts-reply-detail', '')
372
+ detail.setAttribute('popover', 'manual')
373
+ detail.setAttribute('role', 'note')
374
+ const heading = panel.ownerDocument.createElement('strong')
375
+ const content = panel.ownerDocument.createElement('div')
376
+ content.setAttribute('data-prts-preview-body', '')
377
+ detail.append(heading, content)
378
+ panel.append(detail)
379
+ current.detail = detail
380
+ }
381
+ const detail = current.detail
382
+ detailKey = reply.key
383
+ detailEntry?.removeAttribute('data-prts-reply-active')
384
+ detailEntry = item
385
+ item?.setAttribute('data-prts-reply-active', '')
386
+ detail.querySelector('strong').textContent = '回复 ' + (reply.ordinal ?? index + 1)
387
+ const content = detail.querySelector('[data-prts-preview-body]')
388
+ content.innerHTML = current.contentCache(reply.text).html
389
+ content.scrollTop = 0
390
+ detail.hidden = false
391
+ const rect = panel.getBoundingClientRect()
392
+ const width = Math.min(340, window.innerWidth - 16)
393
+ detail.style.width = width + 'px'
394
+ const beside = rect.right + 10 + width <= window.innerWidth - 8
395
+ detail.style.left = (beside ? rect.right + 10 : Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))) + 'px'
396
+ detail.style.top = Math.max(8, Math.min(beside ? rect.top : rect.bottom + 8, window.innerHeight - 240)) + 'px'
397
+ detail.showPopover?.()
398
+ }
399
+ const render = () => {
400
+ if (active !== current) return
401
+ if (current.editor) { current.validate?.(); return }
402
+ const status = getSessionStatus?.()?.getSnapshot().get(id)
403
+ const pending = status?.pendingInteraction === current.answeredPending
404
+ ? undefined : status?.pendingInteraction
405
+ const approval = pending?.kind === 'approval'
406
+ const question = approval || pending?.kind === 'question' || pending?.kind === 'plan-review'
407
+ const state = question ? 'warning' : status?.running || (current.answeredPending && status?.pendingInteraction === current.answeredPending) ? 'ongoing'
408
+ : status?.completionUnread || status?.running === false ? 'done' : current.group?.getAttribute('data-prts-session-summary-group') || ''
409
+ if (current.state && current.state !== state && !follows(current.state)) return
410
+ if (current.state && current.state !== state) {
411
+ current.sticky = true
412
+ keep()
413
+ }
414
+ current.state = state
415
+ panel.setAttribute('data-prts-preview-state', state)
416
+ label.textContent = approval ? '等待审批 · 仅本次授权' : question ? '等待你的选择' : state === 'ongoing' ? '运行中 · 已完成正文' : state === 'done' ? '已完成 · 最后回复' : '最新 AI 回复'
417
+ panel.toggleAttribute('data-prts-preview-question', question)
418
+ if (question) {
419
+ if (!actions.contains(enter)) actions.replaceChildren(enter)
420
+ } else if (current.sticky) {
421
+ if (!actions.contains(closeSticky)) actions.replaceChildren(closeSticky)
422
+ } else actions.replaceChildren()
423
+ const first = question ? pending.questions?.[0] : undefined
424
+ const labels = (first?.options ?? []).slice(0, 3).map(option => option.label)
425
+ const remaining = Math.max(0, (first?.options?.length ?? 0) - 3)
426
+ if (remaining) labels.push('另有 ' + remaining + ' 个选项')
427
+ if ((pending?.questions?.length ?? 0) > 1) labels.push('共 ' + pending.questions.length + ' 个问题')
428
+ const signature = JSON.stringify(labels)
429
+ if (signature !== lastChoices) {
430
+ lastChoices = signature
431
+ choices.replaceChildren(...labels.map(text => {
432
+ const option = panel.ownerDocument.createElement('span')
433
+ option.textContent = text
434
+ return option
435
+ }))
436
+ }
437
+ choices.hidden = !labels.length
438
+ panel.setAttribute('aria-label', title + ':' + label.textContent)
439
+ if (state === 'ongoing' && source) {
440
+ const replies = selectReplies(source.getSnapshot(), replyLimit())
441
+ if (replies === historyKeys) return
442
+ panel.toggleAttribute('data-prts-preview-empty', !replies.length)
443
+ const previousTop = body.scrollTop
444
+ const scrollAnchor = previousTop > 0 ? [...body.children].find(entry => entry.getBoundingClientRect().bottom > body.getBoundingClientRect().top) : undefined
445
+ const anchorTop = scrollAnchor?.getBoundingClientRect().top
446
+ const retained = new Map(historyKeys.map(reply => [reply.key, reply]))
447
+ for (const old of historyKeys) {
448
+ const replacement = replies.find(reply => reply.key === old.key)
449
+ if (!replacement || replacement.text !== old.text || replacement.ordinal !== old.ordinal) {
450
+ body.querySelectorAll('[data-prts-preview-message]').forEach(entry => {
451
+ if (entry.dataset.replyKey === old.key) entry.remove()
452
+ })
453
+ retained.delete(old.key)
454
+ if (detailKey === old.key) hideDetail()
455
+ }
456
+ }
457
+ if (!historyKeys.length) body.replaceChildren()
458
+ if (!replies.length) body.textContent = '正在处理,等待本轮正文输出完成…'
459
+ for (let index = 0; index < replies.length; index++) {
460
+ const reply = replies[index]
461
+ if (retained.has(reply.key)) continue
462
+ const entry = panel.ownerDocument.createElement('article')
463
+ entry.setAttribute('data-prts-preview-message', '')
464
+ entry.dataset.replyKey = reply.key
465
+ const item = panel.ownerDocument.createElement('button')
466
+ item.type = 'button'
467
+ item.setAttribute('data-prts-reply-entry', '')
468
+ const heading = panel.ownerDocument.createElement('small')
469
+ heading.textContent = '回复 ' + reply.ordinal
470
+ const summary = panel.ownerDocument.createElement('span')
471
+ summary.textContent = current.contentCache(reply.text).summary
472
+ item.append(heading, summary)
473
+ item.addEventListener('pointermove', () => showDetail(reply, index, item))
474
+ item.addEventListener('focus', () => showDetail(reply, index, item))
475
+ item.addEventListener('click', () => showDetail(reply, index, item))
476
+ entry.append(item)
477
+ body.prepend(entry)
478
+ }
479
+ // Reorder only when necessary, preserving retained DOM and open details.
480
+ const entries = new Map([...body.children].map(entry => [entry.dataset.replyKey, entry]))
481
+ for (let i = replies.length - 1, position = 0; i >= 0; i--, position++) {
482
+ const entry = entries.get(replies[i].key)
483
+ if (body.children[position] !== entry) body.insertBefore(entry, body.children[position] ?? null)
484
+ }
485
+ if (scrollAnchor?.isConnected) body.scrollTop += scrollAnchor.getBoundingClientRect().top - anchorTop
486
+ else if (previousTop === 0) body.scrollTop = 0
487
+ historyKeys = replies
488
+ previousText = undefined
489
+ previousQuestion = undefined
490
+ return
491
+ }
492
+ hideDetail()
493
+ historyKeys = []
494
+ const excerpt = source ? latestAssistantReply(source.getSnapshot()) : ''
495
+ panel.toggleAttribute('data-prts-preview-empty', !question && !excerpt)
496
+ const text = question
497
+ ? approval
498
+ ? [pending.toolName, pending.displayReason?.['zh-CN'] || pending.displayReason?.zh || pending.reason || pending.displayReason?.en || '请进入会话查看操作详情'].filter(Boolean).join(':')
499
+ : first?.question || pendingQuestionPreview(pending)
500
+ : source ? excerpt || (state === 'ongoing' ? '正在处理,尚未输出正文…' : '暂无正文回复,可进入会话查看执行记录。') : '正在读取…'
501
+ if (previousText !== text || previousQuestion !== question) {
502
+ const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
503
+ if (question) body.textContent = text
504
+ else body.innerHTML = current.contentCache(text).html
505
+ previousText = text
506
+ previousQuestion = question
507
+ if (!extendsReply) body.scrollTop = 0
508
+ }
509
+ if (canAnswerPreview(pending)) enableAnswer()
510
+ }
511
+ let statusFields = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
512
+ current.unsubscribeStatus = getSessionStatus?.()?.subscribe(() => {
513
+ const next = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
514
+ if (next.every((value, index) => value === statusFields[index])) return
515
+ statusFields = next
516
+ render()
517
+ })
518
+ render()
519
+ body.scrollTop = 0
520
+ current.delay = window.setTimeout(async () => {
521
+ try {
522
+ const service = getUiConversation?.()
523
+ const controller = getSessions?.() ?? sessions
524
+ if (!controller?.retain || !service?.binding) throw new Error('unavailable')
525
+ if (!current.reference) current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
526
+ const binding = await current.reference.ready
527
+ if (active !== current) return
528
+ source ??= service.binding(binding).target('chat')
529
+ current.source = source
530
+ current.unsubscribe = source.subscribe(() => {
531
+ if (active !== current || current.refresh !== undefined) return
532
+ current.refresh = window.setTimeout(() => {
533
+ current.refresh = undefined
534
+ if (current.state === 'ongoing' && !current.editor && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys) return
535
+ render()
536
+ }, 120)
537
+ })
538
+ render()
539
+ } catch {
540
+ if (active !== current) return
541
+ current.unsubscribe?.()
542
+ current.unsubscribe = undefined
543
+ current.reference?.release()
544
+ current.reference = undefined
545
+ render()
546
+ if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
547
+ }
548
+ }, cached ? 0 : 80)
549
+ }
550
+ function leave() {
551
+ if (!active || active.sticky) return
552
+ const current = active
553
+ window.clearTimeout(current.leaveTimer)
554
+ current.leaveTimer = window.setTimeout(() => {
555
+ if (active === current && !current.panel.matches(':hover')) stop(true, true)
556
+ }, 300)
557
+ }
558
+ function reanchor(root) {
559
+ if (!active) return
560
+ const anchor = [...root.querySelectorAll('[data-prts-session-summary-item]')]
561
+ .find(item => item.getAttribute('data-prts-session-summary-item') === active.id)
562
+ active.group?.removeAttribute('data-prts-preview-open')
563
+ active.anchor?.removeAttribute('data-prts-preview-active')
564
+ active.anchor = anchor
565
+ active.group = anchor?.closest('[data-prts-session-summary-group]')
566
+ active.originalParent = undefined
567
+ active.group?.setAttribute('data-prts-preview-open', '')
568
+ anchor?.setAttribute('data-prts-preview-active', '')
569
+ window.clearTimeout(active.leaveTimer)
570
+ }
571
+ function enterState(state) {
572
+ if (active && active.state !== state) stop(true, true)
573
+ }
574
+ function pinnedState() {
575
+ return active && !follows(active.state)
576
+ ? { id: active.id, state: active.state } : undefined
577
+ }
578
+ return { show, stop, leave, reanchor, enterState, pinnedState, isOpen: () => Boolean(active) }
579
+ }
580
+
581
+ export function scrollSummaryTitle(button, viewport, text, window) {
582
+ let animation
583
+ const stop = () => { animation?.cancel(); animation = undefined }
584
+ const start = () => {
585
+ stop()
586
+ const distance = text.scrollWidth - viewport.clientWidth
587
+ if (distance <= 8 || !text.animate) return
588
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
589
+ animation = text.animate([
590
+ { transform: 'translateX(0)' },
591
+ { transform: `translateX(-${distance}px)` },
592
+ ], { duration: reduced ? 0 : distance / 30 * 1000, fill: 'forwards', easing: 'linear' })
593
+ }
594
+ button.addEventListener('pointerenter', start)
595
+ button.addEventListener('pointerleave', stop)
596
+ button.addEventListener('focus', start)
597
+ button.addEventListener('blur', stop)
598
+ return stop
599
+ }