@ash-qw/dsh-theme-prts 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
@@ -96,11 +96,10 @@ export function applyPrtsPlugin(ctx, environment) {
96
96
  const sidebarControls = createSidebarControlAdapter({
97
97
  document,
98
98
  window,
99
- sessions: contextService(ctx, 'sessions'),
100
- workspaces: contextService(ctx, 'workspaces'),
101
99
  getSessionStatus: () => contextService(ctx, 'uiSession')?.sessionStatus,
102
100
  getUiWorkspace: () => contextService(ctx, 'uiWorkspace'),
103
101
  getSessions: () => contextService(ctx, 'sessions'),
102
+ getWorkspaces: () => contextService(ctx, 'workspaces'),
104
103
  getUiConversation: () => contextService(ctx, 'uiConversation'),
105
104
  })
106
105
  const floatingGlass = createFloatingGlassAdapter({ document, window })
@@ -34,7 +34,10 @@ export const DEFAULT_PREFERENCES = Object.freeze({
34
34
  previewOngoing: true,
35
35
  previewWarning: true,
36
36
  previewDone: true,
37
+ previewFollowOngoing: true,
38
+ previewFollowWarning: false,
37
39
  previewDoubleClick: true,
40
+ previewReplyLimit: 5,
38
41
 
39
42
  sessionFlow: true,
40
43
  sessionGlow: true,
@@ -86,7 +89,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
86
89
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
87
90
  material: Object.freeze(['glass']),
88
91
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
89
- navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick']),
92
+ navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
90
93
  conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'conversationEmblem']),
91
94
  })
92
95
 
@@ -207,6 +210,9 @@ export function normalizePreferences(value) {
207
210
  previewOngoing: booleanOr(input.previewOngoing, DEFAULT_PREFERENCES.previewOngoing),
208
211
  previewWarning: booleanOr(input.previewWarning, DEFAULT_PREFERENCES.previewWarning),
209
212
  previewDone: booleanOr(input.previewDone, DEFAULT_PREFERENCES.previewDone),
213
+ previewReplyLimit: [0, 5, 10, '0', '5', '10'].includes(input.previewReplyLimit) ? Number(input.previewReplyLimit) : 5,
214
+ previewFollowOngoing: booleanOr(input.previewFollowOngoing, booleanOr(input.previewFollowState, true)),
215
+ previewFollowWarning: booleanOr(input.previewFollowWarning, booleanOr(input.previewFollowState, false)),
210
216
  previewDoubleClick: booleanOr(input.previewDoubleClick, DEFAULT_PREFERENCES.previewDoubleClick),
211
217
  conversationEmblem: booleanOr(input.conversationEmblem, DEFAULT_PREFERENCES.conversationEmblem),
212
218
  bakerGroupAvatars: booleanOr(input.bakerGroupAvatars, DEFAULT_PREFERENCES.bakerGroupAvatars),
@@ -16,3 +16,29 @@ export function renderPreviewMarkdown(text) {
16
16
  }
17
17
  return previewMarkdownParser.render(text)
18
18
  }
19
+
20
+ // Bounded per retained session; oversized replies are rendered without caching.
21
+ export function createPreviewContentCache(document, render = renderPreviewMarkdown) {
22
+ const entries = new Map()
23
+ let size = 0
24
+ return text => {
25
+ const hit = entries.get(text)
26
+ if (hit) { entries.delete(text); entries.set(text, hit); return hit }
27
+ const html = render(text)
28
+ const plain = document.createElement('div')
29
+ plain.innerHTML = html
30
+ const summary = plain.textContent.replace(/\s+/g, ' ').trim().slice(0, 100)
31
+ const cost = text.length + html.length + summary.length
32
+ const value = {html, summary, cost}
33
+ if (cost <= 262144) {
34
+ while (entries.size && (entries.size >= 32 || size + cost > 262144)) {
35
+ const key = entries.keys().next().value
36
+ size -= entries.get(key).cost
37
+ entries.delete(key)
38
+ }
39
+ entries.set(text, value)
40
+ size += cost
41
+ }
42
+ return value
43
+ }
44
+ }
@@ -138,7 +138,7 @@ export function createSessionLifelineAnimator({ document, window }) {
138
138
  return Boolean(
139
139
  row?.isConnected
140
140
  && lifeline?.isConnected
141
- && row.getAttribute('aria-selected') === 'true'
141
+ && (row.hasAttribute('data-prts-selection-target') ? row.getAttribute('data-prts-selection-target') === 'true' : row.getAttribute('aria-selected') === 'true')
142
142
  && document?.visibilityState !== 'hidden'
143
143
  && document?.documentElement?.dataset?.prtsSessionFlow !== 'off'
144
144
  && document?.documentElement?.dataset?.prtsMotion !== 'reduced'
@@ -1,4 +1,4 @@
1
- import { renderPreviewMarkdown } from './preview-markdown.js'
1
+ import { createPreviewContentCache } from './preview-markdown.js'
2
2
  export function canAnswerPreview(pending) {
3
3
  if (pending?.kind === 'approval') return pending.answerable === true && typeof pending.answer === 'function'
4
4
  const question = pending?.questions?.[0]
@@ -42,7 +42,9 @@ export function latestAssistantReply(snapshot) {
42
42
  export function completedPreviewReplies(snapshot) {
43
43
  const replies = []
44
44
  const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
45
- for (const key of [...(snapshot?.order ?? [])].reverse()) {
45
+ const order = snapshot?.order ?? []
46
+ for (let i = order.length - 1; i >= 0; i--) {
47
+ const key = order[i]
46
48
  const node = snapshot.nodes?.get?.(key)
47
49
  if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) break
48
50
  if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
@@ -53,10 +55,89 @@ export function completedPreviewReplies(snapshot) {
53
55
  return replies.reverse()
54
56
  }
55
57
 
56
- export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences }) {
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?.callId, pending?.toolName, pending?.reason, pending?.displayReason])]
95
+ }
96
+
97
+ export function approvalCallDetails(snapshot, pending) {
98
+ if (!pending?.callId) return undefined
99
+ for (const node of snapshot?.nodes?.values?.() ?? []) {
100
+ const root = node.kind === 'tool-call' ? node.data?.root : undefined
101
+ if (!root || root.callId !== pending.callId || 'kind' in root || root.phase !== 'start') continue
102
+ const raw = root.argsRaw
103
+ if (typeof raw !== 'string') return undefined
104
+ try {
105
+ const args = JSON.parse(raw)
106
+ if (args && typeof args === 'object' && !Array.isArray(args) && typeof args.command === 'string') {
107
+ const {command, ...rest} = args
108
+ return {command, parameters: Object.keys(rest).length ? JSON.stringify(rest, null, 2) : ''}
109
+ }
110
+ return {parameters: JSON.stringify(args, null, 2)}
111
+ } catch { return {parameters: raw} }
112
+ }
113
+ }
114
+
115
+ export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences, onPreviewClosed, onAnswerSubmitted }) {
57
116
  let active
58
- function stop(force = true) {
59
- if (!force && active) return
117
+ function follows(state) {
118
+ const preferences = getPreviewPreferences?.() ?? {}
119
+ return state === 'warning' ? preferences.previewFollowWarning === true : preferences.previewFollowOngoing !== false
120
+ }
121
+ // Shared across running, pending and completed previews. Active ownership is
122
+ // outside the LRU so it can never be evicted by an idle timeout.
123
+ const warm = new Map()
124
+ const cacheLimit = 5
125
+ function releaseWarm(id) {
126
+ const entry = warm.get(id)
127
+ if (!entry) return
128
+ warm.delete(id)
129
+ window.clearTimeout(entry.timer)
130
+ entry.reference.release()
131
+ }
132
+ function clearWarm() {
133
+ for (const id of warm.keys()) releaseWarm(id)
134
+ }
135
+ function trimWarm(limit) {
136
+ while (warm.size > limit) releaseWarm(warm.keys().next().value)
137
+ }
138
+ function stop(force = true, keepWarm = false) {
139
+ if (!force) return
140
+ if (!keepWarm) clearWarm()
60
141
  const old = active
61
142
  active = undefined
62
143
  if (!old) return
@@ -65,7 +146,15 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
65
146
  window.clearTimeout(old.refresh)
66
147
  old.unsubscribe?.()
67
148
  old.unsubscribeStatus?.()
68
- old.reference?.release()
149
+ if (keepWarm && old.reference && old.source) {
150
+ releaseWarm(old.id)
151
+ const entry = { reference: old.reference, source: old.source, contentCache: old.contentCache }
152
+ entry.timer = window.setTimeout(() => {
153
+ if (warm.get(old.id) === entry) releaseWarm(old.id)
154
+ }, 30000)
155
+ warm.set(old.id, entry)
156
+ trimWarm(cacheLimit)
157
+ } else old.reference?.release()
69
158
  old.panel.hidePopover?.()
70
159
  old.panel.hidden = true
71
160
  old.panel.removeAttribute('data-prts-preview-editing')
@@ -80,19 +169,25 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
80
169
  old.panel.remove()
81
170
  if (old.originalParent?.isConnected) old.originalParent.append(old.panel)
82
171
  }
172
+ onPreviewClosed?.()
83
173
  }
84
174
  function show(id, panel, title, anchor) {
85
- if (active?.sticky && active.id !== id) return
86
175
  if (active?.id === id) {
87
176
  window.clearTimeout(active.leaveTimer)
88
177
  return
89
178
  }
90
- stop()
179
+ stop(true, true)
180
+ const cached = warm.get(id)
181
+ if (cached) { window.clearTimeout(cached.timer); warm.delete(id) }
182
+ // Reserve one slot for the active preview, including a pending acquisition.
183
+ trimWarm(cacheLimit - 1)
91
184
  const current = { id, panel, anchor, originalParent: panel.parentElement,
92
185
  group: anchor?.closest('[data-prts-session-summary-group]') }
93
186
  current.group?.setAttribute('data-prts-preview-open', '')
94
187
  anchor?.setAttribute('data-prts-preview-active', '')
95
188
  active = current
189
+ current.contentCache = cached?.contentCache ?? createPreviewContentCache(panel.ownerDocument)
190
+ if (cached) { current.reference = cached.reference; current.source = cached.source }
96
191
  const keep = () => window.clearTimeout(current.leaveTimer)
97
192
  const onDoubleClick = event => {
98
193
  if (active !== current || event.button !== 0 || getPreviewPreferences?.().previewDoubleClick === false) return
@@ -134,7 +229,82 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
134
229
  panel.ownerDocument.body.append(panel)
135
230
  current.detached = true
136
231
  panel.showPopover?.()
232
+ let positionFrame
233
+ const positionPanels = () => {
234
+ positionFrame = undefined
235
+ if (active !== current) return
236
+ const viewport = window.visualViewport
237
+ const left = (viewport?.offsetLeft ?? 0) + 8, top = (viewport?.offsetTop ?? 0) + 8
238
+ const width = Math.max(1, (viewport?.width ?? window.innerWidth) - 16)
239
+ const height = Math.max(1, (viewport?.height ?? window.innerHeight) - 16)
240
+ const place = (surface, reference, preferredTop, noticeOwner, anchorBottom = preferredTop) => {
241
+ surface.style.maxWidth = 'none'
242
+ surface.style.maxHeight = 'none'
243
+ const bounds = surface.getBoundingClientRect()
244
+ const x = reference ? reference.right + 10 : left
245
+ const fitsVertically = y => y >= top && y + bounds.height <= top + height
246
+ const above = anchorBottom - bounds.height
247
+ const y = !fitsVertically(preferredTop) && fitsVertically(above) ? above : preferredTop
248
+ const insufficient = Boolean(reference) && (x < left || x + bounds.width > left + width || !fitsVertically(y))
249
+ surface.toggleAttribute('data-prts-preview-no-space', insufficient)
250
+ const noticeKey = surface === panel ? 'spaceNotice' : 'detailSpaceNotice'
251
+ if (insufficient && noticeOwner) {
252
+ let notice = current[noticeKey]
253
+ if (!notice) {
254
+ notice = panel.ownerDocument.createElement('div')
255
+ notice.setAttribute('data-prts-preview-space-notice', '')
256
+ notice.setAttribute('role', 'status')
257
+ const guidance = '空间不足,无法展开预览。可点击会话进入;建议扩大窗口,或在辅助功能中关闭对应预览。'
258
+ notice.textContent = surface === panel ? '空间不足,无法展开正文预览' : guidance
259
+ notice.title = guidance
260
+ current[noticeKey] = notice
261
+ }
262
+ if (notice.parentElement !== noticeOwner) noticeOwner.append(notice)
263
+ } else { current[noticeKey]?.remove(); current[noticeKey] = undefined }
264
+ surface.style.left = x + 'px'
265
+ surface.style.top = y + 'px'
266
+ return !insufficient
267
+ }
268
+ const menu = current.anchor?.closest('[data-prts-session-summary-popover]')
269
+ const reference = menu?.getBoundingClientRect()
270
+ const row = current.anchor?.getBoundingClientRect()
271
+ const visible = place(panel, reference, row?.top ?? (parseFloat(panel.style.top) || top), menu, row?.bottom)
272
+ if (current.detail && !current.detail.hidden) {
273
+ if (visible) place(current.detail, panel.getBoundingClientRect(), panel.getBoundingClientRect().top, panel, panel.getBoundingClientRect().bottom)
274
+ else current.detail.setAttribute('data-prts-preview-no-space', '')
275
+ }
276
+
277
+ }
278
+ const schedulePosition = () => {
279
+ if (positionFrame === undefined) positionFrame = window.requestAnimationFrame(positionPanels)
280
+ }
281
+ const positionObserver = window.ResizeObserver ? new window.ResizeObserver(schedulePosition) : undefined
282
+ positionObserver?.observe(panel)
283
+ window.addEventListener('resize', schedulePosition)
284
+ window.visualViewport?.addEventListener('resize', schedulePosition)
285
+ window.visualViewport?.addEventListener('scroll', schedulePosition)
286
+ const cleanupHover = current.cleanupHover
287
+ current.cleanupHover = () => {
288
+ cleanupHover()
289
+ current.spaceNotice?.remove()
290
+ current.detailSpaceNotice?.remove()
291
+ panel.removeAttribute('data-prts-preview-no-space')
292
+ positionObserver?.disconnect()
293
+ window.cancelAnimationFrame(positionFrame)
294
+ window.removeEventListener('resize', schedulePosition)
295
+ window.visualViewport?.removeEventListener('resize', schedulePosition)
296
+ window.visualViewport?.removeEventListener('scroll', schedulePosition)
297
+ }
298
+ schedulePosition()
137
299
  const label = panel.querySelector('strong')
300
+ let sessionTitle = panel.querySelector('[data-prts-preview-session-title]')
301
+ if (!sessionTitle) {
302
+ sessionTitle = panel.ownerDocument.createElement('div')
303
+ sessionTitle.setAttribute('data-prts-preview-session-title', '')
304
+ panel.prepend(sessionTitle)
305
+ }
306
+ sessionTitle.textContent = title
307
+ sessionTitle.title = title
138
308
  let body = panel.querySelector('[data-prts-preview-body]')
139
309
  if (!body) {
140
310
  body = panel.ownerDocument.createElement('div')
@@ -163,6 +333,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
163
333
  const pending = pendingNow()
164
334
  if (!canAnswerPreview(pending)) return
165
335
  current.editor = true
336
+ current.editingPending = pending
166
337
  // Detach from the hover menu before promoting again to the top layer.
167
338
  panel.setAttribute('data-prts-preview-editing', '')
168
339
  panel.setAttribute('role', 'dialog')
@@ -191,7 +362,10 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
191
362
  await pending.answer(approval
192
363
  ? selected.has('仅允许本次') ? 'allowed-once' : 'rejected'
193
364
  : {answers:[{id:question.id,selected:question.options.map(o=>o.label).filter(value=>selected.has(value))}]})
194
- if (active === current) {
365
+ if (active === current && !follows('warning')) {
366
+ stop(true, true)
367
+ onAnswerSubmitted?.('warning')
368
+ } else if (active === current) {
195
369
  current.sticky = true
196
370
  keep()
197
371
  current.answeredPending = pending
@@ -259,15 +433,19 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
259
433
  }
260
434
  }
261
435
  let lastChoices = ''
262
- let source
436
+ let source = cached?.source
263
437
  let previousText
264
438
  let previousQuestion
265
439
  let historyKeys = []
440
+ const selectReplies = createPreviewReplySelector()
441
+ const replyLimit = () => getPreviewPreferences?.().previewReplyLimit ?? 5
266
442
  let detailKey
267
443
  let detailEntry
268
444
  const hideDetail = () => {
269
445
  current.detail?.hidePopover?.()
270
446
  if (current.detail) current.detail.hidden = true
447
+ current.detailSpaceNotice?.remove()
448
+ current.detailSpaceNotice = undefined
271
449
  detailKey = undefined
272
450
  detailEntry?.removeAttribute('data-prts-reply-active')
273
451
  detailEntry = undefined
@@ -287,15 +465,16 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
287
465
  detail.append(heading, content)
288
466
  panel.append(detail)
289
467
  current.detail = detail
468
+ positionObserver?.observe(detail)
290
469
  }
291
470
  const detail = current.detail
292
471
  detailKey = reply.key
293
472
  detailEntry?.removeAttribute('data-prts-reply-active')
294
473
  detailEntry = item
295
474
  item?.setAttribute('data-prts-reply-active', '')
296
- detail.querySelector('strong').textContent = '回复 ' + (index + 1)
475
+ detail.querySelector('strong').textContent = '回复 ' + (reply.ordinal ?? index + 1)
297
476
  const content = detail.querySelector('[data-prts-preview-body]')
298
- content.innerHTML = renderPreviewMarkdown(reply.text)
477
+ content.innerHTML = current.contentCache(reply.text).html
299
478
  content.scrollTop = 0
300
479
  detail.hidden = false
301
480
  const rect = panel.getBoundingClientRect()
@@ -305,17 +484,42 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
305
484
  detail.style.left = (beside ? rect.right + 10 : Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))) + 'px'
306
485
  detail.style.top = Math.max(8, Math.min(beside ? rect.top : rect.bottom + 8, window.innerHeight - 240)) + 'px'
307
486
  detail.showPopover?.()
487
+ schedulePosition()
488
+ }
489
+ const renderApprovalDetails = pending => {
490
+ if (pending?.kind !== 'approval') return
491
+ const details = approvalCallDetails(source?.getSnapshot(), pending)
492
+ let section = body.querySelector('[data-prts-approval-details]')
493
+ const signature = JSON.stringify(details ?? null)
494
+ if (section?.dataset.signature === signature) return
495
+ if (!section) { section = document.createElement('div'); section.setAttribute('data-prts-approval-details', ''); body.append(section) }
496
+ section.dataset.signature = signature
497
+ section.replaceChildren()
498
+ if (!details) {
499
+ section.textContent = source ? '暂无可用的操作参数,请进入会话核对详情。' : '正在读取操作详情…'
500
+ return
501
+ }
502
+ for (const [name, value] of [['命令', details.command], ['参数', details.parameters]]) {
503
+ if (!value) continue
504
+ const heading = document.createElement('div')
505
+ heading.textContent = name
506
+ const code = document.createElement('pre')
507
+ code.textContent = value
508
+ section.append(heading, code)
509
+ }
308
510
  }
309
511
  const render = () => {
310
512
  if (active !== current) return
311
- if (current.editor) { current.validate?.(); return }
513
+ if (current.editor) { current.validate?.(); if (pendingNow() === current.editingPending) renderApprovalDetails(current.editingPending); return }
312
514
  const status = getSessionStatus?.()?.getSnapshot().get(id)
313
515
  const pending = status?.pendingInteraction === current.answeredPending
314
516
  ? undefined : status?.pendingInteraction
315
517
  const approval = pending?.kind === 'approval'
518
+ panel.toggleAttribute('data-prts-preview-approval', approval)
316
519
  const question = approval || pending?.kind === 'question' || pending?.kind === 'plan-review'
317
520
  const state = question ? 'warning' : status?.running || (current.answeredPending && status?.pendingInteraction === current.answeredPending) ? 'ongoing'
318
521
  : status?.completionUnread || status?.running === false ? 'done' : current.group?.getAttribute('data-prts-session-summary-group') || ''
522
+ if (current.state && current.state !== state && !follows(current.state)) return
319
523
  if (current.state && current.state !== state) {
320
524
  current.sticky = true
321
525
  keep()
@@ -346,39 +550,53 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
346
550
  choices.hidden = !labels.length
347
551
  panel.setAttribute('aria-label', title + ':' + label.textContent)
348
552
  if (state === 'ongoing' && source) {
349
- const replies = completedPreviewReplies(source.getSnapshot())
553
+ const replies = selectReplies(source.getSnapshot(), replyLimit())
554
+ if (replies === historyKeys) return
350
555
  panel.toggleAttribute('data-prts-preview-empty', !replies.length)
351
- const extendsHistory = historyKeys.length > 0 && historyKeys.every((entry,index) => replies[index]?.key === entry.key && replies[index]?.text === entry.text)
352
- if (!extendsHistory) {
353
- hideDetail()
354
- body.replaceChildren()
355
- body.scrollTop = 0
356
- historyKeys = []
556
+ const previousTop = body.scrollTop
557
+ const scrollAnchor = previousTop > 0 ? [...body.children].find(entry => entry.getBoundingClientRect().bottom > body.getBoundingClientRect().top) : undefined
558
+ const anchorTop = scrollAnchor?.getBoundingClientRect().top
559
+ const retained = new Map(historyKeys.map(reply => [reply.key, reply]))
560
+ for (const old of historyKeys) {
561
+ const replacement = replies.find(reply => reply.key === old.key)
562
+ if (!replacement || replacement.text !== old.text || replacement.ordinal !== old.ordinal) {
563
+ body.querySelectorAll('[data-prts-preview-message]').forEach(entry => {
564
+ if (entry.dataset.replyKey === old.key) entry.remove()
565
+ })
566
+ retained.delete(old.key)
567
+ if (detailKey === old.key) hideDetail()
568
+ }
357
569
  }
570
+ if (!historyKeys.length) body.replaceChildren()
358
571
  if (!replies.length) body.textContent = '正在处理,等待本轮正文输出完成…'
359
- const previousHeight = body.scrollHeight
360
- const previousTop = body.scrollTop
361
- for (let index = historyKeys.length; index < replies.length; index++) {
572
+ for (let index = 0; index < replies.length; index++) {
573
+ const reply = replies[index]
574
+ if (retained.has(reply.key)) continue
362
575
  const entry = panel.ownerDocument.createElement('article')
363
576
  entry.setAttribute('data-prts-preview-message', '')
577
+ entry.dataset.replyKey = reply.key
364
578
  const item = panel.ownerDocument.createElement('button')
365
579
  item.type = 'button'
366
580
  item.setAttribute('data-prts-reply-entry', '')
367
581
  const heading = panel.ownerDocument.createElement('small')
368
- heading.textContent = '回复 ' + (index + 1)
582
+ heading.textContent = '回复 ' + reply.ordinal
369
583
  const summary = panel.ownerDocument.createElement('span')
370
- const plain = panel.ownerDocument.createElement('div')
371
- plain.innerHTML = renderPreviewMarkdown(replies[index].text)
372
- summary.textContent = plain.textContent.replace(/\s+/g, ' ').trim().slice(0, 100)
584
+ summary.textContent = current.contentCache(reply.text).summary
373
585
  item.append(heading, summary)
374
- item.addEventListener('pointermove', () => showDetail(replies[index], index, item))
375
- item.addEventListener('focus', () => showDetail(replies[index], index, item))
376
- item.addEventListener('click', () => showDetail(replies[index], index, item))
586
+ item.addEventListener('pointermove', () => showDetail(reply, index, item))
587
+ item.addEventListener('focus', () => showDetail(reply, index, item))
588
+ item.addEventListener('click', () => showDetail(reply, index, item))
377
589
  entry.append(item)
378
590
  body.prepend(entry)
379
591
  }
380
- if (previousTop > 0) body.scrollTop = previousTop + body.scrollHeight - previousHeight
381
- else body.scrollTop = 0
592
+ // Reorder only when necessary, preserving retained DOM and open details.
593
+ const entries = new Map([...body.children].map(entry => [entry.dataset.replyKey, entry]))
594
+ for (let i = replies.length - 1, position = 0; i >= 0; i--, position++) {
595
+ const entry = entries.get(replies[i].key)
596
+ if (body.children[position] !== entry) body.insertBefore(entry, body.children[position] ?? null)
597
+ }
598
+ if (scrollAnchor?.isConnected) body.scrollTop += scrollAnchor.getBoundingClientRect().top - anchorTop
599
+ else if (previousTop === 0) body.scrollTop = 0
382
600
  historyKeys = replies
383
601
  previousText = undefined
384
602
  previousQuestion = undefined
@@ -396,14 +614,21 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
396
614
  if (previousText !== text || previousQuestion !== question) {
397
615
  const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
398
616
  if (question) body.textContent = text
399
- else body.innerHTML = renderPreviewMarkdown(text)
617
+ else body.innerHTML = current.contentCache(text).html
400
618
  previousText = text
401
619
  previousQuestion = question
402
620
  if (!extendsReply) body.scrollTop = 0
403
621
  }
622
+ if (approval) renderApprovalDetails(pending)
404
623
  if (canAnswerPreview(pending)) enableAnswer()
405
624
  }
406
- current.unsubscribeStatus = getSessionStatus?.()?.subscribe(render)
625
+ let statusFields = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
626
+ current.unsubscribeStatus = getSessionStatus?.()?.subscribe(() => {
627
+ const next = previewStatusFields(getSessionStatus?.()?.getSnapshot().get(id))
628
+ if (next.every((value, index) => value === statusFields[index])) return
629
+ statusFields = next
630
+ render()
631
+ })
407
632
  render()
408
633
  body.scrollTop = 0
409
634
  current.delay = window.setTimeout(async () => {
@@ -411,14 +636,16 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
411
636
  const service = getUiConversation?.()
412
637
  const controller = getSessions?.() ?? sessions
413
638
  if (!controller?.retain || !service?.binding) throw new Error('unavailable')
414
- current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
639
+ if (!current.reference) current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
415
640
  const binding = await current.reference.ready
416
641
  if (active !== current) return
417
- source = service.binding(binding).target('chat')
642
+ source ??= service.binding(binding).target('chat')
643
+ current.source = source
418
644
  current.unsubscribe = source.subscribe(() => {
419
645
  if (active !== current || current.refresh !== undefined) return
420
646
  current.refresh = window.setTimeout(() => {
421
647
  current.refresh = undefined
648
+ if (current.state === 'ongoing' && !current.editor && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys) return
422
649
  render()
423
650
  }, 120)
424
651
  })
@@ -432,14 +659,14 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
432
659
  render()
433
660
  if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
434
661
  }
435
- }, 80)
662
+ }, cached ? 0 : 80)
436
663
  }
437
664
  function leave() {
438
665
  if (!active || active.sticky) return
439
666
  const current = active
440
667
  window.clearTimeout(current.leaveTimer)
441
668
  current.leaveTimer = window.setTimeout(() => {
442
- if (active === current && !current.panel.matches(':hover')) stop()
669
+ if (active === current && !current.panel.matches(':hover')) stop(true, true)
443
670
  }, 300)
444
671
  }
445
672
  function reanchor(root) {
@@ -455,7 +682,14 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
455
682
  anchor?.setAttribute('data-prts-preview-active', '')
456
683
  window.clearTimeout(active.leaveTimer)
457
684
  }
458
- return { show, stop, leave, reanchor }
685
+ function enterState(state) {
686
+ if (active && active.state !== state) stop(true, true)
687
+ }
688
+ function pinnedState() {
689
+ return active && !follows(active.state)
690
+ ? { id: active.id, state: active.state } : undefined
691
+ }
692
+ return { show, stop, leave, reanchor, enterState, pinnedState, isOpen: () => Boolean(active) }
459
693
  }
460
694
 
461
695
  export function scrollSummaryTitle(button, viewport, text, window) {