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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ import { renderPreviewMarkdown } 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
+ for (const key of [...(snapshot?.order ?? [])].reverse()) {
46
+ const node = snapshot.nodes?.get?.(key)
47
+ if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) break
48
+ if (latestTurn !== undefined && node?.location?.turn?.turn !== latestTurn) continue
49
+ if (node?.kind !== 'assistant-step' || node.visibility === 'hidden' || node.data?.status !== 'settled') continue
50
+ const text = (node.data.blocks ?? []).filter(block => block.kind === 'text').map(block => block.text ?? '').join('').trim()
51
+ if (text) replies.push({key, text})
52
+ }
53
+ return replies.reverse()
54
+ }
55
+
56
+ export function createSessionSummaryPreview({ document, window, sessions, getSessions, getUiConversation, getSessionStatus, openSession, getPreviewPreferences }) {
57
+ let active
58
+ function stop(force = true) {
59
+ if (!force && active) return
60
+ const old = active
61
+ active = undefined
62
+ if (!old) return
63
+ window.clearTimeout(old.leaveTimer)
64
+ window.clearTimeout(old.delay)
65
+ window.clearTimeout(old.refresh)
66
+ old.unsubscribe?.()
67
+ old.unsubscribeStatus?.()
68
+ old.reference?.release()
69
+ old.panel.hidePopover?.()
70
+ old.panel.hidden = true
71
+ old.panel.removeAttribute('data-prts-preview-editing')
72
+ old.panel.setAttribute('role', 'note')
73
+ old.cleanupEditor?.()
74
+ old.cleanupHover?.()
75
+ old.detail?.hidePopover?.()
76
+ old.detail?.remove()
77
+ old.group?.removeAttribute('data-prts-preview-open')
78
+ old.anchor?.removeAttribute('data-prts-preview-active')
79
+ if (old.detached) {
80
+ old.panel.remove()
81
+ if (old.originalParent?.isConnected) old.originalParent.append(old.panel)
82
+ }
83
+ }
84
+ function show(id, panel, title, anchor) {
85
+ if (active?.sticky && active.id !== id) return
86
+ if (active?.id === id) {
87
+ window.clearTimeout(active.leaveTimer)
88
+ return
89
+ }
90
+ stop()
91
+ const current = { id, panel, anchor, originalParent: panel.parentElement,
92
+ group: anchor?.closest('[data-prts-session-summary-group]') }
93
+ current.group?.setAttribute('data-prts-preview-open', '')
94
+ anchor?.setAttribute('data-prts-preview-active', '')
95
+ active = current
96
+ const keep = () => window.clearTimeout(current.leaveTimer)
97
+ const onDoubleClick = event => {
98
+ if (active !== current || event.button !== 0 || getPreviewPreferences?.().previewDoubleClick === false) return
99
+ if (event.target.closest?.('button, a, input, textarea, select, label, summary, [role="button"], [role="link"], [contenteditable]:not([contenteditable="false"])')) return
100
+ event.preventDefault()
101
+ event.stopPropagation()
102
+ stop()
103
+ openSession?.(id)
104
+ }
105
+ const dismissSticky = event => {
106
+ if (!current.sticky) return
107
+ if (event.type === 'keydown') {
108
+ if (event.key !== 'Escape') return
109
+ event.preventDefault()
110
+ } else if (panel.contains(event.target) || current.anchor?.contains(event.target)) return
111
+ stop()
112
+ }
113
+ panel.ownerDocument.addEventListener('pointerdown', dismissSticky)
114
+ panel.ownerDocument.addEventListener('keydown', dismissSticky)
115
+ panel.addEventListener('dblclick', onDoubleClick)
116
+ panel.addEventListener('pointerenter', keep)
117
+ panel.addEventListener('pointerleave', leave)
118
+ current.cleanupHover = () => {
119
+ panel.ownerDocument.removeEventListener('pointerdown', dismissSticky)
120
+ panel.ownerDocument.removeEventListener('keydown', dismissSticky)
121
+ panel.removeEventListener('dblclick', onDoubleClick)
122
+ panel.removeEventListener('pointerenter', keep)
123
+ panel.removeEventListener('pointerleave', leave)
124
+ }
125
+ panel.removeAttribute('data-prts-preview-editing')
126
+ panel.setAttribute('role', 'note')
127
+ panel.hidden = false
128
+ if (anchor) {
129
+ const list = anchor.closest('[data-prts-session-summary-popover]').getBoundingClientRect()
130
+ const row = anchor.getBoundingClientRect()
131
+ panel.style.left = (list.right + 10) + 'px'
132
+ panel.style.top = Math.max(8, Math.min(row.top, window.innerHeight - 230)) + 'px'
133
+ }
134
+ panel.ownerDocument.body.append(panel)
135
+ current.detached = true
136
+ panel.showPopover?.()
137
+ const label = panel.querySelector('strong')
138
+ let body = panel.querySelector('[data-prts-preview-body]')
139
+ if (!body) {
140
+ body = panel.ownerDocument.createElement('div')
141
+ body.setAttribute('data-prts-preview-body', '')
142
+ panel.querySelector('p').replaceWith(body)
143
+ }
144
+ const choices = panel.ownerDocument.createElement('div')
145
+ choices.setAttribute('data-prts-preview-choices', '')
146
+ panel.querySelector('[data-prts-preview-choices]')?.remove()
147
+ panel.append(choices)
148
+ panel.querySelector('[data-prts-preview-actions]')?.remove()
149
+ const actions = panel.ownerDocument.createElement('div')
150
+ actions.setAttribute('data-prts-preview-actions', '')
151
+ panel.append(actions)
152
+ const pendingNow = () => getSessionStatus?.()?.getSnapshot().get(id)?.pendingInteraction
153
+ const button = (text, action) => {
154
+ const el = panel.ownerDocument.createElement('button')
155
+ el.type = 'button'
156
+ el.textContent = text
157
+ el.addEventListener('click', action)
158
+ return el
159
+ }
160
+ const closeSticky = button('关闭预览', () => stop())
161
+ const enter = button('进入会话', () => { stop(); openSession?.(id) })
162
+ const enableAnswer = () => {
163
+ const pending = pendingNow()
164
+ if (!canAnswerPreview(pending)) return
165
+ current.editor = true
166
+ // Detach from the hover menu before promoting again to the top layer.
167
+ panel.setAttribute('data-prts-preview-editing', '')
168
+ panel.setAttribute('role', 'dialog')
169
+
170
+ const approval = pending.kind === 'approval'
171
+ const question = approval ? {
172
+ options: [{label:'仅允许本次'}, {label:'拒绝'}]
173
+ } : pending.questions[0]
174
+ const submitLabel = approval ? '提交审批' : '提交回答'
175
+ const selected = new Set()
176
+ let busy = false
177
+ const message = panel.ownerDocument.createElement('small')
178
+ message.setAttribute('role', 'status')
179
+ const submit = button(submitLabel, async () => {
180
+ if (busy || !selected.size) return
181
+ if (pendingNow() !== pending || pending.getSnapshot?.().closed || (approval && !pending.answerable)) {
182
+ message.textContent = '问题状态已变化,请重新打开预览。'
183
+ submit.disabled = true
184
+ return
185
+ }
186
+ busy = true
187
+ submit.disabled = true
188
+ submit.textContent = '正在提交…'
189
+ try {
190
+ const channel = pending.getSnapshot?.().channel
191
+ await pending.answer(approval
192
+ ? selected.has('仅允许本次') ? 'allowed-once' : 'rejected'
193
+ : {answers:[{id:question.id,selected:question.options.map(o=>o.label).filter(value=>selected.has(value))}]})
194
+ if (active === current) {
195
+ current.sticky = true
196
+ keep()
197
+ current.answeredPending = pending
198
+ current.cleanupEditor?.()
199
+ current.cleanupEditor = undefined
200
+ current.validate = undefined
201
+ current.editor = false
202
+ panel.removeAttribute('data-prts-preview-editing')
203
+ panel.setAttribute('role', 'note')
204
+ render()
205
+ }
206
+ if (channel === 'rpc') await pending.dismiss?.()
207
+ } catch {
208
+ if (active !== current) return
209
+ busy = false
210
+ submit.disabled = false
211
+ submit.textContent = submitLabel
212
+ message.textContent = '提交失败,请重试或进入会话处理。'
213
+ }
214
+ })
215
+ submit.disabled = true
216
+ choices.replaceChildren(...question.options.map(option => {
217
+ const choice = button(option.label, () => {
218
+ if (busy) return
219
+ if (question.multiSelect) {
220
+ if (selected.has(option.label)) selected.delete(option.label)
221
+ else selected.add(option.label)
222
+ } else {
223
+ selected.clear()
224
+ selected.add(option.label)
225
+ }
226
+ pending.engage?.()
227
+ for (const el of choices.children) el.setAttribute('aria-pressed', String(selected.has(el.dataset.value)))
228
+ submit.disabled = !selected.size
229
+ })
230
+ choice.dataset.value = option.label
231
+ choice.setAttribute('aria-pressed','false')
232
+ if (option.description) {
233
+ const detail = panel.ownerDocument.createElement('small')
234
+ detail.textContent = option.description
235
+ choice.append(detail)
236
+ }
237
+ return choice
238
+ }))
239
+ choices.hidden = false
240
+ actions.replaceChildren(button('取消', () => stop()), enter, submit, message)
241
+ const onKey = event => { if (event.key === 'Escape') { event.preventDefault(); stop() } }
242
+ panel.addEventListener('keydown', onKey)
243
+ const onEnter = () => pending.holdFocus?.()
244
+ const onOutside = event => { if (!panel.contains(event.target) && !anchor?.contains(event.target)) stop() }
245
+ panel.addEventListener('pointerenter', onEnter)
246
+ panel.ownerDocument.addEventListener('pointerdown', onOutside)
247
+ current.cleanupEditor = () => {
248
+ pending.releaseFocus?.()
249
+ panel.removeEventListener('keydown', onKey)
250
+ panel.removeEventListener('pointerenter', onEnter)
251
+ panel.ownerDocument.removeEventListener('pointerdown', onOutside)
252
+ }
253
+ current.validate = () => {
254
+ if (!busy && (pendingNow() !== pending || (approval && !pending.answerable))) {
255
+ message.textContent = '问题已更新或结束,请进入会话查看。'
256
+ submit.disabled = true
257
+ for (const el of choices.children) el.disabled = true
258
+ }
259
+ }
260
+ }
261
+ let lastChoices = ''
262
+ let source
263
+ let previousText
264
+ let previousQuestion
265
+ let historyKeys = []
266
+ let detailKey
267
+ let detailEntry
268
+ const hideDetail = () => {
269
+ current.detail?.hidePopover?.()
270
+ if (current.detail) current.detail.hidden = true
271
+ detailKey = undefined
272
+ detailEntry?.removeAttribute('data-prts-reply-active')
273
+ detailEntry = undefined
274
+ }
275
+ const showDetail = (reply, index, item) => {
276
+ keep()
277
+ if (detailKey === reply.key && current.detail && !current.detail.hidden) return
278
+ if (!current.detail) {
279
+ const detail = panel.ownerDocument.createElement('section')
280
+ detail.setAttribute('data-prts-summary-reply', '')
281
+ detail.setAttribute('data-prts-reply-detail', '')
282
+ detail.setAttribute('popover', 'manual')
283
+ detail.setAttribute('role', 'note')
284
+ const heading = panel.ownerDocument.createElement('strong')
285
+ const content = panel.ownerDocument.createElement('div')
286
+ content.setAttribute('data-prts-preview-body', '')
287
+ detail.append(heading, content)
288
+ panel.append(detail)
289
+ current.detail = detail
290
+ }
291
+ const detail = current.detail
292
+ detailKey = reply.key
293
+ detailEntry?.removeAttribute('data-prts-reply-active')
294
+ detailEntry = item
295
+ item?.setAttribute('data-prts-reply-active', '')
296
+ detail.querySelector('strong').textContent = '回复 ' + (index + 1)
297
+ const content = detail.querySelector('[data-prts-preview-body]')
298
+ content.innerHTML = renderPreviewMarkdown(reply.text)
299
+ content.scrollTop = 0
300
+ detail.hidden = false
301
+ const rect = panel.getBoundingClientRect()
302
+ const width = Math.min(340, window.innerWidth - 16)
303
+ detail.style.width = width + 'px'
304
+ const beside = rect.right + 10 + width <= window.innerWidth - 8
305
+ detail.style.left = (beside ? rect.right + 10 : Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))) + 'px'
306
+ detail.style.top = Math.max(8, Math.min(beside ? rect.top : rect.bottom + 8, window.innerHeight - 240)) + 'px'
307
+ detail.showPopover?.()
308
+ }
309
+ const render = () => {
310
+ if (active !== current) return
311
+ if (current.editor) { current.validate?.(); return }
312
+ const status = getSessionStatus?.()?.getSnapshot().get(id)
313
+ const pending = status?.pendingInteraction === current.answeredPending
314
+ ? undefined : status?.pendingInteraction
315
+ const approval = pending?.kind === 'approval'
316
+ const question = approval || pending?.kind === 'question' || pending?.kind === 'plan-review'
317
+ const state = question ? 'warning' : status?.running || (current.answeredPending && status?.pendingInteraction === current.answeredPending) ? 'ongoing'
318
+ : status?.completionUnread || status?.running === false ? 'done' : current.group?.getAttribute('data-prts-session-summary-group') || ''
319
+ if (current.state && current.state !== state) {
320
+ current.sticky = true
321
+ keep()
322
+ }
323
+ current.state = state
324
+ panel.setAttribute('data-prts-preview-state', state)
325
+ label.textContent = approval ? '等待审批 · 仅本次授权' : question ? '等待你的选择' : state === 'ongoing' ? '运行中 · 已完成正文' : state === 'done' ? '已完成 · 最后回复' : '最新 AI 回复'
326
+ panel.toggleAttribute('data-prts-preview-question', question)
327
+ if (question) {
328
+ if (!actions.contains(enter)) actions.replaceChildren(enter)
329
+ } else if (current.sticky) {
330
+ if (!actions.contains(closeSticky)) actions.replaceChildren(closeSticky)
331
+ } else actions.replaceChildren()
332
+ const first = question ? pending.questions?.[0] : undefined
333
+ const labels = (first?.options ?? []).slice(0, 3).map(option => option.label)
334
+ const remaining = Math.max(0, (first?.options?.length ?? 0) - 3)
335
+ if (remaining) labels.push('另有 ' + remaining + ' 个选项')
336
+ if ((pending?.questions?.length ?? 0) > 1) labels.push('共 ' + pending.questions.length + ' 个问题')
337
+ const signature = JSON.stringify(labels)
338
+ if (signature !== lastChoices) {
339
+ lastChoices = signature
340
+ choices.replaceChildren(...labels.map(text => {
341
+ const option = panel.ownerDocument.createElement('span')
342
+ option.textContent = text
343
+ return option
344
+ }))
345
+ }
346
+ choices.hidden = !labels.length
347
+ panel.setAttribute('aria-label', title + ':' + label.textContent)
348
+ if (state === 'ongoing' && source) {
349
+ const replies = completedPreviewReplies(source.getSnapshot())
350
+ 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 = []
357
+ }
358
+ 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++) {
362
+ const entry = panel.ownerDocument.createElement('article')
363
+ entry.setAttribute('data-prts-preview-message', '')
364
+ const item = panel.ownerDocument.createElement('button')
365
+ item.type = 'button'
366
+ item.setAttribute('data-prts-reply-entry', '')
367
+ const heading = panel.ownerDocument.createElement('small')
368
+ heading.textContent = '回复 ' + (index + 1)
369
+ 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)
373
+ 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))
377
+ entry.append(item)
378
+ body.prepend(entry)
379
+ }
380
+ if (previousTop > 0) body.scrollTop = previousTop + body.scrollHeight - previousHeight
381
+ else body.scrollTop = 0
382
+ historyKeys = replies
383
+ previousText = undefined
384
+ previousQuestion = undefined
385
+ return
386
+ }
387
+ hideDetail()
388
+ historyKeys = []
389
+ const excerpt = source ? latestAssistantReply(source.getSnapshot()) : ''
390
+ panel.toggleAttribute('data-prts-preview-empty', !question && !excerpt)
391
+ const text = question
392
+ ? approval
393
+ ? [pending.toolName, pending.displayReason?.['zh-CN'] || pending.displayReason?.zh || pending.reason || pending.displayReason?.en || '请进入会话查看操作详情'].filter(Boolean).join(':')
394
+ : first?.question || pendingQuestionPreview(pending)
395
+ : source ? excerpt || (state === 'ongoing' ? '正在处理,尚未输出正文…' : '暂无正文回复,可进入会话查看执行记录。') : '正在读取…'
396
+ if (previousText !== text || previousQuestion !== question) {
397
+ const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
398
+ if (question) body.textContent = text
399
+ else body.innerHTML = renderPreviewMarkdown(text)
400
+ previousText = text
401
+ previousQuestion = question
402
+ if (!extendsReply) body.scrollTop = 0
403
+ }
404
+ if (canAnswerPreview(pending)) enableAnswer()
405
+ }
406
+ current.unsubscribeStatus = getSessionStatus?.()?.subscribe(render)
407
+ render()
408
+ body.scrollTop = 0
409
+ current.delay = window.setTimeout(async () => {
410
+ try {
411
+ const service = getUiConversation?.()
412
+ const controller = getSessions?.() ?? sessions
413
+ if (!controller?.retain || !service?.binding) throw new Error('unavailable')
414
+ current.reference = controller.retain(id, { source: 'prtsSummaryPreview' })
415
+ const binding = await current.reference.ready
416
+ if (active !== current) return
417
+ source = service.binding(binding).target('chat')
418
+ current.unsubscribe = source.subscribe(() => {
419
+ if (active !== current || current.refresh !== undefined) return
420
+ current.refresh = window.setTimeout(() => {
421
+ current.refresh = undefined
422
+ render()
423
+ }, 120)
424
+ })
425
+ render()
426
+ } catch {
427
+ if (active !== current) return
428
+ current.unsubscribe?.()
429
+ current.unsubscribe = undefined
430
+ current.reference?.release()
431
+ current.reference = undefined
432
+ render()
433
+ if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
434
+ }
435
+ }, 80)
436
+ }
437
+ function leave() {
438
+ if (!active || active.sticky) return
439
+ const current = active
440
+ window.clearTimeout(current.leaveTimer)
441
+ current.leaveTimer = window.setTimeout(() => {
442
+ if (active === current && !current.panel.matches(':hover')) stop()
443
+ }, 300)
444
+ }
445
+ function reanchor(root) {
446
+ if (!active) return
447
+ const anchor = [...root.querySelectorAll('[data-prts-session-summary-item]')]
448
+ .find(item => item.getAttribute('data-prts-session-summary-item') === active.id)
449
+ active.group?.removeAttribute('data-prts-preview-open')
450
+ active.anchor?.removeAttribute('data-prts-preview-active')
451
+ active.anchor = anchor
452
+ active.group = anchor?.closest('[data-prts-session-summary-group]')
453
+ active.originalParent = undefined
454
+ active.group?.setAttribute('data-prts-preview-open', '')
455
+ anchor?.setAttribute('data-prts-preview-active', '')
456
+ window.clearTimeout(active.leaveTimer)
457
+ }
458
+ return { show, stop, leave, reanchor }
459
+ }
460
+
461
+ export function scrollSummaryTitle(button, viewport, text, window) {
462
+ let animation
463
+ const stop = () => { animation?.cancel(); animation = undefined }
464
+ const start = () => {
465
+ stop()
466
+ const distance = text.scrollWidth - viewport.clientWidth
467
+ if (distance <= 8 || !text.animate) return
468
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
469
+ animation = text.animate([
470
+ { transform: 'translateX(0)' },
471
+ { transform: `translateX(-${distance}px)` },
472
+ ], { duration: reduced ? 0 : distance / 30 * 1000, fill: 'forwards', easing: 'linear' })
473
+ }
474
+ button.addEventListener('pointerenter', start)
475
+ button.addEventListener('pointerleave', stop)
476
+ button.addEventListener('focus', start)
477
+ button.addEventListener('blur', stop)
478
+ return stop
479
+ }
@@ -1,3 +1,4 @@
1
+ import { createSessionSummaryPreview, scrollSummaryTitle } from './session-summary-preview.js'
1
2
  import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
2
3
  import { createSessionLifelineAnimator } from './session-lifeline.js'
3
4
 
@@ -189,7 +190,8 @@ function ensureFacilitySpine(document, row, kind, next) {
189
190
  }
190
191
 
191
192
 
192
- export function createSidebarControlAdapter({ document, window, sessions, workspaces, getSessionStatus, getUiWorkspace }) {
193
+ export function createSidebarControlAdapter({ document, window, sessions, workspaces, getSessionStatus, getUiWorkspace, getUiConversation, getSessions }) {
194
+ let previewPreferences = {}
193
195
  let observer
194
196
  let resizeObserver
195
197
  let resizeFrame
@@ -201,6 +203,9 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
201
203
  let statusSourcesStarted = false
202
204
  let summaryRoot
203
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 = [] }
204
209
  let owned = new Set()
205
210
  let vectorOwners = new Set()
206
211
  const dirtyVectors = new Map()
@@ -269,6 +274,19 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
269
274
  hint.textContent = '选择会话'
270
275
  heading.append(headingLabel, hint)
271
276
  popover.append(heading)
277
+ const reply = document.createElement('section')
278
+ reply.setAttribute('data-prts-summary-reply', '')
279
+ reply.setAttribute('role', 'note')
280
+ reply.setAttribute('popover', 'manual')
281
+ reply.hidden = true
282
+ reply.append(document.createElement('strong'), document.createElement('p'))
283
+ group.addEventListener('pointerleave', () => preview.leave())
284
+ group.addEventListener('focusout', event => {
285
+ if (!group.contains(event.relatedTarget)) preview.leave()
286
+ })
287
+ const list = document.createElement('span')
288
+ list.setAttribute('data-prts-summary-list', '')
289
+ popover.append(list)
272
290
  for (const item of items) {
273
291
  const button = document.createElement('button')
274
292
  button.type = 'button'
@@ -276,8 +294,21 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
276
294
  button.setAttribute('data-prts-session-summary-item', item.id)
277
295
  button.append(statusSignal(document, state))
278
296
  const title = document.createElement('span')
279
- title.textContent = item.title
297
+ title.setAttribute('data-prts-summary-title', '')
298
+ const text = document.createElement('span')
299
+ text.textContent = item.title
300
+ title.append(text)
280
301
  button.append(title)
302
+ button.setAttribute('aria-label', item.title)
303
+ stopTitles.push(scrollSummaryTitle(button, title, text, window))
304
+ const showReply = () => {
305
+ const key = {ongoing:'previewOngoing',warning:'previewWarning',done:'previewDone'}[state]
306
+ if (previewPreferences[key] === false) { preview.stop(); return }
307
+ preview.show(item.id, reply, item.title, button)
308
+ }
309
+ button.addEventListener('pointerenter', showReply)
310
+ button.addEventListener('pointerleave', () => preview.leave())
311
+ button.addEventListener('focus', showReply)
281
312
  if (item.statusLabel) {
282
313
  const detail = document.createElement('small')
283
314
  detail.setAttribute('data-prts-session-summary-item-status', '')
@@ -287,17 +318,21 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
287
318
  }
288
319
  button.addEventListener('click', event => {
289
320
  event.stopPropagation()
321
+ preview.stop()
290
322
  openSession(item.id)
291
323
  })
292
- popover.append(button)
324
+ list.append(button)
293
325
  }
326
+ popover.append(reply)
294
327
  group.append(trigger, popover)
295
328
  return group
296
329
  }
297
330
 
298
331
  function syncSummary(root, next) {
299
332
  const { header, label } = resolveSectionHeader(root)
300
- if (!header || !label) {
333
+ if (previewPreferences.sessionPreview === false || !header || !label) {
334
+ preview.stop()
335
+ clearSummaryEffects()
301
336
  summaryRoot?.remove()
302
337
  summaryRoot = undefined
303
338
  summarySignature = ''
@@ -349,10 +384,12 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
349
384
  summaryRoot.hidden = warning.length === 0 && ongoing.length === 0 && done.length === 0
350
385
  if (signature === summarySignature) return
351
386
  summarySignature = signature
387
+ clearSummaryEffects()
352
388
  summaryRoot.replaceChildren()
353
389
  if (warning.length) summaryRoot.append(buildSummaryGroup('warning', '等待处理的会话', warning))
354
390
  if (ongoing.length) summaryRoot.append(buildSummaryGroup('ongoing', '运行中的会话', ongoing))
355
391
  if (done.length) summaryRoot.append(buildSummaryGroup('done', '完成未查看', done))
392
+ preview.reanchor(summaryRoot)
356
393
  }
357
394
 
358
395
  function scheduleVectors() {
@@ -487,6 +524,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
487
524
  }
488
525
  syncSummary(root, next)
489
526
  } else {
527
+ preview.stop()
528
+ clearSummaryEffects()
490
529
  summaryRoot?.remove()
491
530
  summaryRoot = undefined
492
531
  summarySignature = ''
@@ -508,6 +547,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
508
547
  }
509
548
 
510
549
  function clear() {
550
+ preview.stop()
551
+ clearSummaryEffects()
511
552
  lifelineAnimator.setTarget(undefined, undefined)
512
553
  for (const node of document?.querySelectorAll?.('[data-prts-owned-projection], [data-prts-owned-facility], [data-prts-owned-facility-spine], [data-prts-owned-facility-vector], [data-prts-owned-facility-defs]') ?? []) node.remove()
513
554
  for (const node of owned) {
@@ -525,7 +566,10 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
525
566
  }
526
567
 
527
568
  return {
528
- start() {
569
+ start(preferences = {}) {
570
+ const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick']
571
+ if (keys.some(key => previewPreferences[key] !== preferences[key])) preview.stop()
572
+ previewPreferences = Object.fromEntries(keys.map(key => [key, preferences[key]]))
529
573
  if (!document) return
530
574
  lifelineAnimator.start()
531
575
  ensureFacilityTextureDefs(document)
@@ -550,7 +594,9 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
550
594
  if (observer || !window?.MutationObserver) return
551
595
  const observationRoot = document.querySelector('[data-prts-region="sessions"]')
552
596
  if (!observationRoot) return
553
- observer = new window.MutationObserver(queueScan)
597
+ observer = new window.MutationObserver(mutations => {
598
+ if (mutations.some(mutation => !mutation.target.closest?.('[data-prts-owned-session-summary]'))) queueScan()
599
+ })
554
600
  observer.observe(observationRoot, {
555
601
  subtree: true,
556
602
  childList: true,