dsh-smooth-stream 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,977 @@
1
+ /**
2
+ * Auto-collapse coordinator: folds the work process of finished turns behind
3
+ * one summary row so only the model's final answer stays visible.
4
+ *
5
+ * The Harness ChatView lays a conversation out as a flat list of flow items
6
+ * (`[data-chat-flow] > [data-chat-flow-kind=…]`). This controller watches that
7
+ * list, splits it into turn segments at `user`/`steering` boundaries closed by
8
+ * `turn-tail`, and — once a segment has settled — hides its thinking rows,
9
+ * tool/command/context seats and intermediate replies, leaving one clickable
10
+ * `已处理 {时长}` row plus the final answer. Clicking the row expands the full
11
+ * process again; the choice survives later renders because it lives in the
12
+ * controller, not the DOM.
13
+ *
14
+ * Adapted from the standalone `dsh-auto-collapse` plugin (level-1 folding
15
+ * only): no second-level chips, no merged thinking rows, no status-text
16
+ * rewrite, no transition animation — the collapse decision itself stays
17
+ * instantaneous and reversible.
18
+ *
19
+ * React coexistence follows the upstream design: injected rows are plain
20
+ * siblings/prepend children the vdom diff never claims, native rows are only
21
+ * ever touched through an inline-`display` ledger that records the precise
22
+ * original value and restores it on stop, session switch, or foreign takeover
23
+ * (a written-value comparison plus an ownership sentinel catches another
24
+ * script rewriting styles behind our back). A MutationObserver batches real
25
+ * structure changes into rAF passes; a low-frequency self-rearming audit pass
26
+ * reconciles unobservable style writes; a setTimeout fallback keeps passes
27
+ * flowing when background tabs suspend rAF.
28
+ */
29
+
30
+ const STYLE_ID = 'dshss-auto-collapse-style'
31
+ /** Class of the injected per-turn summary row; also consumed by the follow
32
+ * engine so the row rides the same lag transform as native message rows. */
33
+ export const PROCESSED_CLASS = 'dshss-processed'
34
+ /** Inline custom property marking elements whose display this plugin owns. */
35
+ const DISPLAY_OWNED_PROP = '--dshss-display-owned'
36
+ /** Unobservable external style writes are reconciled by this audit cadence. */
37
+ const AUDIT_TICK_MS = 1000
38
+ /** Background-tab fallback that flushes a scheduled pass when rAF is frozen. */
39
+ const PASS_FALLBACK_MS = 60
40
+ /**
41
+ * Fold motion, deliberately minimal: layout changes land synchronously (the
42
+ * display toggle happens in the same tick as the decision) and nothing
43
+ * animates geometry at all — the earlier container-height squeeze was removed
44
+ * because real sessions showed residual drift after the pin released (other
45
+ * layout participants keep settling around the fold). What remains is one
46
+ * paint-only flourish: the summary row fades its opacity in. A fold is
47
+ * therefore a single reflow followed by a fade — the most stable shape
48
+ * possible.
49
+ */
50
+ const FOLD_ROW_FADE_MS = 160
51
+ const FOLD_EASE_OUT = 'cubic-bezier(0.22, 0.61, 0.36, 1)'
52
+
53
+ /** User-visible copy; DSH web ships Chinese-first, other locales get English. */
54
+ interface FoldCopy {
55
+ processedLabel: string
56
+ expandTitle: string
57
+ collapseTitle: string
58
+ formatDuration: (ms: number) => string
59
+ }
60
+
61
+ const ZH_COPY: FoldCopy = {
62
+ processedLabel: '已处理',
63
+ expandTitle: '展开工作过程',
64
+ collapseTitle: '收起工作过程',
65
+ formatDuration: formatZhDuration,
66
+ }
67
+
68
+ const EN_COPY: FoldCopy = {
69
+ processedLabel: 'Processed',
70
+ expandTitle: 'Show the work process',
71
+ collapseTitle: 'Hide the work process',
72
+ formatDuration: formatEnDuration,
73
+ }
74
+
75
+ function resolveFoldCopy(): FoldCopy {
76
+ let lang = ''
77
+ try {
78
+ lang = document.documentElement.lang || navigator.language || ''
79
+ } catch {
80
+ lang = ''
81
+ }
82
+ return lang.toLowerCase().startsWith('zh') ? ZH_COPY : EN_COPY
83
+ }
84
+
85
+ const FOLD_CSS = `
86
+ .${PROCESSED_CLASS} {
87
+ box-sizing: border-box;
88
+ display: inline-flex;
89
+ align-self: flex-start;
90
+ width: fit-content;
91
+ max-width: 100%;
92
+ align-items: center;
93
+ gap: 6px;
94
+ padding: 2px 0;
95
+ border: none;
96
+ background: none;
97
+ font: 400 14px/24px system-ui, -apple-system, "Segoe UI", sans-serif;
98
+ color: var(--dsw-alias-label-tertiary, rgba(127, 127, 127, 0.9));
99
+ cursor: pointer;
100
+ user-select: none;
101
+ border-radius: 4px;
102
+ transition: color 0.15s ease;
103
+ }
104
+ .${PROCESSED_CLASS}:hover {
105
+ color: var(--dsw-alias-label-primary, inherit);
106
+ background: transparent;
107
+ }
108
+ .${PROCESSED_CLASS}:focus-visible {
109
+ outline: 2px solid var(--dsw-alias-state-focus-ring, rgba(77, 107, 254, 0.8));
110
+ outline-offset: 2px;
111
+ }
112
+ .${PROCESSED_CLASS}-chevron {
113
+ display: inline-flex;
114
+ flex: none;
115
+ width: 14px;
116
+ height: 14px;
117
+ opacity: 0.55;
118
+ transform: rotate(-90deg);
119
+ transition: transform 0.12s ease, opacity 0.1s ease;
120
+ }
121
+ .${PROCESSED_CLASS}:hover .${PROCESSED_CLASS}-chevron {
122
+ opacity: 0.9;
123
+ }
124
+ .${PROCESSED_CLASS}[aria-expanded="true"] .${PROCESSED_CLASS}-chevron {
125
+ transform: rotate(0deg);
126
+ }
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .${PROCESSED_CLASS},
129
+ .${PROCESSED_CLASS}-chevron {
130
+ transition: none;
131
+ }
132
+ }
133
+ `
134
+
135
+ /** One settled-or-open stretch of the flow between boundaries. */
136
+ interface SegmentSnapshot {
137
+ /** Stable across React re-renders; derived from the opening marker. */
138
+ key: string
139
+ /** Closing element: turn-tail, or the user/steering row that ended it. */
140
+ boundary: HTMLElement | null
141
+ /** Opening user/steering element; null for history before the first one. */
142
+ startMarker: HTMLElement | null
143
+ /** Whole flow items hidden while collapsed (work seats + middle replies). */
144
+ hideSeats: ReadonlySet<HTMLElement>
145
+ /** Reply-bearing steps that are not the segment's final answer. */
146
+ middleSteps: ReadonlySet<HTMLElement>
147
+ /** Last reply-bearing step; always visible, its thinking rows may hide. */
148
+ finalStep: HTMLElement | null
149
+ /** Thinking rows inside {@link finalStep}, hidden while collapsed. */
150
+ finalThinkRows: readonly HTMLElement[]
151
+ /** Summary row anchor: first hidden seat, else the final answer. */
152
+ firstWork: HTMLElement | null
153
+ closed: boolean
154
+ running: boolean
155
+ hasWork: boolean
156
+ }
157
+
158
+ interface SegmentState {
159
+ key: string
160
+ row: HTMLButtonElement | null
161
+ /** User gesture state; a newly settled segment starts collapsed. */
162
+ expanded: boolean
163
+ snapshot: SegmentSnapshot
164
+ /** Frozen display duration in ms; official host value wins once known. */
165
+ duration?: number
166
+ }
167
+
168
+
169
+ export class AutoCollapseController {
170
+ private observer: MutationObserver | null = null
171
+ private raf = 0
172
+ private timer: ReturnType<typeof setTimeout> | 0 = 0
173
+ private auditTimer: ReturnType<typeof setTimeout> | 0 = 0
174
+ /** True between start() and stop(); the preference toggle re-enters both. */
175
+ private running = false
176
+ private lastPassError = ''
177
+
178
+ private flow: HTMLElement | null = null
179
+ /** Stable segment key → summary row + gesture state. */
180
+ private segmentStates = new Map<string, SegmentState>()
181
+ /** First sighting of a running segment, for turns without a host duration. */
182
+ private runningSince = new Map<string, number>()
183
+ /** Segments that settled once: a resumed run restarts local timing. */
184
+ private completedOnce = new Set<string>()
185
+ /** Precise pre-plugin display values, restored on stop/switch/takeover. */
186
+ private originalDisplay = new WeakMap<HTMLElement, string>()
187
+ private writtenDisplay = new WeakMap<HTMLElement, string>()
188
+ private controlledDisplay = new Set<HTMLElement>()
189
+ /** Reply-presence verdicts cached per message until a mutation hits it. */
190
+ private bodyTextCache = new WeakMap<HTMLElement, boolean>()
191
+ private dirtyMessages = new Set<HTMLElement>()
192
+
193
+ private readonly onVisibilityChange = (): void => {
194
+ if (typeof document === 'undefined' || document.hidden !== true) this.schedule()
195
+ }
196
+
197
+ /** Re-run a pass immediately (settings changes, diagnostics, tests).
198
+ * Reply-presence verdicts are dropped wholesale: manual refreshes are rare,
199
+ * and a conservative rebuild beats a stale classification. */
200
+ refresh(): void {
201
+ this.bodyTextCache = new WeakMap()
202
+ this.dirtyMessages.clear()
203
+ this.schedule()
204
+ }
205
+
206
+ start(): void {
207
+ if (this.running) return
208
+ this.running = true
209
+ injectStyle()
210
+ try {
211
+ this.observer = new MutationObserver(records => {
212
+ if (this.shouldSchedule(records)) {
213
+ this.markDirty(records)
214
+ this.schedule()
215
+ }
216
+ })
217
+ this.observer.observe(document.body, {
218
+ childList: true,
219
+ subtree: true,
220
+ attributes: true,
221
+ // Tool/think completion flips data-state without touching structure;
222
+ // characterData drives late reply text into the classifier.
223
+ attributeFilter: ['data-state'],
224
+ characterData: true,
225
+ })
226
+ this.armAuditLoop()
227
+ this.schedule()
228
+ } catch (error) {
229
+ this.reportError(error)
230
+ throw error
231
+ }
232
+ }
233
+
234
+ stop(): void {
235
+ this.running = false
236
+ if (this.raf !== 0) cancelAnimationFrame(this.raf)
237
+ this.raf = 0
238
+ if (this.timer !== 0) clearTimeout(this.timer)
239
+ this.timer = 0
240
+ if (this.auditTimer !== 0) clearTimeout(this.auditTimer)
241
+ this.auditTimer = 0
242
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
243
+ document.removeEventListener('visibilitychange', this.onVisibilityChange)
244
+ }
245
+ this.observer?.disconnect()
246
+ this.observer = null
247
+ this.switchFlow(null)
248
+ removeStyle()
249
+ }
250
+
251
+ /** Low-frequency reconciliation for style writes no observer can see. */
252
+ private armAuditLoop(): void {
253
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
254
+ document.addEventListener('visibilitychange', this.onVisibilityChange)
255
+ }
256
+ this.rearmAudit()
257
+ }
258
+
259
+ private rearmAudit(): void {
260
+ if (!this.running || this.auditTimer !== 0) return
261
+ this.auditTimer = setTimeout(() => {
262
+ this.auditTimer = 0
263
+ if (!this.running) return
264
+ if (typeof document !== 'undefined' && document.hidden === true) {
265
+ this.rearmAudit()
266
+ return
267
+ }
268
+ this.schedule()
269
+ this.rearmAudit()
270
+ }, AUDIT_TICK_MS)
271
+ }
272
+
273
+ /** Body-level observations only need to wake us up near the active flow. */
274
+ private shouldSchedule(records: MutationRecord[]): boolean {
275
+ if (records.length === 0 || this.flow === null || !this.flow.isConnected) return true
276
+ return records.some(record => (
277
+ nodeWithin(record.target, this.flow as HTMLElement)
278
+ || nodeWithin(this.flow as HTMLElement, record.target)
279
+ ))
280
+ }
281
+
282
+ /** Walk each record to its flow child so reply-cache invalidation stays
283
+ * targeted; anything unattributable invalidates conservatively. */
284
+ private markDirty(records: MutationRecord[]): void {
285
+ const flow = this.flow
286
+ if (flow === null || !flow.isConnected) return
287
+ if (records.length === 0) {
288
+ this.bodyTextCache = new WeakMap()
289
+ this.dirtyMessages.clear()
290
+ return
291
+ }
292
+ for (const record of records) {
293
+ let current: Node | null = record.target
294
+ while (current !== null && current.parentNode !== flow) current = current.parentNode
295
+ if (!(current instanceof HTMLElement)) {
296
+ this.bodyTextCache = new WeakMap()
297
+ this.dirtyMessages.clear()
298
+ return
299
+ }
300
+ this.dirtyMessages.add(current)
301
+ }
302
+ }
303
+
304
+ private schedule(): void {
305
+ if (!this.running || this.raf !== 0) return
306
+ this.raf = requestAnimationFrame(() => {
307
+ this.raf = 0
308
+ if (this.timer !== 0) {
309
+ clearTimeout(this.timer)
310
+ this.timer = 0
311
+ }
312
+ this.runPass()
313
+ })
314
+ // Background tabs freeze rAF; the timeout guarantees the pass still runs.
315
+ if (this.timer !== 0) clearTimeout(this.timer)
316
+ this.timer = setTimeout(() => {
317
+ this.timer = 0
318
+ if (this.raf !== 0) {
319
+ cancelAnimationFrame(this.raf)
320
+ this.raf = 0
321
+ this.runPass()
322
+ }
323
+ }, PASS_FALLBACK_MS)
324
+ }
325
+
326
+ private runPass(): void {
327
+ try {
328
+ this.pass()
329
+ this.lastPassError = ''
330
+ } catch (error) {
331
+ this.reportError(error)
332
+ }
333
+ }
334
+
335
+ private reportError(error: unknown): void {
336
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
337
+ // An async observer crash must not kill coordination silently; keep one
338
+ // non-spammy diagnostic and let later mutations retry.
339
+ if (message === this.lastPassError) return
340
+ this.lastPassError = message
341
+ console.error('[dsh-smooth-stream] auto-collapse pass failed', error)
342
+ }
343
+
344
+ private pass(): void {
345
+ if (!this.running) return
346
+
347
+ const nextFlow = findFlow()
348
+ if (nextFlow !== this.flow) this.switchFlow(nextFlow)
349
+ const flow = this.flow
350
+ if (flow === null) return
351
+
352
+ for (const el of this.dirtyMessages) this.bodyTextCache.delete(el)
353
+ this.dirtyMessages.clear()
354
+ const segments = buildSegments(flow, el => this.hasBodyCached(el))
355
+ const liveSegmentKeys = new Set(segments.map(segment => segment.key))
356
+
357
+ for (const segment of segments) {
358
+ if (!segment.running) continue
359
+ // Re-settled after completing once: restart local timing so the frozen
360
+ // duration cannot swallow the idle gap in between.
361
+ if (this.completedOnce.has(segment.key)) {
362
+ this.completedOnce.delete(segment.key)
363
+ this.runningSince.delete(segment.key)
364
+ }
365
+ if (!this.runningSince.has(segment.key)) this.runningSince.set(segment.key, Date.now())
366
+ }
367
+
368
+ const completedKeys = new Set<string>()
369
+ for (const segment of segments) {
370
+ if (!segment.closed || segment.running || !segment.hasWork) continue
371
+ completedKeys.add(segment.key)
372
+ this.completedOnce.add(segment.key)
373
+ let state = this.segmentStates.get(segment.key)
374
+ if (state === undefined) {
375
+ state = { key: segment.key, row: null, expanded: false, snapshot: segment }
376
+ this.segmentStates.set(segment.key, state)
377
+ } else {
378
+ state.snapshot = segment
379
+ }
380
+ const started = this.runningSince.get(segment.key)
381
+ const parsed = segment.boundary === null ? undefined : parseTurnDuration(segment.boundary)
382
+ // The host's own duration always wins so live completions and reloaded
383
+ // history agree. Without one (typically a stopped turn) fall back to the
384
+ // locally observed running window, frozen at first settlement so the
385
+ // label stops ticking; a late official value may still override it.
386
+ if (parsed !== undefined) state.duration = parsed
387
+ else if (state.duration === undefined && started !== undefined) state.duration = Date.now() - started
388
+ if (state.row === null || !state.row.isConnected) state.row = this.createProcessedRow(state)
389
+ this.syncProcessedRow(state)
390
+ }
391
+
392
+ // Every layout-affecting decision below lands synchronously: one pass,
393
+ // one reflow, no deferred or animated geometry.
394
+ for (const [key, state] of [...this.segmentStates]) {
395
+ if (completedKeys.has(key)) continue
396
+ state.row?.remove()
397
+ this.segmentStates.delete(key)
398
+ }
399
+
400
+ const desiredHidden = new Set<HTMLElement>()
401
+ for (const segment of segments) {
402
+ const state = this.segmentStates.get(segment.key)
403
+ const collapse = state !== undefined && !state.expanded
404
+ if (collapse) {
405
+ for (const seat of segment.hideSeats) this.hideElement(seat, desiredHidden)
406
+ for (const row of segment.finalThinkRows) this.hideElement(row, desiredHidden)
407
+ // The final answer itself always shows; its thinking rows were hidden
408
+ // above, everything else about it is restored defensively.
409
+ if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
410
+ } else {
411
+ for (const seat of segment.hideSeats) this.restoreElement(seat)
412
+ for (const row of segment.finalThinkRows) this.restoreElement(row)
413
+ if (segment.finalStep !== null) this.restoreElement(segment.finalStep)
414
+ }
415
+ }
416
+
417
+ // Segments whose work is already invisible (hidden by the host or another
418
+ // extension) withdraw their summary row instead of pointing at nothing,
419
+ // but keep owning whatever they still hold so the sweep below leaves it.
420
+ for (const segment of segments) {
421
+ if (!hasVisibleSegmentWork(segment)) {
422
+ const state = this.segmentStates.get(segment.key)
423
+ if (state !== undefined && state.row !== null) {
424
+ state.row.remove()
425
+ state.row = null
426
+ }
427
+ }
428
+ for (const seat of segment.hideSeats) this.retainDisplayControl(seat, desiredHidden)
429
+ for (const row of segment.finalThinkRows) this.retainDisplayControl(row, desiredHidden)
430
+ if (segment.finalStep !== null) this.retainDisplayControl(segment.finalStep, desiredHidden)
431
+ }
432
+
433
+ this.restoreUnusedDisplays(desiredHidden)
434
+ for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state)
435
+
436
+ for (const key of [...this.runningSince.keys()]) {
437
+ if (!liveSegmentKeys.has(key)) this.runningSince.delete(key)
438
+ }
439
+ for (const key of [...this.completedOnce]) {
440
+ if (!liveSegmentKeys.has(key)) this.completedOnce.delete(key)
441
+ }
442
+ }
443
+
444
+ /** Flow swap means a session switch: fully restore the old tree, rebuild. */
445
+ private switchFlow(next: HTMLElement | null): void {
446
+ if (next === this.flow) return
447
+ for (const state of this.segmentStates.values()) state.row?.remove()
448
+ this.segmentStates.clear()
449
+ this.runningSince.clear()
450
+ this.completedOnce.clear()
451
+ this.bodyTextCache = new WeakMap()
452
+ this.dirtyMessages.clear()
453
+ this.restoreAllDisplays()
454
+ this.flow = next
455
+ }
456
+
457
+ /** Keep already-controlled elements in the desired-hidden set so the sweep
458
+ * does not flip them back mid-flight of an external change. */
459
+ private retainDisplayControl(el: HTMLElement, desiredHidden: Set<HTMLElement>): void {
460
+ if (this.controlledDisplay.has(el)) desiredHidden.add(el)
461
+ }
462
+
463
+ /** Restore every element the ledger controls that this pass no longer wants
464
+ * hidden; elements absent from `desired` come back to their true display. */
465
+ private restoreUnusedDisplays(desired: ReadonlySet<HTMLElement>): void {
466
+ for (const el of [...this.controlledDisplay]) {
467
+ if (!desired.has(el)) this.restoreElement(el)
468
+ }
469
+ }
470
+
471
+ /** Restore every element still under the display ledger, then retire the
472
+ * ledger itself (stop / session switch). */
473
+ private restoreAllDisplays(): void {
474
+ for (const el of [...this.controlledDisplay]) this.restoreElement(el)
475
+ this.controlledDisplay.clear()
476
+ this.originalDisplay = new WeakMap<HTMLElement, string>()
477
+ this.writtenDisplay = new WeakMap<HTMLElement, string>()
478
+ }
479
+
480
+ private createProcessedRow(state: SegmentState): HTMLButtonElement {
481
+ const row = createProcessedRowElement(state.duration)
482
+ row.addEventListener('click', () => {
483
+ state.expanded = !state.expanded
484
+ this.syncProcessedRow(state)
485
+ this.schedule()
486
+ })
487
+ return row
488
+ }
489
+
490
+ private syncProcessedRow(state: SegmentState): void {
491
+ const row = state.row
492
+ if (row === null) return
493
+ const copy = resolveFoldCopy()
494
+ const text = row.firstElementChild
495
+ const label = state.duration === undefined
496
+ ? copy.processedLabel
497
+ : `${copy.processedLabel} ${copy.formatDuration(state.duration)}`
498
+ if (text !== null && text.textContent !== label) text.textContent = label
499
+ const expanded = String(state.expanded)
500
+ if (row.getAttribute('aria-expanded') !== expanded) row.setAttribute('aria-expanded', expanded)
501
+ const title = state.expanded ? copy.collapseTitle : copy.expandTitle
502
+ if (row.title !== title) row.title = title
503
+ }
504
+
505
+ private placeProcessedRow(flow: HTMLElement, state: SegmentState): void {
506
+ const row = state.row
507
+ if (row === null) return
508
+ if (!state.snapshot.hasWork || !hasVisibleSegmentWork(state.snapshot)) {
509
+ row.remove()
510
+ state.row = null
511
+ return
512
+ }
513
+ let target = state.snapshot.firstWork ?? state.snapshot.finalStep ?? state.snapshot.boundary
514
+ // Snapshot targets are always flow children; if that invariant somehow
515
+ // breaks, drop the row and let the next pass rebuild it rather than
516
+ // stacking disconnected rows with duplicate click handlers.
517
+ if (target === null || target.parentElement !== flow) {
518
+ row.remove()
519
+ state.row = null
520
+ return
521
+ }
522
+ if (row.parentElement !== flow || row.nextElementSibling !== target) {
523
+ target.before(row)
524
+ this.revealVisualOnce(row)
525
+ }
526
+ }
527
+
528
+ private hasBodyCached(el: HTMLElement): boolean {
529
+ const cached = this.bodyTextCache.get(el)
530
+ if (cached !== undefined) return cached
531
+ const value = hasBodyContent(el)
532
+ this.bodyTextCache.set(el, value)
533
+ return value
534
+ }
535
+
536
+ /** True when the recorded display was changed (or the ownership sentinel
537
+ * wiped) behind the ledger's back; callers hand control back to reality. */
538
+ private displayForeign(el: HTMLElement): boolean {
539
+ const written = this.writtenDisplay.get(el)
540
+ if (written === undefined) return false
541
+ return el.style.getPropertyValue(DISPLAY_OWNED_PROP) === '' || el.style.display !== written
542
+ }
543
+
544
+ private releaseDisplayLedger(el: HTMLElement): void {
545
+ this.originalDisplay.delete(el)
546
+ this.writtenDisplay.delete(el)
547
+ this.controlledDisplay.delete(el)
548
+ el.style.removeProperty(DISPLAY_OWNED_PROP)
549
+ }
550
+
551
+ private hideElement(el: HTMLElement, desired: Set<HTMLElement>): void {
552
+ desired.add(el)
553
+ if (!this.originalDisplay.has(el) && !isDisplayed(el)) return
554
+ // First takeover records the exact original; a foreign full rewrite (sentinel
555
+ // gone) or value swap re-records from current truth so restoration hands
556
+ // back what the outsider wrote over, never a stale snapshot.
557
+ if (!this.originalDisplay.has(el) || this.displayForeign(el)) {
558
+ this.originalDisplay.set(el, el.style.display)
559
+ this.writtenDisplay.set(el, el.style.display)
560
+ el.style.setProperty(DISPLAY_OWNED_PROP, '1')
561
+ }
562
+ this.controlledDisplay.add(el)
563
+ if (el.style.display === 'none') return
564
+ // Synchronous by contract: deferring the layout change to an animation end
565
+ // leaves the true geometry unmeasured and reintroduces the post-collapse
566
+ // snap. Folds are one reflow plus a paint-only row fade.
567
+ el.style.display = 'none'
568
+ this.writtenDisplay.set(el, 'none')
569
+ }
570
+
571
+ private restoreElement(el: HTMLElement): void {
572
+ if (!this.originalDisplay.has(el)) return
573
+ // Respect an outside takeover: never resurrect something another script
574
+ // (or the user) deliberately hid after we did.
575
+ if (this.displayForeign(el)) {
576
+ this.releaseDisplayLedger(el)
577
+ return
578
+ }
579
+ const original = this.originalDisplay.get(el) as string
580
+ if (el.style.display !== original) el.style.display = original
581
+ this.releaseDisplayLedger(el)
582
+ }
583
+
584
+ /** Whether WAAPI exists and the user has not asked for reduced motion. */
585
+ private canAnimate(el: HTMLElement): boolean {
586
+ if (typeof el.animate !== 'function') return false
587
+ try {
588
+ if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return false
589
+ } catch {
590
+ return false
591
+ }
592
+ return true
593
+ }
594
+
595
+ /** First-appearance flourish for the plugin-owned summary row: a pure
596
+ * opacity fade. Paint-only, so it cannot shift anyone's layout. */
597
+ private revealVisualOnce(row: HTMLButtonElement): void {
598
+ if (row.dataset.dshssShown === '1') return
599
+ row.dataset.dshssShown = '1'
600
+ if (!this.canAnimate(row)) return
601
+ row.animate(
602
+ [{ opacity: '0' }, { opacity: '1' }],
603
+ { duration: FOLD_ROW_FADE_MS, easing: FOLD_EASE_OUT },
604
+ )
605
+ }
606
+ }
607
+
608
+ /** Visible chat flow container, falling back to the first one rendered. */
609
+ function findFlow(): HTMLElement | null {
610
+ const flows = document.querySelectorAll<HTMLElement>('[data-chat-flow]')
611
+ for (const flow of flows) {
612
+ if (flow.offsetParent !== null || flow.getBoundingClientRect().width > 0) return flow
613
+ }
614
+ return flows[0] ?? null
615
+ }
616
+
617
+ /** parentNode walk that also accepts Text mutation targets. */
618
+ function nodeWithin(node: Node, ancestor: Node): boolean {
619
+ for (let current: Node | null = node; current !== null; current = current.parentNode) {
620
+ if (current === ancestor) return true
621
+ }
622
+ return false
623
+ }
624
+
625
+ /** Top-level message order, excluding this plugin's own summary rows. */
626
+ function flowItems(flow: HTMLElement): HTMLElement[] {
627
+ return [...flow.children].filter((el): el is HTMLElement => (
628
+ el instanceof HTMLElement && !el.classList.contains(PROCESSED_CLASS)
629
+ ))
630
+ }
631
+
632
+ function isDisplayed(el: HTMLElement): boolean {
633
+ if (typeof getComputedStyle === 'function') return getComputedStyle(el).display !== 'none'
634
+ return el.style.display !== 'none'
635
+ }
636
+
637
+ function stableElementKey(el: HTMLElement, fallbackIndex: number): string {
638
+ const kind = el.getAttribute('data-chat-flow-kind') ?? 'node'
639
+ const key = el.getAttribute('data-chat-flow-key')
640
+ ?? el.getAttribute('data-chat-anchor-key')
641
+ ?? `${kind}:${fallbackIndex}`
642
+ return `${kind}:${key}`
643
+ }
644
+
645
+ /** Kinds that can open a history stretch lacking an explicit user marker. */
646
+ function opensTurnWork(el: HTMLElement): boolean {
647
+ const kind = el.getAttribute('data-chat-flow-kind')
648
+ return kind === 'assistant-step'
649
+ || kind === 'assistant'
650
+ || kind === 'tool-call'
651
+ || kind === 'command'
652
+ || kind === 'manual-compaction'
653
+ }
654
+
655
+ /**
656
+ * Split the flow into turn segments. `user`/`steering` rows close the previous
657
+ * segment and open the next; `turn-tail` closes and resets the opener. Leading
658
+ * context before the first user row joins that user's segment, matching how
659
+ * the host presents context injection.
660
+ */
661
+ function buildSegments(flow: HTMLElement, hasBody: (el: HTMLElement) => boolean): SegmentSnapshot[] {
662
+ const items = flowItems(flow)
663
+ const itemIndex = new Map(items.map((el, index) => [el, index]))
664
+ const snapshots: SegmentSnapshot[] = []
665
+ let contentStart = 0
666
+ let startMarker: HTMLElement | null = null
667
+
668
+ const append = (end: number, boundary: HTMLElement | null, closed: boolean): void => {
669
+ if (end < contentStart) return
670
+ const range = items.slice(contentStart, end)
671
+ const hideSeats = new Set<HTMLElement>()
672
+ const bodySteps: HTMLElement[] = []
673
+ let running = false
674
+ for (const el of range) {
675
+ const classified = classifyItem(el, hasBody)
676
+ if (classified.fold === 'body') bodySteps.push(el)
677
+ else if (classified.fold === 'seat') hideSeats.add(el)
678
+ if (classified.running) running = true
679
+ }
680
+ const finalStep = bodySteps.at(-1) ?? null
681
+ const middleSteps = new Set(bodySteps.slice(0, -1))
682
+ for (const step of middleSteps) hideSeats.add(step)
683
+ const finalThinkRows = finalStep === null ? [] : thinkRowsIn(finalStep)
684
+ const firstWork: HTMLElement | null = range.find(el => hideSeats.has(el)) ?? finalStep
685
+ const identity = startMarker
686
+ ?? range.find(el => opensTurnWork(el))
687
+ ?? boundary
688
+ const identityIndex = identity === null ? contentStart : (itemIndex.get(identity) ?? contentStart)
689
+ const prefix = startMarker === null ? 'leading' : 'segment'
690
+ const key = `${prefix}:${identity === null ? `open:${contentStart}` : stableElementKey(identity, identityIndex)}`
691
+ snapshots.push({
692
+ key,
693
+ boundary,
694
+ startMarker,
695
+ hideSeats,
696
+ middleSteps,
697
+ finalStep,
698
+ finalThinkRows,
699
+ firstWork,
700
+ closed,
701
+ running,
702
+ hasWork: hideSeats.size > 0 || finalThinkRows.length > 0,
703
+ })
704
+ }
705
+
706
+ items.forEach((el, index) => {
707
+ const kind = el.getAttribute('data-chat-flow-kind')
708
+ if (kind === 'user' || kind === 'steering') {
709
+ if (startMarker !== null) {
710
+ append(index, el, true)
711
+ contentStart = index + 1
712
+ } else {
713
+ const leading = items.slice(contentStart, index)
714
+ if (leading.some(el => opensTurnWork(el))) {
715
+ append(index, el, true)
716
+ contentStart = index + 1
717
+ }
718
+ // Only leading context so far: keep accumulating into the next user's
719
+ // segment, mirroring the host's presentation of context injection.
720
+ }
721
+ startMarker = el
722
+ return
723
+ }
724
+ if (kind === 'turn-tail') {
725
+ append(index, el, true)
726
+ contentStart = index + 1
727
+ startMarker = null
728
+ }
729
+ })
730
+ if (contentStart < items.length) append(items.length, null, false)
731
+ return snapshots
732
+ }
733
+
734
+ interface ItemClass {
735
+ fold: 'body' | 'seat' | 'skip'
736
+ running: boolean
737
+ }
738
+
739
+ /**
740
+ * Classify one top-level flow item against the Harness's own node vocabulary.
741
+ * Reply-bearing assistant messages become `body` candidates (last one per
742
+ * segment wins as the final answer); core work process — tool-call seats,
743
+ * command/compaction cards, context injection, thinking-only messages —
744
+ * becomes a hideable `seat`. Everything else is skipped untouched: boundaries,
745
+ * empty decorations, and crucially any kind this module does not own, so rows
746
+ * contributed by other plugins' slots are never folded or hidden.
747
+ */
748
+ function classifyItem(el: HTMLElement, hasBody: (el: HTMLElement) => boolean): ItemClass {
749
+ const kind = el.getAttribute('data-chat-flow-kind')
750
+ if (kind === 'user' || kind === 'steering' || kind === 'turn-tail') return { fold: 'skip', running: false }
751
+ if (kind === 'assistant-step' || kind === 'assistant') {
752
+ if (hasBody(el)) return { fold: 'body', running: subtreeRunning(el) }
753
+ // Thinking-only message: folded whole, like any other work seat.
754
+ if (thinkRowsIn(el).length > 0) return { fold: 'seat', running: subtreeRunning(el) }
755
+ return { fold: 'skip', running: false }
756
+ }
757
+ if (kind === 'tool-call') {
758
+ // Native tool cards come in two families — ToolRow compositions
759
+ // (read/write/search/todo/… plus the GenericToolCard fallback, stamped
760
+ // data-tool + data-variant + data-state) and hand-rolled chrome that
761
+ // still follows the convention (Bash: data-variant="bash" +
762
+ // data-state). Fully custom toolviews — an embedded piano, a game board
763
+ // — carry neither attribute; they belong to other plugins' surfaces and
764
+ // are never folded.
765
+ const rows = topCallRowsIn(el)
766
+ if (rows.length === 0 || !rows.every(row => hasNativeCardChrome(row))) {
767
+ return { fold: 'skip', running: subtreeRunning(el) }
768
+ }
769
+ return { fold: 'seat', running: subtreeRunning(el) }
770
+ }
771
+ if (kind === 'context' || kind === 'command' || kind === 'manual-compaction') {
772
+ return { fold: 'seat', running: subtreeRunning(el) }
773
+ }
774
+ return { fold: 'skip', running: false }
775
+ }
776
+
777
+ /** Native work-card shape: every core card family stamps `data-variant`
778
+ * together with `data-state`, whether composed through ToolRow or hand-rolled
779
+ * (Bash). Custom toolviews use their own namespaced attributes instead. */
780
+ function hasNativeCardChrome(row: HTMLElement): boolean {
781
+ return row.querySelector('[data-variant][data-state]') !== null
782
+ }
783
+
784
+ /** Whether anything inside the item reports a running lifecycle. Deliberately
785
+ * broad: staying "running" too long only delays a collapse, collapsing early
786
+ * would cut off live output. Native cards stamp `data-state` next to
787
+ * `data-tool`/`data-variant`; custom views use their own vocabulary and are
788
+ * intentionally invisible here. */
789
+ function subtreeRunning(el: HTMLElement): boolean {
790
+ if (el.getAttribute('data-state') === 'running') return true
791
+ for (const root of el.querySelectorAll<HTMLElement>('[data-tool][data-state], [data-variant][data-state]')) {
792
+ if (root.getAttribute('data-state') === 'running') return true
793
+ }
794
+ return false
795
+ }
796
+
797
+ function hasVisibleSegmentWork(segment: SegmentSnapshot): boolean {
798
+ const candidates = [
799
+ ...segment.hideSeats,
800
+ ...segment.middleSteps,
801
+ ]
802
+ if (segment.startMarker !== null) candidates.push(segment.startMarker)
803
+ if (segment.finalStep !== null) candidates.push(segment.finalStep)
804
+ return candidates.some(isDisplayed)
805
+ }
806
+
807
+ /** Top-level tool card rows of a seat (excluding sub-dispatch nests). */
808
+ function topCallRowsIn(el: HTMLElement): HTMLElement[] {
809
+ const rows: HTMLElement[] = []
810
+ for (const row of el.querySelectorAll<HTMLElement>('[data-chat-call-id]')) {
811
+ if (row.closest('[data-subcalls]') !== null) continue
812
+ if (row.closest('[data-chat-call-id]') !== row) continue
813
+ rows.push(row)
814
+ }
815
+ return rows
816
+ }
817
+
818
+ /** Reasoning rows of a message: native think variants outside tool cards. */
819
+ function thinkRowsIn(el: HTMLElement): HTMLElement[] {
820
+ const rows: HTMLElement[] = []
821
+ for (const row of el.querySelectorAll<HTMLElement>('[data-variant="think"]:not([data-tool])')) {
822
+ if (row.closest('[data-chat-call-id]') !== null) continue
823
+ if (row.closest('[data-subcalls]') !== null) continue
824
+ rows.push(row)
825
+ }
826
+ return rows
827
+ }
828
+
829
+ /**
830
+ * Reply detection beyond reasoning rows and tool cards: any non-empty text or
831
+ * media counts, because Markdown paragraphs necessarily carry text nodes.
832
+ * Rendered markdown class names are build-time hashes, so structure-based
833
+ * detection is the only stable contract.
834
+ */
835
+ function hasBodyContent(el: HTMLElement): boolean {
836
+ // Command and compaction cards are work visualization, never replies.
837
+ const kind = el.getAttribute('data-chat-flow-kind')
838
+ if (kind === 'command' || kind === 'manual-compaction') return false
839
+ if (hasBodyText(el)) return true
840
+ const excluded = '[data-variant="think"], [data-chat-call-id]'
841
+ for (const media of el.querySelectorAll<HTMLElement>('img, video, audio, canvas')) {
842
+ if (media.closest(excluded) === null) return true
843
+ }
844
+ return false
845
+ }
846
+
847
+ function hasBodyText(el: HTMLElement): boolean {
848
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
849
+ let node: Text | null
850
+ while ((node = walker.nextNode() as Text | null) !== null) {
851
+ if (node.data.trim() === '') continue
852
+ const parent = node.parentElement
853
+ if (parent !== null && parent.closest('[data-variant="think"], [data-chat-call-id], [data-variant="others"][data-state]') !== null) continue
854
+ return true
855
+ }
856
+ return false
857
+ }
858
+
859
+ /**
860
+ * Official turn duration from the closing element: localized tails read
861
+ * `用时 33秒` / `用时 2分05秒` or `Ran for 33s` / `Ran for 2m 05s`; newer
862
+ * tails only carry an end timestamp, which is diffed against the opening user
863
+ * row's timestamp.
864
+ */
865
+ function parseTurnDuration(boundary: HTMLElement): number | undefined {
866
+ const text = boundary.textContent ?? ''
867
+ const zh = text.match(/用时\s*(\d+)分(\d+)秒|用时\s*(\d+)秒/)
868
+ if (zh !== null) {
869
+ if (zh[1] !== undefined && zh[2] !== undefined) return Number(zh[1]) * 60000 + Number(zh[2]) * 1000
870
+ if (zh[3] !== undefined) return Number(zh[3]) * 1000
871
+ return undefined
872
+ }
873
+ const en = text.match(/Ran for\s*(?:(\d+)m\s*)?(\d+)s/)
874
+ if (en !== null) {
875
+ const minutes = en[1] === undefined ? 0 : Number(en[1])
876
+ return (minutes * 60 + Number(en[2])) * 1000
877
+ }
878
+ const end = parseTimeText(text)
879
+ const start = findTurnStart(boundary)
880
+ if (end !== undefined && start !== undefined && end > start) return end - start
881
+ return undefined
882
+ }
883
+
884
+ /** DSH timestamp text (`8月14日 21:56` / `2026年8月14日 22:11`) → epoch ms. */
885
+ function parseTimeText(text: string): number | undefined {
886
+ const m = text.match(/(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/)
887
+ if (m === null) return undefined
888
+ const year = m[1] !== undefined ? Number(m[1]) : new Date().getFullYear()
889
+ const t = new Date(year, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])).getTime()
890
+ return Number.isNaN(t) ? undefined : t
891
+ }
892
+
893
+ /** Latest turn-opening timestamp at or before the boundary element. */
894
+ function findTurnStart(boundary: HTMLElement): number | undefined {
895
+ const flow = boundary.parentElement
896
+ if (flow === null) return undefined
897
+ let best: HTMLElement | null = null
898
+ for (const s of flow.querySelectorAll<HTMLElement>('[class*="timeStart"]')) {
899
+ // Timestamps live deep inside user rows; order them against the boundary
900
+ // instead of trusting querySelector document order alone.
901
+ const pos = s.compareDocumentPosition(boundary)
902
+ if ((pos & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 || (pos & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0 || s === boundary) best = s
903
+ else break
904
+ }
905
+ if (best === null) return undefined
906
+ return parseTimeText(best.textContent ?? '')
907
+ }
908
+
909
+ /** Compact duration pieces shared by both locales: seconds under a minute,
910
+ * whole minutes drop the seconds, hours drop to minute granularity. */
911
+ function durationParts(ms: number): { h: number; m: number; s: number } {
912
+ const s = Math.round(ms / 1000)
913
+ return { h: Math.floor(s / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 }
914
+ }
915
+
916
+ /** 毫秒 → 中文紧凑时长(14秒 / 2分05秒 / 15分 / 3小时2分)。 */
917
+ export function formatZhDuration(ms: number): string {
918
+ const { h, m, s } = durationParts(ms)
919
+ if (h > 0) return m > 0 ? `${h}小时${m}分` : `${h}小时`
920
+ if (s < 60 && m === 0 && h === 0) return `${s}秒`
921
+ if (s === 0) return `${m}分`
922
+ return `${m}分${String(s).padStart(2, '0')}秒`
923
+ }
924
+
925
+ /** Milliseconds → compact English duration (14s / 2m05s / 15m / 3h2m). */
926
+ export function formatEnDuration(ms: number): string {
927
+ const { h, m, s } = durationParts(ms)
928
+ if (h > 0) return m > 0 ? `${h}h${m}m` : `${h}h`
929
+ if (m === 0 && h === 0) return `${s}s`
930
+ if (s === 0) return `${m}m`
931
+ return `${m}m${String(s).padStart(2, '0')}s`
932
+ }
933
+
934
+ /** Native disclosure chevron (IconChevronDownOutline14, 14x14). */
935
+ const CHEVRON_PATH = 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z'
936
+
937
+ function createChevronIcon(className: string): SVGSVGElement {
938
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
939
+ svg.setAttribute('width', '14')
940
+ svg.setAttribute('height', '14')
941
+ svg.setAttribute('class', className)
942
+ svg.setAttribute('viewBox', '0 0 14 14')
943
+ svg.setAttribute('fill', 'none')
944
+ svg.setAttribute('aria-hidden', 'true')
945
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
946
+ path.setAttribute('d', CHEVRON_PATH)
947
+ path.setAttribute('fill', 'currentColor')
948
+ svg.appendChild(path)
949
+ return svg
950
+ }
951
+
952
+ function createProcessedRowElement(duration?: number): HTMLButtonElement {
953
+ const copy = resolveFoldCopy()
954
+ const btn = document.createElement('button')
955
+ btn.type = 'button'
956
+ btn.className = PROCESSED_CLASS
957
+ btn.setAttribute('aria-expanded', 'false')
958
+ const text = document.createElement('span')
959
+ text.textContent = duration === undefined
960
+ ? copy.processedLabel
961
+ : `${copy.processedLabel} ${copy.formatDuration(duration)}`
962
+ btn.append(text, createChevronIcon(`${PROCESSED_CLASS}-chevron`))
963
+ btn.title = copy.expandTitle
964
+ return btn
965
+ }
966
+
967
+ function injectStyle(): void {
968
+ if (document.getElementById(STYLE_ID) !== null) return
969
+ const style = document.createElement('style')
970
+ style.id = STYLE_ID
971
+ style.textContent = FOLD_CSS
972
+ document.head.appendChild(style)
973
+ }
974
+
975
+ function removeStyle(): void {
976
+ document.getElementById(STYLE_ID)?.remove()
977
+ }