@ash-qw/dsh-theme-prts 2.1.1 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
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",
@@ -75,7 +75,7 @@
75
75
  "@playwright/test": "1.55.1",
76
76
  "esbuild": "^0.28.2",
77
77
  "jsdom": "24.1.3",
78
- "markdown-it": "^14.3.2"
78
+ "markdown-it": "^14.3.3"
79
79
  },
80
80
  "dependencies": {
81
81
  "@deepseek-ai/schemastery": "~3.18.4",
@@ -14,6 +14,8 @@ function closestWithClassSuffix(node, suffix) {
14
14
  function classify(button, operation) {
15
15
  // The host's process disclosure owns its transparent, full-width hit area.
16
16
  if (button.hasAttribute('data-turn-process')) return null
17
+ // Notification headers belong to the card surface, not standalone buttons.
18
+ if (button.parentElement?.matches('[data-turn-trigger]')) return null
17
19
  // Changed-file cards own one outer surface; their header and rows stay native.
18
20
  if (button.closest('[data-changed-files]')) return null
19
21
  // File previews are transparent hit targets; the enclosing card owns its material and outline.
@@ -63,7 +63,7 @@ export function createBackgroundStore({ window, document }) {
63
63
  },
64
64
  }
65
65
  }
66
- export function backgroundMarkup() {
66
+ export function backgroundMarkup(heroContrastControl = '') {
67
67
  return `<section data-prts-background-settings>
68
68
  <header><strong>会话背景</strong><small>分区使用不同图片,或让一张完整背景贯穿侧边栏与正文。</small></header>
69
69
  <div data-prts-background-modes role="group" aria-label="背景模式">
@@ -75,6 +75,7 @@ export function backgroundMarkup() {
75
75
  <input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
76
76
  </article>`).join('')}</div>
77
77
  <label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
78
+ <div data-prts-background-hero-setting hidden>${heroContrastControl}</div>
78
79
  ${opacityDialMarkup()}
79
80
  <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片居中铺满,切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
80
81
  </section>`
@@ -1,3 +1,4 @@
1
+ import { createThemeTransfer } from './theme-transfer.js'
1
2
  import { createBackgroundStore } from './custom-background.js'
2
3
  import { createNativePreferences } from './native-preferences.js'
3
4
  import { createComposerGlassSurface } from './composer-glass-surface.js'
@@ -91,6 +92,7 @@ export function applyPrtsPlugin(ctx, environment) {
91
92
  cssText,
92
93
  service: themeService,
93
94
  cursorAssets: assets.cursor,
95
+ onSchemeChange() { particleField.repaintScheme() },
94
96
  onTransitionStateChange(active) {
95
97
  particleField.setSchemeTransitionActive(active)
96
98
  },
@@ -111,6 +113,7 @@ export function applyPrtsPlugin(ctx, environment) {
111
113
  const adapter = createRc7Adapter({ document })
112
114
  const hostGeometry = createHostGeometryAdapter({ document, window })
113
115
  const connection = contextService(ctx, 'connection')
116
+ let themeTransfer
114
117
  let operations
115
118
  let mountObserver
116
119
  let mountFrame
@@ -201,6 +204,9 @@ export function applyPrtsPlugin(ctx, environment) {
201
204
  if (!themeAlreadyApplied) theme.apply(preferences)
202
205
  operations ||= createOperationsShell({
203
206
  document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore, backgroundStore,
207
+ themeTransfer: themeTransfer ||= createThemeTransfer({ window, getPreferences: () => nativePreferences.value,
208
+ savePreferences: (value, revision) => nativePreferences.replace(value, revision),
209
+ checkWritable: () => nativePreferences.checkWritable(), checkExport: () => nativePreferences.checkReadable(), themeVersion: version }),
204
210
  onSchemeToggle(next, interaction) {
205
211
  return theme.setTheme(next, { ...interaction, animate: true })
206
212
  },
@@ -347,6 +353,7 @@ export function applyPrtsPlugin(ctx, environment) {
347
353
  } else {
348
354
  applyVisualState()
349
355
  }
356
+ const stopBackgroundRedraw = backgroundStore.subscribe(() => { if (!safeMode) particleField.update(preferences) })
350
357
  const bindNativePreferences = settingsCtx => {
351
358
  const forms = contextService(settingsCtx, 'configForms')
352
359
  if (forms) settingsCtx.effect(() => nativePreferences.bind(forms.get('theme-prts')))
@@ -359,6 +366,7 @@ export function applyPrtsPlugin(ctx, environment) {
359
366
  statusSource.dispose()
360
367
  customEmblemStore.dispose()
361
368
  avatarStore.dispose()
369
+ stopBackgroundRedraw()
362
370
  backgroundStore.dispose()
363
371
  particleField.dispose()
364
372
  startup.dispose({ restoreFocus: false })
@@ -69,6 +69,37 @@ export function createNativePreferences({ storage, onChange }) {
69
69
  }
70
70
  return {
71
71
  get value() { return current },
72
+ checkReadable() {
73
+ const s = snapshot()
74
+ if (disposed || s?.status !== 'ready') throw Error('请等待 DSH 设置加载完成')
75
+ if (busy || Object.keys(pending).length || Object.keys(failed).length) throw Error('请先等待当前设置保存成功')
76
+ return s.revision
77
+ },
78
+ checkWritable() {
79
+ const revision = this.checkReadable()
80
+ if (!writable(snapshot())) throw Error('当前连接无法保存主题设置')
81
+ return revision
82
+ },
83
+ async replace(next, expectedRevision) {
84
+ this.checkWritable()
85
+ const s = snapshot()
86
+ if (s.revision !== expectedRevision) throw Error('设置已在其他页面更新,请重新导入')
87
+ const normalized = normalizePreferences(next)
88
+ busy = true
89
+ phase = 'saving'; emit()
90
+ try {
91
+ const ops = Object.entries(normalized).map(([key, value]) => ({ op: 'set', path: [key], value }))
92
+ ops.push({ op: 'set', path: ['preferencesInitialized'], value: true })
93
+ if (await form.mutate(ops, s.revision) !== true) throw Error('DSH 未接受导入设置,请重试')
94
+ phase = 'saved'
95
+ } catch (error) {
96
+ phase = 'error'
97
+ throw error
98
+ } finally {
99
+ busy = false
100
+ receive()
101
+ }
102
+ },
72
103
  bind(next) {
73
104
  unsubscribe?.(); form = next
74
105
  unsubscribe = form.subscribe(receive)
@@ -46,7 +46,7 @@ export function createOperationsShell({
46
46
  onSchemeToggle = () => {},
47
47
  onSchemeDragStart = () => undefined,
48
48
  onThemeDisable = () => {},
49
- customEmblemStore, avatarStore, backgroundStore,
49
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
50
50
  onPreferenceChange = () => {},
51
51
  onPreferencePreview = () => {},
52
52
  onVisualReset = () => {},
@@ -481,7 +481,7 @@ export function createOperationsShell({
481
481
  trigger: shell.querySelector('[data-prts-rail-brand]'),
482
482
  panel: shell.querySelector('[data-prts-theme-settings]'),
483
483
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
484
- customEmblemStore, avatarStore, backgroundStore,
484
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
485
485
  onPreferenceChange,
486
486
  onPreferencePreview,
487
487
  onResetVisual: onVisualReset,
@@ -432,7 +432,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
432
432
  const densityProfile = density => PARTICLE_DENSITY_PROFILES[density] ?? PARTICLE_DENSITY_PROFILES.standard
433
433
  const particleRadius = density => densityProfile(density ?? activeDensity()).radius
434
434
  const fieldEnabled = () => heroActive || (preferences.conversationStyle !== 'deck-chat' && preferences.conversationEmblem !== false)
435
- const canRenderFrame = () => fieldEnabled() && !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && !schemeTransitionPaused && !resizing
435
+ const canRenderFrame = (paintOnly = false) => fieldEnabled() && !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && (paintOnly || !schemeTransitionPaused) && !resizing
436
436
  const layoutWidth = () => width
437
437
  const layoutHeight = () => height
438
438
  const layoutShiftX = () => 0
@@ -1134,12 +1134,25 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1134
1134
  resizeTimer = window.setTimeout?.(finishResizeTransaction, RESIZE_SETTLE_MS)
1135
1135
  }
1136
1136
 
1137
+ function resizeHeroTransition(nextSize) {
1138
+ // Keep live positions and velocities; the spring follows the new destination.
1139
+ // Snapping to particle.target here would restore the old hero mid-explosion.
1140
+ clearResizeState()
1141
+ pointerActive = false
1142
+ resizeCommits += applyFieldSize(nextSize) ? 1 : 0
1143
+ }
1144
+
1137
1145
  function onOperationResize(entries = []) {
1138
1146
  if (!operation || !canvas) return
1139
1147
  resizeObservations += 1
1140
1148
  const entry = entries.find(candidate => candidate.target === operation) ?? entries[0]
1141
1149
  const contentRect = entry?.contentRect
1142
1150
  pendingResizeSize = contentRect ? sizeFromRect(contentRect) : readOperationSize()
1151
+ if (transition?.fromHero && !phoneStatic()) {
1152
+ resizeHeroTransition(pendingResizeSize)
1153
+ updateFrameGate()
1154
+ return
1155
+ }
1143
1156
  if (phoneStatic()) {
1144
1157
  const nextSize = pendingResizeSize
1145
1158
  clearResizeState()
@@ -1289,21 +1302,30 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1289
1302
  if (queuedDensity !== undefined && (queuedDensity !== particles.length || force)) startDensityReassembly(queuedDensity, force)
1290
1303
  }
1291
1304
 
1292
- function draw(timestamp) {
1305
+ function heroContrastStrength() {
1306
+ const root = document.documentElement
1307
+ const imageVisible = preferences.backgroundMode === 'full'
1308
+ ? root.hasAttribute('data-prts-background-full')
1309
+ : preferences.backgroundMode === 'split' && root.hasAttribute('data-prts-background-conversation')
1310
+ if (preferences.heroContrast === false || !imageVisible) return 0
1311
+ return heroActive ? 1 : transition?.fromHero ? 1 - easeInOut(transition.progress) : 0
1312
+ }
1313
+
1314
+ function draw(timestamp, paintOnly = false) {
1293
1315
  frame = undefined
1294
1316
  if (!ambient || !canvas) return
1295
- if (!canRenderFrame()) return
1317
+ if (!canRenderFrame(paintOnly)) return
1296
1318
  if (geometryDirty) {
1297
1319
  syncCanvasBackingStore()
1298
1320
  geometryDirty = false
1299
1321
  }
1300
- if (transition) {
1322
+ if (transition && !paintOnly) {
1301
1323
  transitionStartedAt ??= timestamp
1302
1324
  transition.progress = clamp((timestamp - transitionStartedAt) / TRANSITION_MS, 0, 1)
1303
1325
  if (transition.progress >= 1) completeTransition()
1304
1326
  }
1305
1327
 
1306
- advanceDensity(timestamp)
1328
+ if (!paintOnly) advanceDensity(timestamp)
1307
1329
  const reducedMotion = motionReduced()
1308
1330
  const staticPhone = phoneStatic()
1309
1331
  const staticField = reducedMotion || staticPhone
@@ -1322,7 +1344,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1322
1344
  }
1323
1345
  const frameGeometry = { activeLayout, staticField, transitionLayout }
1324
1346
  const { center, size: fieldSize } = activeLayout
1325
- const follow = reducedMotion ? 1 : 0.035
1347
+ const follow = paintOnly ? 0 : reducedMotion ? 1 : 0.035
1326
1348
  glowX += (center.x - glowX) * follow
1327
1349
  glowY += (center.y - glowY) * follow
1328
1350
  if (glow) glow.style.transform = 'translate3d(calc(' + glowX.toFixed(2) + 'px - 50%), calc(' + glowY.toFixed(2) + 'px - 50%), 0)'
@@ -1337,19 +1359,31 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1337
1359
  const damping = heroPhysics ? 0.86 : 0.84
1338
1360
  const pointerForce = heroPhysics ? 2.2 : 2.7
1339
1361
  const lightScheme = document.documentElement.dataset.prtsScheme === 'light'
1340
- const baseAlpha = lightScheme ? 0.66 : 0.72
1341
- const radiusScale = lightScheme ? 1.12 : 1
1362
+ const contrast = heroContrastStrength()
1363
+ if (contrast > 0 && context.createRadialGradient) {
1364
+ const origin = heroActive ? center : transition.fromCenter
1365
+ const radius = (heroActive ? fieldSize : transition.fromSize) * .78
1366
+ const shade = lightScheme ? '248,250,252' : '7,14,22'
1367
+ const gradient = context.createRadialGradient(origin.x, origin.y, 0, origin.x, origin.y, radius)
1368
+ gradient.addColorStop(0, `rgba(${shade},${.58 * contrast})`)
1369
+ gradient.addColorStop(.42, `rgba(${shade},${.42 * contrast})`)
1370
+ gradient.addColorStop(1, `rgba(${shade},0)`)
1371
+ context.fillStyle = gradient
1372
+ context.fillRect(origin.x - radius, origin.y - radius, radius * 2, radius * 2)
1373
+ }
1374
+ const baseAlpha = (lightScheme ? 0.66 : 0.72) + .16 * contrast
1375
+ const radiusScale = (lightScheme ? 1.12 : 1) * (1 + .08 * contrast)
1342
1376
  const particleBatches = typeof context.moveTo === 'function' ? new Map() : null
1343
1377
 
1344
1378
  for (const particle of particles) {
1345
1379
  const target = targetPosition(particle, frameGeometry)
1346
- if (staticField) {
1380
+ if (staticField && !paintOnly) {
1347
1381
  particle.x = target.x
1348
1382
  particle.y = target.y
1349
1383
  particle.vx = 0
1350
1384
  particle.vy = 0
1351
1385
  }
1352
- if (!staticField) {
1386
+ if (!staticField && !paintOnly) {
1353
1387
  particle.vx += (target.x - particle.x) * spring
1354
1388
  particle.vy += (target.y - particle.y) * spring
1355
1389
 
@@ -1412,6 +1446,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1412
1446
  canvas.setAttribute('data-prts-particle-ready', '')
1413
1447
  canvas.style.visibility = ''
1414
1448
  }
1449
+ if (paintOnly) return
1415
1450
  const explicitMotion = Boolean(densityTransition || transition)
1416
1451
  const animatedField = !staticField && context && particles.length
1417
1452
  if (explicitMotion) {
@@ -1550,7 +1585,15 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1550
1585
  }
1551
1586
  transitionStartedAt = undefined
1552
1587
  pointerActive = false
1553
- scheduleFrame()
1588
+ // The initial message may have paused hydration before syncHeroState ran.
1589
+ // Let this short exit finish uninterrupted by subsequent response updates.
1590
+ cancelHydrationResume()
1591
+ hydrationPaused = false
1592
+ mutationBurstScore = 0
1593
+ if (mutationBurstTimer !== undefined) window.clearTimeout?.(mutationBurstTimer)
1594
+ mutationBurstTimer = undefined
1595
+ if (pendingResizeSize) resizeHeroTransition(pendingResizeSize)
1596
+ updateFrameGate()
1554
1597
  }
1555
1598
 
1556
1599
  function syncHeroState() {
@@ -1612,7 +1655,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1612
1655
  }
1613
1656
 
1614
1657
  function registerContentMutations(mutations) {
1615
- if (!fieldEnabled() || phoneStatic()) return
1658
+ if (!fieldEnabled() || phoneStatic() || transition?.fromHero) return
1616
1659
  let weight = mutations.length
1617
1660
  for (const mutation of mutations) {
1618
1661
  for (const node of [...mutation.addedNodes, ...mutation.removedNodes]) {
@@ -1780,6 +1823,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1780
1823
  if (context && particles.length) {
1781
1824
  syncCanvasBackingStore()
1782
1825
  geometryDirty = false
1826
+ cancelFrame()
1783
1827
  draw(Number(window.performance?.now?.()) || 0)
1784
1828
  } else {
1785
1829
  canvas.setAttribute('data-prts-particle-ready', '')
@@ -1847,6 +1891,11 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1847
1891
  startupPaused = next
1848
1892
  updateFrameGate()
1849
1893
  },
1894
+ repaintScheme() {
1895
+ cancelFrame()
1896
+ draw(Number(window.performance?.now?.()) || 0, true)
1897
+ if (!schemeTransitionPaused) scheduleFrame()
1898
+ },
1850
1899
  setSchemeTransitionActive(active) {
1851
1900
  const next = Boolean(active)
1852
1901
  if (schemeTransitionPaused === next) return
@@ -1888,10 +1937,13 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1888
1937
 
1889
1938
  findMount()
1890
1939
  if (!fieldEnabled()) resetTraversal()
1891
- const previousActiveDensity = heroActive
1940
+ const previousRequestedDensity = heroActive
1892
1941
  ? previousPreferences.heroParticleDensity
1893
1942
  : previousPreferences.conversationParticleDensity
1894
- const activeDensityChanged = previousActiveDensity !== undefined && previousActiveDensity !== activeDensity()
1943
+ // Compare effective densities on both sides: sparse/light hero settings
1944
+ // already render at standard density, including after a focus refresh.
1945
+ const previousActiveDensity = heroActive ? effectiveHeroDensity(previousRequestedDensity) : previousRequestedDensity
1946
+ const activeDensityChanged = previousRequestedDensity !== undefined && previousActiveDensity !== activeDensity()
1895
1947
  reportState({ requestedPattern: 'orthogonal' })
1896
1948
  if (particles.length && activeDensityChanged) scheduleDensityReassembly(targetParticleCount(), true)
1897
1949
  if (preservedTraversalCoordinate !== undefined && scroller && !heroActive) reanchorTraversal(preservedTraversalCoordinate)
@@ -1929,6 +1981,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1929
1981
  enabled: fieldEnabled(),
1930
1982
  mounted: Boolean(ambient?.isConnected && canvas?.isConnected),
1931
1983
  hero: heroActive,
1984
+ heroContrast: heroContrastStrength(),
1932
1985
  side,
1933
1986
  emblemKey: (heroActive || transition?.fromHero ? heroEmblem() : emblemForSegment(segment))?.key,
1934
1987
  nextEmblemKey: transition ? emblemForSegment(transition.targetSegment)?.key : null,
@@ -27,6 +27,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({
27
27
  composerGlass: false,
28
28
  backgroundMode: 'off',
29
29
  backgroundDim: .45,
30
+ heroContrast: true,
30
31
  sidebarOpacity: .06,
31
32
  railOpacity: .3,
32
33
  bakerOpacity: .5,
@@ -42,7 +43,9 @@ export const DEFAULT_PREFERENCES = Object.freeze({
42
43
  particlePattern: 'orthogonal',
43
44
  railDefaultHidden: false,
44
45
  sessionPreview: true,
46
+ aiToolsEnabled: true,
45
47
  previewOngoing: true,
48
+ previewTools: true,
46
49
  previewWarning: true,
47
50
  previewDone: true,
48
51
  previewFollowOngoing: true,
@@ -97,11 +100,12 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
97
100
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
98
101
  })
99
102
  export const PREFERENCE_GROUPS = Object.freeze({
100
- background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
103
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
101
104
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
102
105
  material: Object.freeze(['glass', 'composerGlass']),
103
106
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
104
- navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
107
+ aiTools: Object.freeze(['aiToolsEnabled']),
108
+ navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
105
109
  conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'bakerLoading', 'conversationEmblem']),
106
110
  })
107
111
 
@@ -217,6 +221,7 @@ export function normalizePreferences(value) {
217
221
  railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
218
222
  bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
219
223
  classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
224
+ heroContrast: booleanOr(input.heroContrast, DEFAULT_PREFERENCES.heroContrast),
220
225
  backgroundDim: typeof input.backgroundDim === 'number' && Number.isFinite(input.backgroundDim) ? Math.min(.9, Math.max(0, input.backgroundDim)) : .45,
221
226
  composerGlass: booleanOr(input.composerGlass, DEFAULT_PREFERENCES.composerGlass),
222
227
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
@@ -237,7 +242,9 @@ export function normalizePreferences(value) {
237
242
  sessionFlowColorFront: sessionFlowColors.front,
238
243
  sessionFlowSpeed: sessionFlowSpeedOr(input.sessionFlowSpeed, DEFAULT_PREFERENCES.sessionFlowSpeed),
239
244
  sessionPreview: booleanOr(input.sessionPreview, DEFAULT_PREFERENCES.sessionPreview),
245
+ aiToolsEnabled: booleanOr(input.aiToolsEnabled, DEFAULT_PREFERENCES.aiToolsEnabled),
240
246
  previewOngoing: booleanOr(input.previewOngoing, DEFAULT_PREFERENCES.previewOngoing),
247
+ previewTools: booleanOr(input.previewTools, DEFAULT_PREFERENCES.previewTools),
241
248
  previewWarning: booleanOr(input.previewWarning, DEFAULT_PREFERENCES.previewWarning),
242
249
  previewDone: booleanOr(input.previewDone, DEFAULT_PREFERENCES.previewDone),
243
250
  previewReplyLimit: [0, 5, 10, '0', '5', '10'].includes(input.previewReplyLimit) ? Number(input.previewReplyLimit) : 5,
@@ -39,6 +39,60 @@ export function latestAssistantReply(snapshot) {
39
39
  return ''
40
40
  }
41
41
 
42
+ // Call objects may be mutable on older hosts; compare raw arguments before reuse.
43
+ const toolSummaryCache = new WeakMap()
44
+ function toolSummary(call) {
45
+ const raw = call.argsRaw ?? call.call?.argsRaw ?? ''
46
+ const cached = toolSummaryCache.get(call)
47
+ if (cached?.raw === raw) return cached.text
48
+ let args
49
+ try { args = JSON.parse(raw) } catch {}
50
+ const summary = ['description', 'command', 'cmd', 'file_path', 'filePath', 'path', 'query', 'pattern', 'url']
51
+ .map(field => args?.[field]).find(value => typeof value === 'string' && value.trim())
52
+ const normalized = summary?.replace(/\s+/g, ' ').trim()
53
+ const text = normalized ? normalized.slice(0, 160) + (normalized.length > 160 ? '…' : '') : ''
54
+ toolSummaryCache.set(call, {raw, text})
55
+ return text
56
+ }
57
+
58
+ // Only calls after the latest settled prose belong to the current progress preview.
59
+ export function previewToolCalls(snapshot) {
60
+ const turn = snapshot?.timeline?.turnOrder?.at(-1)
61
+ const order = snapshot?.order ?? []
62
+ const calls = [], seen = new Set()
63
+ const visit = (call, key) => {
64
+ if (!call) return
65
+ const id = call.callId ?? key
66
+ if (!seen.has(id)) { seen.add(id); calls.push({call, key: id}) }
67
+ for (const [index, child] of (call.subCalls ?? []).entries()) visit(child, key + ':' + index)
68
+ }
69
+ for (let i = order.length - 1; i >= 0; i--) {
70
+ const node = snapshot.nodes?.get?.(order[i])
71
+ if (['user', 'steering', 'turn-trigger'].includes(node?.kind)) break
72
+ if (turn !== undefined && node?.location?.turn?.turn !== turn) continue
73
+ if (node?.visibility === 'hidden') continue
74
+ if (node?.kind === 'assistant-step' && node.data?.status === 'settled'
75
+ && node.data.blocks?.some(block => block.kind === 'text' && block.text?.trim())) break
76
+ if (node?.kind === 'tool-call') visit(node.data?.root, order[i])
77
+ }
78
+ return calls.sort((a, b) => (b.call.callTime ?? b.call.time ?? 0) - (a.call.callTime ?? a.call.time ?? 0)).map(({call, key}) => {
79
+ const text = toolSummary(call)
80
+ return {
81
+ key,
82
+ name: call.name || call.call?.name || '工具调用',
83
+ status: call.kind === 'tool-result' ? call.isError ? '失败' : '已完成'
84
+ : call.phase === 'preparing' ? '准备中' : '执行中',
85
+ summary: text ? text
86
+ : call.phase === 'preparing' ? '正在准备调用…' : '暂无操作摘要',
87
+ }
88
+ })
89
+ }
90
+
91
+ export function latestToolCall(snapshot) {
92
+ const records = previewToolCalls(snapshot)
93
+ return records.length ? {...records[0], records} : undefined
94
+ }
95
+
42
96
  export function completedPreviewReplies(snapshot) {
43
97
  const replies = []
44
98
  const latestTurn = snapshot?.timeline?.turnOrder?.at(-1)
@@ -142,8 +196,8 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
142
196
  active = undefined
143
197
  if (!old) return
144
198
  window.clearTimeout(old.leaveTimer)
145
- window.clearTimeout(old.delay)
146
- window.clearTimeout(old.refresh)
199
+ window.cancelAnimationFrame(old.delay)
200
+ window.cancelAnimationFrame(old.refresh)
147
201
  old.unsubscribe?.()
148
202
  old.unsubscribeStatus?.()
149
203
  if (keepWarm && old.reference && old.source) {
@@ -311,6 +365,39 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
311
365
  body.setAttribute('data-prts-preview-body', '')
312
366
  panel.querySelector('p').replaceWith(body)
313
367
  }
368
+ panel.querySelector('[data-prts-preview-tool]')?.remove()
369
+ const toolSection = panel.ownerDocument.createElement('div')
370
+ toolSection.setAttribute('data-prts-preview-tool', '')
371
+ toolSection.hidden = true
372
+ body.before(toolSection)
373
+ let previousTool, currentTool
374
+ const toolEntry = panel.ownerDocument.createElement('button')
375
+ toolEntry.type = 'button'
376
+ toolEntry.setAttribute('data-prts-tool-entry', '')
377
+ const toolHeading = panel.ownerDocument.createElement('small')
378
+ const toolName = panel.ownerDocument.createElement('span')
379
+ toolEntry.append(toolHeading, toolName)
380
+ toolSection.append(toolEntry)
381
+ const openToolDetail = (refresh = false) => {
382
+ if (currentTool) showDetail({key: 'latest-tool', tool: currentTool}, 0, toolEntry, refresh)
383
+ }
384
+ toolEntry.addEventListener('pointermove', () => openToolDetail())
385
+ toolEntry.addEventListener('focus', () => openToolDetail())
386
+ toolEntry.addEventListener('click', () => openToolDetail())
387
+ const renderTool = tool => {
388
+ const signature = JSON.stringify(tool)
389
+ if (signature === previousTool) return
390
+ previousTool = signature
391
+ currentTool = tool
392
+ toolSection.hidden = !tool
393
+ if (!tool) {
394
+ if (detailKey === 'latest-tool') hideDetail()
395
+ return
396
+ }
397
+ toolHeading.textContent = '工具 · ' + tool.status + ' · ' + tool.records.length + ' 次'
398
+ toolName.textContent = tool.name
399
+ if (detailKey === 'latest-tool') openToolDetail(true)
400
+ }
314
401
  const choices = panel.ownerDocument.createElement('div')
315
402
  choices.setAttribute('data-prts-preview-choices', '')
316
403
  panel.querySelector('[data-prts-preview-choices]')?.remove()
@@ -450,9 +537,9 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
450
537
  detailEntry?.removeAttribute('data-prts-reply-active')
451
538
  detailEntry = undefined
452
539
  }
453
- const showDetail = (reply, index, item) => {
540
+ const showDetail = (reply, index, item, refresh = false) => {
454
541
  keep()
455
- if (detailKey === reply.key && current.detail && !current.detail.hidden) return
542
+ if (!refresh && detailKey === reply.key && current.detail && !current.detail.hidden) return
456
543
  if (!current.detail) {
457
544
  const detail = panel.ownerDocument.createElement('section')
458
545
  detail.setAttribute('data-prts-summary-reply', '')
@@ -472,10 +559,36 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
472
559
  detailEntry?.removeAttribute('data-prts-reply-active')
473
560
  detailEntry = item
474
561
  item?.setAttribute('data-prts-reply-active', '')
475
- detail.querySelector('strong').textContent = '回复 ' + (reply.ordinal ?? index + 1)
562
+ detail.toggleAttribute('data-prts-tool-history', Boolean(reply.tool))
563
+ detail.querySelector('strong').textContent = reply.tool ? '本段工具记录 · ' + reply.tool.records.length + ' 次' : '回复 ' + (reply.ordinal ?? index + 1)
476
564
  const content = detail.querySelector('[data-prts-preview-body]')
477
- content.innerHTML = current.contentCache(reply.text).html
478
- content.scrollTop = 0
565
+ const scrollTop = refresh ? content.scrollTop : 0
566
+ let anchorKey, anchorOffset
567
+ if (refresh && scrollTop > 0) {
568
+ const anchor = [...content.children].find(row => row.getBoundingClientRect().bottom > content.getBoundingClientRect().top)
569
+ anchorKey = anchor?.dataset.toolKey
570
+ anchorOffset = anchor?.getBoundingClientRect().top
571
+ }
572
+ if (reply.tool) {
573
+ content.replaceChildren(...reply.tool.records.map(record => {
574
+ const row = panel.ownerDocument.createElement('article')
575
+ row.setAttribute('data-prts-tool-record', '')
576
+ row.dataset.toolKey = record.key
577
+ const heading = panel.ownerDocument.createElement('div')
578
+ const name = panel.ownerDocument.createElement('b')
579
+ name.textContent = record.name
580
+ const status = panel.ownerDocument.createElement('small')
581
+ status.textContent = record.status
582
+ heading.append(name, status)
583
+ const summary = panel.ownerDocument.createElement('p')
584
+ summary.textContent = record.summary
585
+ row.append(heading, summary)
586
+ return row
587
+ }))
588
+ } else content.innerHTML = current.contentCache(reply.text).html
589
+ content.scrollTop = scrollTop
590
+ const retainedAnchor = anchorKey && [...content.children].find(row => row.dataset.toolKey === anchorKey)
591
+ if (retainedAnchor) content.scrollTop += retainedAnchor.getBoundingClientRect().top - anchorOffset
479
592
  detail.hidden = false
480
593
  const rect = panel.getBoundingClientRect()
481
594
  const width = Math.min(340, window.innerWidth - 16)
@@ -548,11 +661,18 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
548
661
  }))
549
662
  }
550
663
  choices.hidden = !labels.length
664
+ const tool = state === 'ongoing' && !question && getPreviewPreferences?.().previewTools !== false ? latestToolCall(source?.getSnapshot()) : undefined
665
+ renderTool(tool)
666
+ if (tool) label.textContent = '运行中 · 最新活动'
551
667
  panel.setAttribute('aria-label', title + ':' + label.textContent)
552
668
  if (state === 'ongoing' && source) {
553
669
  const replies = selectReplies(source.getSnapshot(), replyLimit())
554
- if (replies === historyKeys) return
555
- panel.toggleAttribute('data-prts-preview-empty', !replies.length)
670
+ panel.toggleAttribute('data-prts-preview-empty', !replies.length && !tool)
671
+ if (replies === historyKeys) {
672
+ const emptyText = tool ? '' : '正在处理…'
673
+ if (!replies.length && body.textContent !== emptyText) body.textContent = emptyText
674
+ return
675
+ }
556
676
  const previousTop = body.scrollTop
557
677
  const scrollAnchor = previousTop > 0 ? [...body.children].find(entry => entry.getBoundingClientRect().bottom > body.getBoundingClientRect().top) : undefined
558
678
  const anchorTop = scrollAnchor?.getBoundingClientRect().top
@@ -568,7 +688,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
568
688
  }
569
689
  }
570
690
  if (!historyKeys.length) body.replaceChildren()
571
- if (!replies.length) body.textContent = '正在处理,等待本轮正文输出完成…'
691
+ if (!replies.length) body.textContent = tool ? '' : '正在处理…'
572
692
  for (let index = 0; index < replies.length; index++) {
573
693
  const reply = replies[index]
574
694
  if (retained.has(reply.key)) continue
@@ -580,6 +700,13 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
580
700
  item.setAttribute('data-prts-reply-entry', '')
581
701
  const heading = panel.ownerDocument.createElement('small')
582
702
  heading.textContent = '回复 ' + reply.ordinal
703
+ const icon = panel.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg')
704
+ icon.setAttribute('viewBox', '0 0 16 16')
705
+ icon.setAttribute('aria-hidden', 'true')
706
+ const outline = panel.ownerDocument.createElementNS(icon.namespaceURI, 'path')
707
+ outline.setAttribute('d', 'M3 2.5h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7l-4 2v-2a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z')
708
+ icon.append(outline)
709
+ heading.prepend(icon)
583
710
  const summary = panel.ownerDocument.createElement('span')
584
711
  summary.textContent = current.contentCache(reply.text).summary
585
712
  item.append(heading, summary)
@@ -610,7 +737,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
610
737
  ? approval
611
738
  ? [pending.toolName, pending.displayReason?.['zh-CN'] || pending.displayReason?.zh || pending.reason || pending.displayReason?.en || '请进入会话查看操作详情'].filter(Boolean).join(':')
612
739
  : first?.question || pendingQuestionPreview(pending)
613
- : source ? excerpt || (state === 'ongoing' ? '正在处理,尚未输出正文…' : '暂无正文回复,可进入会话查看执行记录。') : '正在读取…'
740
+ : source ? excerpt || (state === 'ongoing' ? '正在处理…' : '暂无正文回复,可进入会话查看执行记录。') : '正在读取…'
614
741
  if (previousText !== text || previousQuestion !== question) {
615
742
  const extendsReply = previousQuestion === question && previousText && text.startsWith(previousText)
616
743
  if (question) body.textContent = text
@@ -631,7 +758,8 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
631
758
  })
632
759
  render()
633
760
  body.scrollTop = 0
634
- current.delay = window.setTimeout(async () => {
761
+ current.delay = window.requestAnimationFrame(async () => {
762
+ current.delay = undefined
635
763
  try {
636
764
  const service = getUiConversation?.()
637
765
  const controller = getSessions?.() ?? sessions
@@ -643,11 +771,13 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
643
771
  current.source = source
644
772
  current.unsubscribe = source.subscribe(() => {
645
773
  if (active !== current || current.refresh !== undefined) return
646
- current.refresh = window.setTimeout(() => {
774
+ current.refresh = window.requestAnimationFrame(() => {
647
775
  current.refresh = undefined
648
- if (current.state === 'ongoing' && !current.editor && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys) return
776
+ if (current.state === 'ongoing' && !current.editor
777
+ && selectReplies(source.getSnapshot(), replyLimit()) === historyKeys
778
+ && JSON.stringify(getPreviewPreferences?.().previewTools === false ? undefined : latestToolCall(source.getSnapshot())) === previousTool) return
649
779
  render()
650
- }, 120)
780
+ })
651
781
  })
652
782
  render()
653
783
  } catch {
@@ -659,7 +789,7 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
659
789
  render()
660
790
  if (body.textContent.trim() === '正在读取…') body.textContent = '暂时无法读取回复,可点击会话查看'
661
791
  }
662
- }, cached ? 0 : 80)
792
+ })
663
793
  }
664
794
  function leave() {
665
795
  if (!active || active.sticky) return
@@ -673,6 +803,8 @@ export function createSessionSummaryPreview({ document, window, sessions, getSes
673
803
  if (!active) return
674
804
  const anchor = [...root.querySelectorAll('[data-prts-session-summary-item]')]
675
805
  .find(item => item.getAttribute('data-prts-session-summary-item') === active.id)
806
+ // A read completion (or removed session) has no status row to own a preview.
807
+ if (!anchor) { stop(); return }
676
808
  active.group?.removeAttribute('data-prts-preview-open')
677
809
  active.anchor?.removeAttribute('data-prts-preview-active')
678
810
  active.anchor = anchor