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