@ash-qw/dsh-theme-prts 1.4.0 → 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.
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.1",
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",
@@ -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
+ }
@@ -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,71 @@ 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?.toolName, pending?.reason, pending?.displayReason])]
95
+ }
96
+
97
+ export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences, onPreviewClosed, onAnswerSubmitted }) {
57
98
  let active
58
- function stop(force = true) {
59
- if (!force && active) return
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()
60
123
  const old = active
61
124
  active = undefined
62
125
  if (!old) return
@@ -65,7 +128,15 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
65
128
  window.clearTimeout(old.refresh)
66
129
  old.unsubscribe?.()
67
130
  old.unsubscribeStatus?.()
68
- old.reference?.release()
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()
69
140
  old.panel.hidePopover?.()
70
141
  old.panel.hidden = true
71
142
  old.panel.removeAttribute('data-prts-preview-editing')
@@ -80,19 +151,25 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
80
151
  old.panel.remove()
81
152
  if (old.originalParent?.isConnected) old.originalParent.append(old.panel)
82
153
  }
154
+ onPreviewClosed?.()
83
155
  }
84
156
  function show(id, panel, title, anchor) {
85
- if (active?.sticky && active.id !== id) return
86
157
  if (active?.id === id) {
87
158
  window.clearTimeout(active.leaveTimer)
88
159
  return
89
160
  }
90
- stop()
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)
91
166
  const current = { id, panel, anchor, originalParent: panel.parentElement,
92
167
  group: anchor?.closest('[data-prts-session-summary-group]') }
93
168
  current.group?.setAttribute('data-prts-preview-open', '')
94
169
  anchor?.setAttribute('data-prts-preview-active', '')
95
170
  active = current
171
+ current.contentCache = cached?.contentCache ?? createPreviewContentCache(panel.ownerDocument)
172
+ if (cached) { current.reference = cached.reference; current.source = cached.source }
96
173
  const keep = () => window.clearTimeout(current.leaveTimer)
97
174
  const onDoubleClick = event => {
98
175
  if (active !== current || event.button !== 0 || getPreviewPreferences?.().previewDoubleClick === false) return
@@ -135,6 +212,14 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
135
212
  current.detached = true
136
213
  panel.showPopover?.()
137
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
138
223
  let body = panel.querySelector('[data-prts-preview-body]')
139
224
  if (!body) {
140
225
  body = panel.ownerDocument.createElement('div')
@@ -191,7 +276,10 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
191
276
  await pending.answer(approval
192
277
  ? selected.has('仅允许本次') ? 'allowed-once' : 'rejected'
193
278
  : {answers:[{id:question.id,selected:question.options.map(o=>o.label).filter(value=>selected.has(value))}]})
194
- if (active === current) {
279
+ if (active === current && !follows('warning')) {
280
+ stop(true, true)
281
+ onAnswerSubmitted?.('warning')
282
+ } else if (active === current) {
195
283
  current.sticky = true
196
284
  keep()
197
285
  current.answeredPending = pending
@@ -259,10 +347,12 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
259
347
  }
260
348
  }
261
349
  let lastChoices = ''
262
- let source
350
+ let source = cached?.source
263
351
  let previousText
264
352
  let previousQuestion
265
353
  let historyKeys = []
354
+ const selectReplies = createPreviewReplySelector()
355
+ const replyLimit = () => getPreviewPreferences?.().previewReplyLimit ?? 5
266
356
  let detailKey
267
357
  let detailEntry
268
358
  const hideDetail = () => {
@@ -293,9 +383,9 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
293
383
  detailEntry?.removeAttribute('data-prts-reply-active')
294
384
  detailEntry = item
295
385
  item?.setAttribute('data-prts-reply-active', '')
296
- detail.querySelector('strong').textContent = '回复 ' + (index + 1)
386
+ detail.querySelector('strong').textContent = '回复 ' + (reply.ordinal ?? index + 1)
297
387
  const content = detail.querySelector('[data-prts-preview-body]')
298
- content.innerHTML = renderPreviewMarkdown(reply.text)
388
+ content.innerHTML = current.contentCache(reply.text).html
299
389
  content.scrollTop = 0
300
390
  detail.hidden = false
301
391
  const rect = panel.getBoundingClientRect()
@@ -316,6 +406,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
316
406
  const question = approval || pending?.kind === 'question' || pending?.kind === 'plan-review'
317
407
  const state = question ? 'warning' : status?.running || (current.answeredPending && status?.pendingInteraction === current.answeredPending) ? 'ongoing'
318
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
319
410
  if (current.state && current.state !== state) {
320
411
  current.sticky = true
321
412
  keep()
@@ -346,39 +437,53 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
346
437
  choices.hidden = !labels.length
347
438
  panel.setAttribute('aria-label', title + ':' + label.textContent)
348
439
  if (state === 'ongoing' && source) {
349
- const replies = completedPreviewReplies(source.getSnapshot())
440
+ const replies = selectReplies(source.getSnapshot(), replyLimit())
441
+ if (replies === historyKeys) return
350
442
  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 = []
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
+ }
357
456
  }
457
+ if (!historyKeys.length) body.replaceChildren()
358
458
  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++) {
459
+ for (let index = 0; index < replies.length; index++) {
460
+ const reply = replies[index]
461
+ if (retained.has(reply.key)) continue
362
462
  const entry = panel.ownerDocument.createElement('article')
363
463
  entry.setAttribute('data-prts-preview-message', '')
464
+ entry.dataset.replyKey = reply.key
364
465
  const item = panel.ownerDocument.createElement('button')
365
466
  item.type = 'button'
366
467
  item.setAttribute('data-prts-reply-entry', '')
367
468
  const heading = panel.ownerDocument.createElement('small')
368
- heading.textContent = '回复 ' + (index + 1)
469
+ heading.textContent = '回复 ' + reply.ordinal
369
470
  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)
471
+ summary.textContent = current.contentCache(reply.text).summary
373
472
  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))
473
+ item.addEventListener('pointermove', () => showDetail(reply, index, item))
474
+ item.addEventListener('focus', () => showDetail(reply, index, item))
475
+ item.addEventListener('click', () => showDetail(reply, index, item))
377
476
  entry.append(item)
378
477
  body.prepend(entry)
379
478
  }
380
- if (previousTop > 0) body.scrollTop = previousTop + body.scrollHeight - previousHeight
381
- else body.scrollTop = 0
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
382
487
  historyKeys = replies
383
488
  previousText = undefined
384
489
  previousQuestion = undefined
@@ -396,14 +501,20 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
396
501
  if (previousText !== text || previousQuestion !== question) {
397
502
  const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
398
503
  if (question) body.textContent = text
399
- else body.innerHTML = renderPreviewMarkdown(text)
504
+ else body.innerHTML = current.contentCache(text).html
400
505
  previousText = text
401
506
  previousQuestion = question
402
507
  if (!extendsReply) body.scrollTop = 0
403
508
  }
404
509
  if (canAnswerPreview(pending)) enableAnswer()
405
510
  }
406
- current.unsubscribeStatus = getSessionStatus?.()?.subscribe(render)
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
+ })
407
518
  render()
408
519
  body.scrollTop = 0
409
520
  current.delay = window.setTimeout(async () => {
@@ -411,14 +522,16 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
411
522
  const service = getUiConversation?.()
412
523
  const controller = getSessions?.() ?? sessions
413
524
  if (!controller?.retain || !service?.binding) throw new Error('unavailable')
414
- current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
525
+ if (!current.reference) current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
415
526
  const binding = await current.reference.ready
416
527
  if (active !== current) return
417
- source = service.binding(binding).target('chat')
528
+ source ??= service.binding(binding).target('chat')
529
+ current.source = source
418
530
  current.unsubscribe = source.subscribe(() => {
419
531
  if (active !== current || current.refresh !== undefined) return
420
532
  current.refresh = window.setTimeout(() => {
421
533
  current.refresh = undefined
534
+ if (current.state === 'ongoing' && !current.editor && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys) return
422
535
  render()
423
536
  }, 120)
424
537
  })
@@ -432,14 +545,14 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
432
545
  render()
433
546
  if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
434
547
  }
435
- }, 80)
548
+ }, cached ? 0 : 80)
436
549
  }
437
550
  function leave() {
438
551
  if (!active || active.sticky) return
439
552
  const current = active
440
553
  window.clearTimeout(current.leaveTimer)
441
554
  current.leaveTimer = window.setTimeout(() => {
442
- if (active === current && !current.panel.matches(':hover')) stop()
555
+ if (active === current && !current.panel.matches(':hover')) stop(true, true)
443
556
  }, 300)
444
557
  }
445
558
  function reanchor(root) {
@@ -455,7 +568,14 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
455
568
  anchor?.setAttribute('data-prts-preview-active', '')
456
569
  window.clearTimeout(active.leaveTimer)
457
570
  }
458
- return { show, stop, leave, reanchor }
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) }
459
579
  }
460
580
 
461
581
  export function scrollSummaryTitle(button, viewport, text, window) {
@@ -203,9 +203,21 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
203
203
  let statusSourcesStarted = false
204
204
  let summaryRoot
205
205
  let summarySignature = ''
206
- const preview = createSessionSummaryPreview({ document, window, sessions, getUiConversation, getSessions, getSessionStatus, openSession, getPreviewPreferences: () => previewPreferences })
207
- let stopTitles = []
208
- function clearSummaryEffects() { preview.stop(false); for (const stop of stopTitles) stop(); stopTitles = [] }
206
+ let heldPreviewState
207
+ function dismissHeldPreview(event) {
208
+ if (!heldPreviewState || (event?.type === 'keydown' && event.key !== 'Escape')) return
209
+ if (event?.type === 'pointerdown' && (summaryRoot?.contains(event.target) || event.target.closest?.('[data-prts-summary-reply]'))) return
210
+ heldPreviewState = undefined
211
+ queueScan()
212
+ }
213
+ function enterPreviewState(state) {
214
+ if (heldPreviewState && heldPreviewState !== state) { heldPreviewState = undefined; queueScan() }
215
+ preview.enterState(state)
216
+ }
217
+ const preview = createSessionSummaryPreview({ document, window, sessions, getUiConversation, getSessions, getSessionStatus, openSession, getPreviewPreferences: () => previewPreferences, onPreviewClosed: () => { if (statusSourcesStarted) queueScan() }, onAnswerSubmitted: state => { heldPreviewState = state; queueScan() } })
218
+ const stopTitles = new Map()
219
+ const summaryGroups = new Map()
220
+ function clearSummaryEffects() { preview.stop(false); for (const stop of stopTitles.values()) stop(); stopTitles.clear(); summaryGroups.clear() }
209
221
  let owned = new Set()
210
222
  let vectorOwners = new Set()
211
223
  const dirtyVectors = new Map()
@@ -221,6 +233,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
221
233
  }
222
234
 
223
235
  function openSession(sessionId) {
236
+ dismissHeldPreview()
224
237
  try {
225
238
  const uiWorkspace = getUiWorkspace?.()
226
239
  if (typeof uiWorkspace?.openSession === 'function') {
@@ -261,6 +274,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
261
274
  count.setAttribute('data-prts-session-summary-count', '')
262
275
  count.textContent = String(items.length)
263
276
  trigger.append(count)
277
+ trigger.addEventListener('pointerenter', () => enterPreviewState(state))
278
+ trigger.addEventListener('focus', () => enterPreviewState(state))
264
279
 
265
280
  const popover = document.createElement('span')
266
281
  popover.setAttribute('data-prts-session-summary-popover', state)
@@ -287,7 +302,35 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
287
302
  const list = document.createElement('span')
288
303
  list.setAttribute('data-prts-summary-list', '')
289
304
  popover.append(list)
290
- for (const item of items) {
305
+ const entries = new Map()
306
+ const update = items => {
307
+ count.textContent = String(items.length)
308
+ trigger.setAttribute('aria-label', `${label} ${items.length}`)
309
+ const ids = new Set(items.map(item => item.id))
310
+ for (const [id, entry] of entries) {
311
+ if (ids.has(id)) continue
312
+ stopTitles.get(entry.button)?.()
313
+ stopTitles.delete(entry.button)
314
+ entry.button.remove()
315
+ entries.delete(id)
316
+ }
317
+ for (const [index, incoming] of items.entries()) {
318
+ const existing = entries.get(incoming.id)
319
+ if (existing) {
320
+ if (existing.item.title !== incoming.title) {
321
+ stopTitles.get(existing.button)?.()
322
+ existing.button.querySelector('[data-prts-summary-title] > span').textContent = incoming.title
323
+ }
324
+ Object.assign(existing.item, incoming)
325
+ let detail = existing.button.querySelector('[data-prts-session-summary-item-status]')
326
+ if (incoming.statusLabel && !detail) { detail = document.createElement('small'); detail.setAttribute('data-prts-session-summary-item-status',''); existing.button.append(detail) }
327
+ if (incoming.statusLabel) detail.textContent = incoming.statusLabel
328
+ else detail?.remove()
329
+ existing.button.setAttribute('aria-label', incoming.statusLabel ? `${incoming.title},${incoming.statusLabel}` : incoming.title)
330
+ if (list.children[index] !== existing.button) list.insertBefore(existing.button, list.children[index] ?? null)
331
+ continue
332
+ }
333
+ const item = {...incoming}
291
334
  const button = document.createElement('button')
292
335
  button.type = 'button'
293
336
  button.setAttribute('role', 'menuitem')
@@ -299,14 +342,18 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
299
342
  text.textContent = item.title
300
343
  title.append(text)
301
344
  button.append(title)
345
+ const viewing = document.createElement('em')
346
+ viewing.setAttribute('data-prts-preview-marker', '')
347
+ viewing.textContent = '预览中'
348
+ button.append(viewing)
302
349
  button.setAttribute('aria-label', item.title)
303
- stopTitles.push(scrollSummaryTitle(button, title, text, window))
350
+ stopTitles.set(button, scrollSummaryTitle(button, title, text, window))
304
351
  const showReply = () => {
305
352
  const key = {ongoing:'previewOngoing',warning:'previewWarning',done:'previewDone'}[state]
306
353
  if (previewPreferences[key] === false) { preview.stop(); return }
307
354
  preview.show(item.id, reply, item.title, button)
308
355
  }
309
- button.addEventListener('pointerenter', showReply)
356
+ button.addEventListener('pointermove', showReply)
310
357
  button.addEventListener('pointerleave', () => preview.leave())
311
358
  button.addEventListener('focus', showReply)
312
359
  if (item.statusLabel) {
@@ -321,8 +368,15 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
321
368
  preview.stop()
322
369
  openSession(item.id)
323
370
  })
324
- list.append(button)
371
+ list.insertBefore(button, list.children[index] ?? null)
372
+ entries.set(item.id, {item, button})
373
+ }
325
374
  }
375
+ update(items)
376
+ summaryGroups.set(state, {group, update, dispose: () => {
377
+ for (const {button} of entries.values()) { stopTitles.get(button)?.(); stopTitles.delete(button) }
378
+ group.remove()
379
+ }})
326
380
  popover.append(reply)
327
381
  group.append(trigger, popover)
328
382
  return group
@@ -331,6 +385,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
331
385
  function syncSummary(root, next) {
332
386
  const { header, label } = resolveSectionHeader(root)
333
387
  if (previewPreferences.sessionPreview === false || !header || !label) {
388
+ heldPreviewState = undefined
334
389
  preview.stop()
335
390
  clearSummaryEffects()
336
391
  summaryRoot?.remove()
@@ -361,10 +416,31 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
361
416
  rows[index] = { ...rows[index], state: nativeRow.state }
362
417
  }
363
418
  }
364
- const warning = rows.filter(item => item.state === 'warning')
365
- const ongoing = rows.filter(item => item.state === 'ongoing')
366
- const done = rows.filter(item => item.state === 'done')
419
+ // Do not move the row/menu to another status behind a frozen preview.
420
+ const pinned = preview.pinnedState()
421
+ if (pinned?.state) {
422
+ const item = rows.find(item => item.id === pinned.id)
423
+ if (item) { item.state = pinned.state; item.statusLabel = undefined }
424
+ }
425
+ // While reading a preview, preserve surviving rows' positions and append
426
+ // newcomers. No persistent ordering: the next idle sync uses host order.
427
+ const orderedState = state => {
428
+ const items = rows.filter(item => item.state === state)
429
+ if (!preview.isOpen() || !summaryRoot) return items
430
+ const remaining = new Map(items.map(item => [item.id, item]))
431
+ const stable = []
432
+ for (const row of summaryRoot.querySelectorAll(`[data-prts-session-summary-group="${state}"] [data-prts-session-summary-item]`)) {
433
+ const id = row.getAttribute('data-prts-session-summary-item')
434
+ if (remaining.has(id)) { stable.push(remaining.get(id)); remaining.delete(id) }
435
+ }
436
+ return [...stable, ...remaining.values()]
437
+ }
438
+ const warning = orderedState('warning')
439
+ const ongoing = orderedState('ongoing')
440
+ const done = orderedState('done')
441
+ if (heldPreviewState && !rows.some(item => item.state === heldPreviewState)) heldPreviewState = undefined
367
442
  const signature = JSON.stringify({
443
+ heldPreviewState,
368
444
  warning: warning.map(item => [item.id, item.title, item.statusLabel]),
369
445
  ongoing: ongoing.map(item => [item.id, item.title]),
370
446
  done: done.map(item => [item.id, item.title]),
@@ -384,12 +460,20 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
384
460
  summaryRoot.hidden = warning.length === 0 && ongoing.length === 0 && done.length === 0
385
461
  if (signature === summarySignature) return
386
462
  summarySignature = signature
387
- clearSummaryEffects()
388
- summaryRoot.replaceChildren()
389
- if (warning.length) summaryRoot.append(buildSummaryGroup('warning', '等待处理的会话', warning))
390
- if (ongoing.length) summaryRoot.append(buildSummaryGroup('ongoing', '运行中的会话', ongoing))
391
- if (done.length) summaryRoot.append(buildSummaryGroup('done', '完成未查看', done))
463
+ for (const [state, title, items] of [['warning','等待处理的会话',warning],['ongoing','运行中的会话',ongoing],['done','完成未查看',done]]) {
464
+ const existing = summaryGroups.get(state)
465
+ if (!items.length) { existing?.dispose(); summaryGroups.delete(state); continue }
466
+ if (existing) existing.update(items)
467
+ else summaryRoot.append(buildSummaryGroup(state, title, items))
468
+ }
469
+ let position = 0
470
+ for (const state of ['warning','ongoing','done']) {
471
+ const group = summaryGroups.get(state)?.group
472
+ if (group && summaryRoot.children[position++] !== group) summaryRoot.insertBefore(group, summaryRoot.children[position - 1] ?? null)
473
+ group?.removeAttribute('data-prts-preview-open')
474
+ }
392
475
  preview.reanchor(summaryRoot)
476
+ if (heldPreviewState) summaryRoot.querySelector(`[data-prts-session-summary-group="${heldPreviewState}"]`)?.setAttribute('data-prts-preview-open', '')
393
477
  }
394
478
 
395
479
  function scheduleVectors() {
@@ -567,7 +651,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
567
651
 
568
652
  return {
569
653
  start(preferences = {}) {
570
- const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick']
654
+ const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']
571
655
  if (keys.some(key => previewPreferences[key] !== preferences[key])) preview.stop()
572
656
  previewPreferences = Object.fromEntries(keys.map(key => [key, preferences[key]]))
573
657
  if (!document) return
@@ -585,6 +669,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
585
669
  flushVectors()
586
670
  })
587
671
  }
672
+ document.addEventListener('pointerdown', dismissHeldPreview)
673
+ document.addEventListener('keydown', dismissHeldPreview)
588
674
  scan()
589
675
  if (!statusSourcesStarted) {
590
676
  statusSourcesStarted = true
@@ -605,6 +691,9 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
605
691
  })
606
692
  },
607
693
  dispose() {
694
+ heldPreviewState = undefined
695
+ document?.removeEventListener('pointerdown', dismissHeldPreview)
696
+ document?.removeEventListener('keydown', dismissHeldPreview)
608
697
  observer?.disconnect()
609
698
  observer = undefined
610
699
  stopSessionList()