@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.
@@ -20,6 +20,7 @@ const OWNED_ATTRIBUTES = [
20
20
  'data-prts-session-time',
21
21
  'data-prts-session-index',
22
22
  'data-prts-session-state',
23
+ 'data-prts-selection-target',
23
24
  'data-prts-session-summary-header',
24
25
  'data-prts-session-summary-label',
25
26
  'data-prts-facility-face',
@@ -190,8 +191,23 @@ function ensureFacilitySpine(document, row, kind, next) {
190
191
  }
191
192
 
192
193
 
193
- export function createSidebarControlAdapter({ document, window, sessions, workspaces, getSessionStatus, getUiWorkspace, getUiConversation, getSessions }) {
194
+ function createRunningMarker(document) {
195
+ const svgNode = name => document.createElementNS('http://www.w3.org/2000/svg', name)
196
+ const marker = svgNode('g')
197
+ marker.setAttribute('data-prts-running-marker', '')
198
+ const letter = svgNode('text')
199
+ letter.setAttribute('x', '12')
200
+ letter.setAttribute('y', '16')
201
+ letter.textContent = 'A'
202
+ const ring = svgNode('circle')
203
+ for (const [key, value] of Object.entries({cx:12, cy:12, r:10, 'stroke-dasharray':'4.5 11.208'})) ring.setAttribute(key, value)
204
+ marker.append(letter, ring)
205
+ return marker
206
+ }
207
+
208
+ export function createSidebarControlAdapter({ document, window, sessions, workspaces, getSessionStatus, getUiWorkspace, getUiConversation, getSessions, getWorkspaces }) {
194
209
  let previewPreferences = {}
210
+ let adapterActive = false, sourceRetryTimer
195
211
  let observer
196
212
  let resizeObserver
197
213
  let resizeFrame
@@ -203,15 +219,124 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
203
219
  let statusSourcesStarted = false
204
220
  let summaryRoot
205
221
  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 = [] }
222
+ let heldPreviewState
223
+ let hoveredSummary, summaryLeaveTimer
224
+ let summaryDismissed = false, pointerLeftSummary = false
225
+ function resumeSummaryHover(event) {
226
+ if (!summaryDismissed) return
227
+ const trigger = event.target.closest?.('[data-prts-session-summary-trigger]')
228
+ if (!trigger || !summaryRoot?.contains(trigger)) { pointerLeftSummary = true; return }
229
+ if (!pointerLeftSummary) return
230
+ summaryDismissed = false
231
+ summaryRoot.removeAttribute('data-prts-summary-dismissed')
232
+ document.removeEventListener('pointermove', resumeSummaryHover)
233
+ keepSummaryHover(trigger.closest('[data-prts-session-summary-group]'))
234
+ }
235
+ function closeNavigationPreview() {
236
+ heldPreviewState = undefined
237
+ preview.stop()
238
+ clearSummaryHover()
239
+ summaryDismissed = true
240
+ pointerLeftSummary = false
241
+ summaryRoot?.setAttribute('data-prts-summary-dismissed', '')
242
+ for (const group of summaryRoot?.querySelectorAll('[data-prts-session-summary-group]') ?? []) group.removeAttribute('data-prts-preview-open')
243
+ document.addEventListener('pointermove', resumeSummaryHover)
244
+ }
245
+ function clearSummaryHover() {
246
+ window.clearTimeout(summaryLeaveTimer)
247
+ hoveredSummary?.removeAttribute('data-prts-summary-hover-open')
248
+ hoveredSummary?.removeAttribute('data-prts-summary-instant')
249
+ hoveredSummary = undefined
250
+ }
251
+ function keepSummaryHover(group) {
252
+ if (summaryDismissed) return
253
+ window.clearTimeout(summaryLeaveTimer)
254
+ if (!hoveredSummary) group.removeAttribute('data-prts-summary-instant')
255
+ if (hoveredSummary && hoveredSummary !== group) {
256
+ hoveredSummary.setAttribute('data-prts-summary-instant', '')
257
+ hoveredSummary.removeAttribute('data-prts-summary-hover-open')
258
+ group.setAttribute('data-prts-summary-instant', '')
259
+ }
260
+ hoveredSummary = group
261
+ group.setAttribute('data-prts-summary-hover-open', '')
262
+ }
263
+ function dismissHeldPreview(event) {
264
+ if (!heldPreviewState || (event?.type === 'keydown' && event.key !== 'Escape')) return
265
+ if (event?.type === 'pointerdown' && (summaryRoot?.contains(event.target) || event.target.closest?.('[data-prts-summary-reply]'))) return
266
+ heldPreviewState = undefined
267
+ queueScan()
268
+ }
269
+ function enterPreviewState(state) {
270
+ if (heldPreviewState && heldPreviewState !== state) { heldPreviewState = undefined; queueScan() }
271
+ preview.enterState(state)
272
+ }
273
+ const preview = createSessionSummaryPreview({ document, window, sessions, getUiConversation, getSessions, getSessionStatus, openSession, getPreviewPreferences: () => previewPreferences, onPreviewClosed: () => { if (statusSourcesStarted) queueScan() }, onAnswerSubmitted: state => { heldPreviewState = state; queueScan() } })
274
+ const stopTitles = new Map()
275
+ const summaryGroups = new Map()
276
+ function clearSummaryEffects() { clearSummaryHover(); preview.stop(false); for (const entry of summaryGroups.values()) entry.dispose(); for (const stop of stopTitles.values()) stop(); stopTitles.clear(); summaryGroups.clear() }
209
277
  let owned = new Set()
210
278
  let vectorOwners = new Set()
211
279
  const dirtyVectors = new Map()
212
280
  const vectorSizes = new WeakMap()
213
281
  const lifelineAnimator = createSessionLifelineAnimator({ document, window })
214
282
 
283
+ let subscribedSessions, subscribedWorkspaces
284
+ function disconnectSummarySources() {
285
+ statusSourcesStarted = false
286
+ window.clearTimeout(sourceRetryTimer)
287
+ sourceRetryTimer = undefined
288
+ stopSessionList()
289
+ stopWorkspaceList()
290
+ stopSessionStatus()
291
+ stopSessionList = stopWorkspaceList = stopSessionStatus = () => {}
292
+ subscribedSessions = subscribedWorkspaces = sessionStatus = undefined
293
+ if (getSessions) sessions = undefined
294
+ if (getWorkspaces) workspaces = undefined
295
+ cancelReveal()
296
+ cancelPendingNavigation()
297
+ clearSelectionTarget()
298
+ heldPreviewState = undefined
299
+ summaryDismissed = false
300
+ document?.removeEventListener('pointermove', resumeSummaryHover)
301
+ document?.removeEventListener('pointerdown', dismissHeldPreview)
302
+ document?.removeEventListener('keydown', dismissHeldPreview)
303
+ preview.stop()
304
+ }
305
+ function syncSummarySources() {
306
+ if (previewPreferences.sessionPreview === false) {
307
+ if (statusSourcesStarted) disconnectSummarySources()
308
+ return
309
+ }
310
+ if (!statusSourcesStarted) {
311
+ statusSourcesStarted = true
312
+ document.addEventListener('pointerdown', dismissHeldPreview)
313
+ document.addEventListener('keydown', dismissHeldPreview)
314
+ }
315
+ connectSummarySources()
316
+ connectSessionStatus()
317
+ const waiting = (getSessions && !subscribedSessions) || (getWorkspaces && !subscribedWorkspaces)
318
+ || (getSessionStatus && !sessionStatus)
319
+ if (waiting && sourceRetryTimer === undefined) {
320
+ sourceRetryTimer = window.setTimeout(() => { sourceRetryTimer = undefined; queueScan() }, 250)
321
+ } else if (!waiting) {
322
+ window.clearTimeout(sourceRetryTimer)
323
+ sourceRetryTimer = undefined
324
+ }
325
+ }
326
+ function connectSummarySources() {
327
+ if (getSessions) sessions = getSessions()
328
+ if (getWorkspaces) workspaces = getWorkspaces()
329
+ if (subscribedSessions !== sessions?.list) {
330
+ stopSessionList()
331
+ subscribedSessions = sessions?.list
332
+ stopSessionList = subscribedSessions?.subscribe?.(queueScan) ?? (() => {})
333
+ }
334
+ if (subscribedWorkspaces !== workspaces?.list) {
335
+ stopWorkspaceList()
336
+ subscribedWorkspaces = workspaces?.list
337
+ stopWorkspaceList = subscribedWorkspaces?.subscribe?.(queueScan) ?? (() => {})
338
+ }
339
+ }
215
340
  function connectSessionStatus() {
216
341
  const source = getSessionStatus?.()
217
342
  if (source === sessionStatus) return
@@ -220,28 +345,139 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
220
345
  stopSessionStatus = source?.subscribe?.(queueScan) ?? (() => {})
221
346
  }
222
347
 
223
- function openSession(sessionId) {
224
- try {
225
- const uiWorkspace = getUiWorkspace?.()
226
- if (typeof uiWorkspace?.openSession === 'function') {
227
- uiWorkspace.openSession(sessionId)
228
- return
229
- }
230
- if (typeof sessions?.open === 'function') {
231
- sessions.open(sessionId)
232
- return
348
+ let selectionTarget, selectionTargetTimer
349
+ function clearSelectionTarget() {
350
+ selectionTarget = undefined
351
+ window.clearTimeout(selectionTargetTimer)
352
+ document?.removeEventListener('pointerdown', interruptSelectionTarget, true)
353
+ queueScan()
354
+ }
355
+ function interruptSelectionTarget(event) {
356
+ if (event.target.closest?.('[data-prts-session-row]')) clearSelectionTarget()
357
+ }
358
+ function setSelectionTarget(id) {
359
+ clearSelectionTarget()
360
+ selectionTarget = id
361
+ selectionTargetTimer = window.setTimeout(clearSelectionTarget, 4000)
362
+ document.addEventListener('pointerdown', interruptSelectionTarget, true)
363
+ }
364
+
365
+ let pendingReveal, revealTimer, revealFrame, revealSettleTimer
366
+ let navigationFrame, navigationGeneration = 0
367
+ function cancelPendingNavigation() {
368
+ navigationGeneration++
369
+ window.cancelAnimationFrame?.(navigationFrame)
370
+ }
371
+ function cancelReveal() {
372
+ pendingReveal = undefined
373
+ window.clearTimeout(revealTimer)
374
+ window.clearTimeout(revealSettleTimer)
375
+ window.cancelAnimationFrame?.(revealFrame)
376
+ }
377
+ function revealSessionCard(sessionId) {
378
+ cancelReveal()
379
+ pendingReveal = {sessionId, attempts: 0}
380
+ revealTimer = window.setTimeout(cancelReveal, 4000)
381
+ const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
382
+ syncSessionReveal(root)
383
+ queueScan()
384
+ return Boolean(pendingReveal?.expanding)
385
+ }
386
+ function syncSessionReveal(root) {
387
+ if (!pendingReveal || !root) return
388
+ const {sessionId} = pendingReveal
389
+ const rows = Array.from(root.querySelectorAll('[data-row-key], [data-session-id]'))
390
+ const row = rows.find(el => el.getAttribute('data-row-key') === `session:${sessionId}`
391
+ || el.getAttribute('data-session-id') === sessionId)
392
+ if (row) {
393
+ if (pendingReveal.scrolling) return
394
+ const request = pendingReveal
395
+ request.scrolling = true
396
+ // Let React finish mounting and let the host's row glide settle before scrolling.
397
+ revealFrame = window.requestAnimationFrame(async () => {
398
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
399
+ const animations = []
400
+ if (!reduced) for (let node = row; node && node !== root; node = node.parentElement) {
401
+ for (const animation of node.getAnimations?.() ?? []) {
402
+ const timing = animation.effect?.getComputedTiming?.()
403
+ if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animations.push(animation.finished)
404
+ }
405
+ }
406
+ if (animations.length) await Promise.race([
407
+ Promise.allSettled(animations),
408
+ new Promise(resolve => { revealSettleTimer = window.setTimeout(resolve, 300) }),
409
+ ])
410
+ if (pendingReveal !== request || !row.isConnected) return
411
+ row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:reduced ? 'instant' : 'smooth'})
412
+ cancelReveal()
413
+ })
414
+ return
415
+ }
416
+ if (++pendingReveal.attempts > 40) { cancelReveal(); return }
417
+ const items = snapshotOf(workspaces?.list)?.items ?? []
418
+ const owner = items.find(item => item.sessionIds?.includes(sessionId))
419
+ const key = owner?.workspaceId ?? ''
420
+ const keyed = key => rows.find(el => el.getAttribute('data-row-key') === key)
421
+ const group = keyed(`workspace:${key}`)
422
+ if (group?.getAttribute('aria-expanded') === 'false') { pendingReveal.expanding = true; group.click(); return }
423
+ if (!group && owner?.path) {
424
+ // Nested workspaces may not mount until their visible ancestor is expanded.
425
+ const path = value => value?.replace(/\\/g, '/').replace(/\/$/, '')
426
+ const target = path(owner.path)
427
+ const ancestors = items.filter(item => item !== owner && item.path && target.startsWith(path(item.path) + '/'))
428
+ .sort((a,b) => b.path.length - a.path.length)
429
+ for (const ancestor of ancestors) {
430
+ const parent = keyed(`workspace:${ancestor.workspaceId}`)
431
+ if (parent?.getAttribute('aria-expanded') === 'false') { pendingReveal.expanding = true; parent.click(); return }
233
432
  }
234
- } catch {}
235
- const row = Array.from(document.querySelectorAll('[data-prts-session-row]')).find(candidate => (
236
- candidate.getAttribute('data-row-key') === `session:${sessionId}`
237
- || candidate.getAttribute('data-session-id') === sessionId
238
- || candidate.querySelector('[data-prts-row-title]')?.textContent.trim() === sessionId
239
- ))
240
- row?.click?.()
433
+ }
434
+ const overflow = keyed(`overflow:${key}`)
435
+ if (overflow?.getAttribute('aria-expanded') === 'false') { pendingReveal.expanding = true; overflow.click() }
436
+ }
437
+
438
+ function openSession(sessionId) {
439
+ closeNavigationPreview()
440
+ cancelPendingNavigation()
441
+ setSelectionTarget(sessionId)
442
+ revealSessionCard(sessionId)
443
+ const generation = navigationGeneration
444
+ const navigate = () => {
445
+ if (generation !== navigationGeneration || !adapterActive) return
446
+ try {
447
+ const uiWorkspace = getUiWorkspace?.()
448
+ if (typeof uiWorkspace?.openSession === 'function') {
449
+ uiWorkspace.openSession(sessionId)
450
+ return
451
+ }
452
+ if (typeof sessions?.open === 'function') {
453
+ sessions.open(sessionId)
454
+ return
455
+ }
456
+ } catch {}
457
+ const row = Array.from(document.querySelectorAll('[data-prts-session-row]')).find(candidate => (
458
+ candidate.getAttribute('data-row-key') === `session:${sessionId}`
459
+ || candidate.getAttribute('data-session-id') === sessionId
460
+ || candidate.querySelector('[data-prts-row-title]')?.textContent.trim() === sessionId
461
+ ))
462
+ if (row) row.click?.()
463
+ else clearSelectionTarget()
464
+ }
465
+ // Paint selection and expansion before the host synchronously mounts another conversation.
466
+ // Already-expanded workspaces need this too: queueScan has not applied selection yet.
467
+ navigationFrame = window.requestAnimationFrame(() => {
468
+ if (generation !== navigationGeneration) return
469
+ navigationFrame = window.requestAnimationFrame(navigate)
470
+ })
241
471
  }
242
472
 
243
473
  function statusSignal(document, state) {
244
- const signal = document.createElement('span')
474
+ const signal = state === 'ongoing'
475
+ ? document.createElementNS('http://www.w3.org/2000/svg', 'svg')
476
+ : document.createElement('span')
477
+ if (state === 'ongoing') {
478
+ signal.setAttribute('viewBox', '0 0 24 24')
479
+ signal.append(createRunningMarker(document))
480
+ }
245
481
  signal.setAttribute('data-prts-session-summary-signal', state)
246
482
  signal.setAttribute('aria-hidden', 'true')
247
483
  return signal
@@ -250,6 +486,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
250
486
  function buildSummaryGroup(state, label, items) {
251
487
  const group = document.createElement('span')
252
488
  group.setAttribute('data-prts-session-summary-group', state)
489
+ group.addEventListener('pointerenter', () => keepSummaryHover(group))
490
+ group.addEventListener('pointerleave', () => {
491
+ window.clearTimeout(summaryLeaveTimer)
492
+ summaryLeaveTimer = window.setTimeout(() => { if (hoveredSummary === group) clearSummaryHover() }, 180)
493
+ })
253
494
 
254
495
  const trigger = document.createElement('button')
255
496
  trigger.type = 'button'
@@ -261,10 +502,20 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
261
502
  count.setAttribute('data-prts-session-summary-count', '')
262
503
  count.textContent = String(items.length)
263
504
  trigger.append(count)
505
+ trigger.addEventListener('pointerenter', () => enterPreviewState(state))
506
+ trigger.addEventListener('focus', () => {
507
+ if (trigger.matches(':focus-visible')) {
508
+ summaryDismissed = false
509
+ summaryRoot?.removeAttribute('data-prts-summary-dismissed')
510
+ document.removeEventListener('pointermove', resumeSummaryHover)
511
+ }
512
+ enterPreviewState(state)
513
+ })
264
514
 
265
515
  const popover = document.createElement('span')
266
516
  popover.setAttribute('data-prts-session-summary-popover', state)
267
517
  popover.setAttribute('role', 'menu')
518
+ popover.setAttribute('popover', 'manual')
268
519
  popover.setAttribute('aria-label', label)
269
520
  const heading = document.createElement('span')
270
521
  heading.setAttribute('data-prts-session-summary-heading', '')
@@ -287,7 +538,92 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
287
538
  const list = document.createElement('span')
288
539
  list.setAttribute('data-prts-summary-list', '')
289
540
  popover.append(list)
290
- for (const item of items) {
541
+ const spaceNotice = document.createElement('div')
542
+ spaceNotice.setAttribute('data-prts-summary-space-notice', '')
543
+ spaceNotice.textContent = '空间不足,无法显示会话预览。建议扩大窗口,或在辅助功能中关闭预览。'
544
+ popover.append(spaceNotice)
545
+ let boundsFrame
546
+ const checkBounds = () => {
547
+ window.cancelAnimationFrame(boundsFrame)
548
+ boundsFrame = undefined
549
+ if (!group.isConnected) return
550
+ // Escape the host sidebar's clipped region without changing its layout.
551
+ if (typeof popover.showPopover === 'function' && !popover.matches(':popover-open')) popover.showPopover()
552
+ const header = group.closest('[data-prts-session-summary-header]')?.getBoundingClientRect()
553
+ if (!header) return
554
+ Object.assign(popover.style, {position:'fixed', boxSizing:'border-box', margin:'0', left:header.left+'px',
555
+ width:header.width+'px', right:'auto', bottom:'auto'})
556
+ popover.removeAttribute('data-prts-summary-no-space')
557
+ popover.removeAttribute('data-prts-summary-no-room')
558
+ const viewport = window.visualViewport
559
+ const left = viewport?.offsetLeft ?? 0, top = viewport?.offsetTop ?? 0
560
+ const right = left + (viewport?.width ?? window.innerWidth) - 8
561
+ const bottom = top + (viewport?.height ?? window.innerHeight) - 8
562
+ const fits = rect => rect.left >= left + 8 && rect.top >= top + 8 && rect.right <= right && rect.bottom <= bottom
563
+ // Measure the final layout, independent of the opening transform animation.
564
+ const place = () => {
565
+ popover.removeAttribute('data-prts-summary-above')
566
+ const parent = header
567
+ const rect = popover.getBoundingClientRect()
568
+ const height = popover.offsetHeight
569
+ const below = parent.bottom + 6
570
+ const above = parent.top - 6 - height
571
+ const candidate = y => ({left: rect.left, right: rect.right, top: y, bottom: y + height})
572
+ popover.style.top = below + 'px'
573
+ if (fits(candidate(below))) return true
574
+ popover.setAttribute('data-prts-summary-above', '')
575
+ if (fits(candidate(above))) { popover.style.top = above + 'px'; return true }
576
+ popover.removeAttribute('data-prts-summary-above')
577
+ return false
578
+ }
579
+ if (place()) { trigger.removeAttribute('title'); return }
580
+ popover.setAttribute('data-prts-summary-no-space', '')
581
+ trigger.title = spaceNotice.textContent
582
+ if (!place()) popover.setAttribute('data-prts-summary-no-room', '')
583
+ }
584
+ const scheduleBounds = () => {
585
+ if (boundsFrame === undefined) boundsFrame = window.requestAnimationFrame(checkBounds)
586
+ }
587
+ const boundsObserver = window.ResizeObserver ? new window.ResizeObserver(scheduleBounds) : undefined
588
+ boundsObserver?.observe(popover)
589
+ // Re-entering the trigger need not re-enter its group (the list is a descendant).
590
+ // Run after enterPreviewState so measurement sees the current status list.
591
+ trigger.addEventListener('pointerenter', checkBounds)
592
+ trigger.addEventListener('focus', checkBounds)
593
+ window.addEventListener('resize', scheduleBounds)
594
+ document.addEventListener('scroll', scheduleBounds, true)
595
+ window.visualViewport?.addEventListener('resize', scheduleBounds)
596
+ window.visualViewport?.addEventListener('scroll', scheduleBounds)
597
+ const entries = new Map()
598
+ const update = items => {
599
+ scheduleBounds()
600
+ count.textContent = String(items.length)
601
+ trigger.setAttribute('aria-label', `${label} ${items.length}`)
602
+ const ids = new Set(items.map(item => item.id))
603
+ for (const [id, entry] of entries) {
604
+ if (ids.has(id)) continue
605
+ stopTitles.get(entry.button)?.()
606
+ stopTitles.delete(entry.button)
607
+ entry.button.remove()
608
+ entries.delete(id)
609
+ }
610
+ for (const [index, incoming] of items.entries()) {
611
+ const existing = entries.get(incoming.id)
612
+ if (existing) {
613
+ if (existing.item.title !== incoming.title) {
614
+ stopTitles.get(existing.button)?.()
615
+ existing.button.querySelector('[data-prts-summary-title] > span').textContent = incoming.title
616
+ }
617
+ Object.assign(existing.item, incoming)
618
+ let detail = existing.button.querySelector('[data-prts-session-summary-item-status]')
619
+ if (incoming.statusLabel && !detail) { detail = document.createElement('small'); detail.setAttribute('data-prts-session-summary-item-status',''); existing.button.append(detail) }
620
+ if (incoming.statusLabel) detail.textContent = incoming.statusLabel
621
+ else detail?.remove()
622
+ existing.button.setAttribute('aria-label', incoming.statusLabel ? `${incoming.title},${incoming.statusLabel}` : incoming.title)
623
+ if (list.children[index] !== existing.button) list.insertBefore(existing.button, list.children[index] ?? null)
624
+ continue
625
+ }
626
+ const item = {...incoming}
291
627
  const button = document.createElement('button')
292
628
  button.type = 'button'
293
629
  button.setAttribute('role', 'menuitem')
@@ -299,14 +635,19 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
299
635
  text.textContent = item.title
300
636
  title.append(text)
301
637
  button.append(title)
638
+ const viewing = document.createElement('em')
639
+ viewing.setAttribute('data-prts-preview-marker', '')
640
+ viewing.textContent = '预览中'
641
+ button.append(viewing)
302
642
  button.setAttribute('aria-label', item.title)
303
- stopTitles.push(scrollSummaryTitle(button, title, text, window))
643
+ stopTitles.set(button, scrollSummaryTitle(button, title, text, window))
304
644
  const showReply = () => {
645
+ if (summaryDismissed) return
305
646
  const key = {ongoing:'previewOngoing',warning:'previewWarning',done:'previewDone'}[state]
306
647
  if (previewPreferences[key] === false) { preview.stop(); return }
307
648
  preview.show(item.id, reply, item.title, button)
308
649
  }
309
- button.addEventListener('pointerenter', showReply)
650
+ button.addEventListener('pointermove', showReply)
310
651
  button.addEventListener('pointerleave', () => preview.leave())
311
652
  button.addEventListener('focus', showReply)
312
653
  if (item.statusLabel) {
@@ -321,8 +662,22 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
321
662
  preview.stop()
322
663
  openSession(item.id)
323
664
  })
324
- list.append(button)
665
+ list.insertBefore(button, list.children[index] ?? null)
666
+ entries.set(item.id, {item, button})
667
+ }
325
668
  }
669
+ update(items)
670
+ summaryGroups.set(state, {group, update, dispose: () => {
671
+ boundsObserver?.disconnect()
672
+ window.cancelAnimationFrame(boundsFrame)
673
+ window.removeEventListener('resize', scheduleBounds)
674
+ document.removeEventListener('scroll', scheduleBounds, true)
675
+ window.visualViewport?.removeEventListener('resize', scheduleBounds)
676
+ window.visualViewport?.removeEventListener('scroll', scheduleBounds)
677
+ for (const {button} of entries.values()) { stopTitles.get(button)?.(); stopTitles.delete(button) }
678
+ if (hoveredSummary === group) clearSummaryHover()
679
+ group.remove()
680
+ }})
326
681
  popover.append(reply)
327
682
  group.append(trigger, popover)
328
683
  return group
@@ -331,6 +686,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
331
686
  function syncSummary(root, next) {
332
687
  const { header, label } = resolveSectionHeader(root)
333
688
  if (previewPreferences.sessionPreview === false || !header || !label) {
689
+ heldPreviewState = undefined
334
690
  preview.stop()
335
691
  clearSummaryEffects()
336
692
  summaryRoot?.remove()
@@ -361,10 +717,31 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
361
717
  rows[index] = { ...rows[index], state: nativeRow.state }
362
718
  }
363
719
  }
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')
720
+ // Do not move the row/menu to another status behind a frozen preview.
721
+ const pinned = preview.pinnedState()
722
+ if (pinned?.state) {
723
+ const item = rows.find(item => item.id === pinned.id)
724
+ if (item) { item.state = pinned.state; item.statusLabel = undefined }
725
+ }
726
+ // While reading a preview, preserve surviving rows' positions and append
727
+ // newcomers. No persistent ordering: the next idle sync uses host order.
728
+ const orderedState = state => {
729
+ const items = rows.filter(item => item.state === state)
730
+ if (!preview.isOpen() || !summaryRoot) return items
731
+ const remaining = new Map(items.map(item => [item.id, item]))
732
+ const stable = []
733
+ for (const row of summaryRoot.querySelectorAll(`[data-prts-session-summary-group="${state}"] [data-prts-session-summary-item]`)) {
734
+ const id = row.getAttribute('data-prts-session-summary-item')
735
+ if (remaining.has(id)) { stable.push(remaining.get(id)); remaining.delete(id) }
736
+ }
737
+ return [...stable, ...remaining.values()]
738
+ }
739
+ const warning = orderedState('warning')
740
+ const ongoing = orderedState('ongoing')
741
+ const done = orderedState('done')
742
+ if (heldPreviewState && !rows.some(item => item.state === heldPreviewState)) heldPreviewState = undefined
367
743
  const signature = JSON.stringify({
744
+ heldPreviewState,
368
745
  warning: warning.map(item => [item.id, item.title, item.statusLabel]),
369
746
  ongoing: ongoing.map(item => [item.id, item.title]),
370
747
  done: done.map(item => [item.id, item.title]),
@@ -381,15 +758,24 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
381
758
  }
382
759
  const labelHidden = Array.from(label.classList).some(name => /hidden/i.test(name))
383
760
  summaryRoot.toggleAttribute('data-prts-summary-search-hidden', labelHidden)
761
+ summaryRoot.toggleAttribute('data-prts-summary-dismissed', summaryDismissed)
384
762
  summaryRoot.hidden = warning.length === 0 && ongoing.length === 0 && done.length === 0
385
763
  if (signature === summarySignature) return
386
764
  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))
765
+ for (const [state, title, items] of [['warning','等待处理的会话',warning],['ongoing','运行中的会话',ongoing],['done','完成未查看',done]]) {
766
+ const existing = summaryGroups.get(state)
767
+ if (!items.length) { existing?.dispose(); summaryGroups.delete(state); continue }
768
+ if (existing) existing.update(items)
769
+ else summaryRoot.append(buildSummaryGroup(state, title, items))
770
+ }
771
+ let position = 0
772
+ for (const state of ['warning','ongoing','done']) {
773
+ const group = summaryGroups.get(state)?.group
774
+ if (group && summaryRoot.children[position++] !== group) summaryRoot.insertBefore(group, summaryRoot.children[position - 1] ?? null)
775
+ group?.removeAttribute('data-prts-preview-open')
776
+ }
392
777
  preview.reanchor(summaryRoot)
778
+ if (heldPreviewState) summaryRoot.querySelector(`[data-prts-session-summary-group="${heldPreviewState}"]`)?.setAttribute('data-prts-preview-open', '')
393
779
  }
394
780
 
395
781
  function scheduleVectors() {
@@ -451,7 +837,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
451
837
 
452
838
 
453
839
  function queueScan() {
454
- if (scanFrame !== undefined) return
840
+ if (!adapterActive || scanFrame !== undefined) return
455
841
  const requestFrame = window?.requestAnimationFrame?.bind(window) ?? (callback => window?.setTimeout?.(callback, 0))
456
842
  scanFrame = requestFrame(() => {
457
843
  scanFrame = undefined
@@ -460,7 +846,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
460
846
  }
461
847
 
462
848
  function scan() {
463
- connectSessionStatus()
849
+ if (!adapterActive) return
850
+ syncSummarySources()
464
851
  const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
465
852
  const next = new Set()
466
853
  const nextVectorOwners = new Set()
@@ -489,16 +876,31 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
489
876
  registerVector(controls.menu, { kind: 'button' }, next, nextVectorOwners)
490
877
  registerVector(controls.create, { kind: 'button' }, next, nextVectorOwners)
491
878
  }
879
+ const rowSessionId = row => row.getAttribute('data-session-id') || row.getAttribute('data-row-key')?.replace(/^session:/, '')
880
+ if (selectionTarget && Array.from(root.querySelectorAll('[role="treeitem"][aria-selected="true"]')).some(row => rowSessionId(row) === selectionTarget)) clearSelectionTarget()
492
881
  let sessionOrdinal = 0
493
882
  for (const row of root.querySelectorAll('div[role="treeitem"][aria-selected]')) {
494
883
  const sessionIndex = String(++sessionOrdinal).padStart(2, '0')
495
884
  const controls = resolveRowControls(row, 'session')
496
885
  const state = nativeSessionState(row)
886
+ const spinner = state === 'ongoing' ? row.querySelector('svg[data-state="ongoing"]') : undefined
887
+ if (spinner) {
888
+ let marker = spinner.querySelector('[data-prts-running-marker]')
889
+ if (!marker) {
890
+ marker = createRunningMarker(document)
891
+ spinner.append(marker)
892
+ }
893
+ next.add(marker)
894
+ }
895
+
497
896
  const face = ensureFacilityFace(document, row, 'session', next)
498
897
  const spine = ensureFacilitySpine(document, row, 'session', next)
499
898
  registerVector(face, { kind: 'face', topNotch: true, sessionNotch: true, texture: 'pickup' }, next, nextVectorOwners)
500
899
  registerVector(spine, { kind: 'spine' }, next, nextVectorOwners)
501
- if (!activeLifelineRow && row.getAttribute('aria-selected') === 'true') {
900
+ const selected = selectionTarget ? rowSessionId(row) === selectionTarget : row.getAttribute('aria-selected') === 'true'
901
+ if (selectionTarget) row.setAttribute('data-prts-selection-target', String(selected))
902
+ else row.removeAttribute('data-prts-selection-target')
903
+ if (!activeLifelineRow && selected) {
502
904
  activeLifelineRow = row
503
905
  activeLifeline = face.querySelector('[data-prts-session-lifeline]')
504
906
  }
@@ -533,7 +935,8 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
533
935
  lifelineAnimator.setTarget(activeLifelineRow, activeLifeline)
534
936
  for (const node of owned) {
535
937
  if (next.has(node)) continue
536
- if (node.hasAttribute?.('data-prts-owned-projection')
938
+ if (node.hasAttribute?.('data-prts-running-marker')
939
+ || node.hasAttribute?.('data-prts-owned-projection')
537
940
  || node.hasAttribute?.('data-prts-owned-facility')
538
941
  || node.hasAttribute?.('data-prts-owned-facility-spine')
539
942
  || node.hasAttribute?.('data-prts-owned-facility-vector')) {
@@ -544,13 +947,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
544
947
  }
545
948
  syncVectorOwners(nextVectorOwners)
546
949
  owned = next
950
+ syncSessionReveal(root)
547
951
  }
548
952
 
549
953
  function clear() {
550
954
  preview.stop()
551
955
  clearSummaryEffects()
552
956
  lifelineAnimator.setTarget(undefined, undefined)
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()
957
+ for (const node of document?.querySelectorAll?.('[data-prts-running-marker], [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()
554
958
  for (const node of owned) {
555
959
  for (const attribute of OWNED_ATTRIBUTES) node.removeAttribute?.(attribute)
556
960
  }
@@ -567,10 +971,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
567
971
 
568
972
  return {
569
973
  start(preferences = {}) {
570
- const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick']
974
+ const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']
571
975
  if (keys.some(key => previewPreferences[key] !== preferences[key])) preview.stop()
572
976
  previewPreferences = Object.fromEntries(keys.map(key => [key, preferences[key]]))
573
977
  if (!document) return
978
+ adapterActive = true
574
979
  lifelineAnimator.start()
575
980
  ensureFacilityTextureDefs(document)
576
981
  const ResizeObserver = window?.ResizeObserver
@@ -586,16 +991,22 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
586
991
  })
587
992
  }
588
993
  scan()
589
- if (!statusSourcesStarted) {
590
- statusSourcesStarted = true
591
- stopSessionList = sessions?.list?.subscribe?.(queueScan) ?? (() => {})
592
- stopWorkspaceList = workspaces?.list?.subscribe?.(queueScan) ?? (() => {})
593
- }
594
994
  if (observer || !window?.MutationObserver) return
595
995
  const observationRoot = document.querySelector('[data-prts-region="sessions"]')
596
996
  if (!observationRoot) return
597
997
  observer = new window.MutationObserver(mutations => {
598
- if (mutations.some(mutation => !mutation.target.closest?.('[data-prts-owned-session-summary]'))) queueScan()
998
+ const relevant = mutations.filter(mutation => !mutation.target.closest?.('[data-prts-owned-session-summary]'))
999
+ const freshRow = 'div[role="treeitem"]:not([data-prts-session-row]):not([data-prts-workspace-row])'
1000
+ const mountsRows = relevant.some(mutation => mutation.type === 'childList'
1001
+ && Array.from(mutation.addedNodes).some(node => node.nodeType === 1
1002
+ && (node.matches(freshRow) || node.querySelector(freshRow))))
1003
+ if (mountsRows) {
1004
+ // React has mounted expanded rows. Finish theme geometry before their first paint.
1005
+ window.cancelAnimationFrame?.(scanFrame)
1006
+ scanFrame = undefined
1007
+ scan()
1008
+ flushVectors()
1009
+ } else if (relevant.length) queueScan()
599
1010
  })
600
1011
  observer.observe(observationRoot, {
601
1012
  subtree: true,
@@ -605,16 +1016,16 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
605
1016
  })
606
1017
  },
607
1018
  dispose() {
1019
+ adapterActive = false
1020
+ disconnectSummarySources()
1021
+ cancelReveal()
1022
+ document.removeEventListener('pointermove', resumeSummaryHover)
1023
+ summaryDismissed = false
1024
+ heldPreviewState = undefined
1025
+ document?.removeEventListener('pointerdown', dismissHeldPreview)
1026
+ document?.removeEventListener('keydown', dismissHeldPreview)
608
1027
  observer?.disconnect()
609
1028
  observer = undefined
610
- stopSessionList()
611
- stopSessionList = () => {}
612
- stopWorkspaceList()
613
- stopWorkspaceList = () => {}
614
- stopSessionStatus()
615
- stopSessionStatus = () => {}
616
- sessionStatus = undefined
617
- statusSourcesStarted = false
618
1029
  if (scanFrame !== undefined) {
619
1030
  if (window?.cancelAnimationFrame) window.cancelAnimationFrame(scanFrame)
620
1031
  else window?.clearTimeout?.(scanFrame)