@ash-qw/dsh-theme-prts 1.3.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",
@@ -73,7 +73,8 @@
73
73
  },
74
74
  "devDependencies": {
75
75
  "@playwright/test": "1.55.1",
76
- "jsdom": "24.1.3"
76
+ "jsdom": "24.1.3",
77
+ "markdown-it": "^14.3.2"
77
78
  },
78
79
  "dependencies": {
79
80
  "sharp": "^0.35.5"
@@ -1,7 +1,7 @@
1
1
  export const FACILITY_GEOMETRY = Object.freeze({
2
2
  stroke: 1,
3
3
  corner: 1,
4
- sideDepth: 2,
4
+ sideDepth: 14 / 17,
5
5
  sideOpening: 10,
6
6
  sideInner: 6,
7
7
  topOpening: 50,
@@ -20,7 +20,7 @@ function point(x, y) {
20
20
  return `${number(x)} ${number(y)}`
21
21
  }
22
22
 
23
- export function createFacilityPath({ width, height, topNotch = false, sessionNotch = false } = {}) {
23
+ export function createFacilityPath({ width, height, topNotch = false, sessionNotch = false, spine = false } = {}) {
24
24
  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 2 || height <= FACILITY_GEOMETRY.sideOpening + 2) return ''
25
25
 
26
26
  const halfStroke = FACILITY_GEOMETRY.stroke / 2
@@ -28,6 +28,10 @@ export function createFacilityPath({ width, height, topNotch = false, sessionNot
28
28
  const right = width - halfStroke
29
29
  const top = halfStroke
30
30
  const bottom = height - halfStroke
31
+ // Keep the original RIIC sidebar width ratio; align notch ends with the card face.
32
+ const sideDepth = spine ? (width - FACILITY_GEOMETRY.stroke) * 2 / 17 : FACILITY_GEOMETRY.sideDepth
33
+ const sideOpening = FACILITY_GEOMETRY.sideOpening
34
+ const sideInner = FACILITY_GEOMETRY.sideInner
31
35
  const centerX = width / 2
32
36
  const centerY = height / 2
33
37
  const points = [point(left + FACILITY_GEOMETRY.corner, top)]
@@ -47,18 +51,18 @@ export function createFacilityPath({ width, height, topNotch = false, sessionNot
47
51
  points.push(
48
52
  point(right - FACILITY_GEOMETRY.corner, top),
49
53
  point(right, top + FACILITY_GEOMETRY.corner),
50
- point(right, centerY - FACILITY_GEOMETRY.sideOpening / 2),
51
- point(right - FACILITY_GEOMETRY.sideDepth, centerY - FACILITY_GEOMETRY.sideInner / 2),
52
- point(right - FACILITY_GEOMETRY.sideDepth, centerY + FACILITY_GEOMETRY.sideInner / 2),
53
- point(right, centerY + FACILITY_GEOMETRY.sideOpening / 2),
54
+ point(right, centerY - sideOpening / 2),
55
+ point(right - sideDepth, centerY - sideInner / 2),
56
+ point(right - sideDepth, centerY + sideInner / 2),
57
+ point(right, centerY + sideOpening / 2),
54
58
  point(right, bottom - FACILITY_GEOMETRY.corner),
55
59
  point(right - FACILITY_GEOMETRY.corner, bottom),
56
60
  point(left + FACILITY_GEOMETRY.corner, bottom),
57
61
  point(left, bottom - FACILITY_GEOMETRY.corner),
58
- point(left, centerY + FACILITY_GEOMETRY.sideOpening / 2),
59
- point(left + FACILITY_GEOMETRY.sideDepth, centerY + FACILITY_GEOMETRY.sideInner / 2),
60
- point(left + FACILITY_GEOMETRY.sideDepth, centerY - FACILITY_GEOMETRY.sideInner / 2),
61
- point(left, centerY - FACILITY_GEOMETRY.sideOpening / 2),
62
+ point(left, centerY + sideOpening / 2),
63
+ point(left + sideDepth, centerY + sideInner / 2),
64
+ point(left + sideDepth, centerY - sideInner / 2),
65
+ point(left, centerY - sideOpening / 2),
62
66
  point(left, top + FACILITY_GEOMETRY.corner),
63
67
  )
64
68
 
@@ -279,7 +279,7 @@ export function renderFacilityGraphic(owner, { width, height } = {}) {
279
279
  renderStates.set(graphic, state)
280
280
  }
281
281
  const { statusUnderlay, idleIndicator, silhouette, pickup, lifeline } = state
282
- const path = createFacilityPath({ width, height, topNotch, sessionNotch })
282
+ const path = createFacilityPath({ width, height, topNotch, sessionNotch, spine: graphic.getAttribute('data-prts-facility-svg') === 'spine' })
283
283
  graphic.setAttribute('viewBox', `0 0 ${Number(width.toFixed(3))} ${Number(height.toFixed(3))}`)
284
284
  for (const layer of state.layers) layer.setAttribute('d', path)
285
285
  statusUnderlay?.setAttribute('d', createFacilityPath({ width, height }))
@@ -100,6 +100,8 @@ export function applyPrtsPlugin(ctx, environment) {
100
100
  workspaces: contextService(ctx, 'workspaces'),
101
101
  getSessionStatus: () => contextService(ctx, 'uiSession')?.sessionStatus,
102
102
  getUiWorkspace: () => contextService(ctx, 'uiWorkspace'),
103
+ getSessions: () => contextService(ctx, 'sessions'),
104
+ getUiConversation: () => contextService(ctx, 'uiConversation'),
103
105
  })
104
106
  const floatingGlass = createFloatingGlassAdapter({ document, window })
105
107
  const adapter = createRc7Adapter({ document })
@@ -257,7 +259,7 @@ export function applyPrtsPlugin(ctx, environment) {
257
259
  toBottom.start()
258
260
  conversationControls.start()
259
261
  layoutDiagnostics.start()
260
- sidebarControls.start()
262
+ sidebarControls.start(preferences)
261
263
  floatingGlass.start()
262
264
  }
263
265
 
@@ -30,6 +30,12 @@ export const DEFAULT_PREFERENCES = Object.freeze({
30
30
  particleTraversalSpeed: PARTICLE_TRAVERSAL_SPEED_DEFAULT,
31
31
  particlePattern: 'orthogonal',
32
32
  railDefaultHidden: false,
33
+ sessionPreview: true,
34
+ previewOngoing: true,
35
+ previewWarning: true,
36
+ previewDone: true,
37
+ previewDoubleClick: true,
38
+
33
39
  sessionFlow: true,
34
40
  sessionGlow: true,
35
41
  sessionFlowPalette: 'triad',
@@ -80,7 +86,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
80
86
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
81
87
  material: Object.freeze(['glass']),
82
88
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
83
- navigation: Object.freeze(['railDefaultHidden']),
89
+ navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick']),
84
90
  conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'conversationEmblem']),
85
91
  })
86
92
 
@@ -197,6 +203,11 @@ export function normalizePreferences(value) {
197
203
  sessionFlowColorCore: sessionFlowColors.core,
198
204
  sessionFlowColorFront: sessionFlowColors.front,
199
205
  sessionFlowSpeed: sessionFlowSpeedOr(input.sessionFlowSpeed, DEFAULT_PREFERENCES.sessionFlowSpeed),
206
+ sessionPreview: booleanOr(input.sessionPreview, DEFAULT_PREFERENCES.sessionPreview),
207
+ previewOngoing: booleanOr(input.previewOngoing, DEFAULT_PREFERENCES.previewOngoing),
208
+ previewWarning: booleanOr(input.previewWarning, DEFAULT_PREFERENCES.previewWarning),
209
+ previewDone: booleanOr(input.previewDone, DEFAULT_PREFERENCES.previewDone),
210
+ previewDoubleClick: booleanOr(input.previewDoubleClick, DEFAULT_PREFERENCES.previewDoubleClick),
200
211
  conversationEmblem: booleanOr(input.conversationEmblem, DEFAULT_PREFERENCES.conversationEmblem),
201
212
  bakerGroupAvatars: booleanOr(input.bakerGroupAvatars, DEFAULT_PREFERENCES.bakerGroupAvatars),
202
213
  conversationStyle: enumOr(input.conversationStyle, CONVERSATION_STYLES, DEFAULT_PREFERENCES.conversationStyle),
@@ -0,0 +1,18 @@
1
+ import MarkdownIt from 'markdown-it'
2
+
3
+ let previewMarkdownParser
4
+ export function renderPreviewMarkdown(text) {
5
+ if (!previewMarkdownParser) {
6
+ previewMarkdownParser = new MarkdownIt({html:false,linkify:false,typographer:false})
7
+ // Previews never fetch remote images or render model-supplied HTML.
8
+ previewMarkdownParser.renderer.rules.image = (tokens,index) =>
9
+ previewMarkdownParser.utils.escapeHtml(tokens[index].content || '图片')
10
+ const link = previewMarkdownParser.renderer.rules.link_open
11
+ previewMarkdownParser.renderer.rules.link_open = (tokens,index,options,env,self) => {
12
+ tokens[index].attrSet('target','_blank')
13
+ tokens[index].attrSet('rel','noopener noreferrer')
14
+ return link ? link(tokens,index,options,env,self) : self.renderToken(tokens,index,options)
15
+ }
16
+ }
17
+ return previewMarkdownParser.render(text)
18
+ }
@@ -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
+ }