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.
- package/README.en.md +13 -0
- package/README.md +13 -0
- package/lib/client.js +962 -1051
- package/lib/client.js.map +1 -1
- package/lib/index.js +22 -6
- package/package.json +11 -5
- package/src/client/AgentRowEntrance.module.css +43 -0
- package/src/client/FollowHost.tsx +18 -1
- package/src/client/SmoothStreamCard.module.css +34 -0
- package/src/client/SmoothStreamCard.tsx +39 -10
- package/src/client/TypewriterAssistantNodeView.tsx +130 -21
- package/src/client/TypewriterToolNodeView.tsx +9 -1
- package/src/client/clientStore.ts +71 -12
- package/src/client/index.ts +15 -29
- package/src/client/locales.ts +25 -5
- package/src/client/smooth-stream-card-controller.ts +10 -6
- package/src/client/smooth-stream-settings-api.ts +6 -4
- package/src/client/teleprompterGlide.ts +1021 -75
- package/src/client/turn-process-face.d.ts +27 -0
- package/src/plugin.ts +30 -6
- package/src/settings-api.ts +4 -2
- package/src/settings.ts +26 -8
- package/src/client/auto-collapse-controller.ts +0 -1072
|
@@ -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
|
-
}
|