dsh-toolfold 0.1.7

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/lib/client.js ADDED
@@ -0,0 +1,1838 @@
1
+ /**
2
+ * dsh-toolfold — CLIENT half (browser).
3
+ *
4
+ * Folding of consecutive tool calls and thinking in the DSH Web GUI chat
5
+ * flow, without replacing any built-in renderer:
6
+ *
7
+ * 1. Runs of consecutive `tool-call` rows fold into ONE compact bar: all
8
+ * cards of the run hide and the bar shows the LAST call's own one-line
9
+ * summary (cloned from the product card) plus "已折叠 N 个工具调用 · 点击展开".
10
+ * Clicking the bar toggles the run open/closed (state keyed by the run's
11
+ * first call). With "思考分隔" (splitThink, DEFAULT ON) ANY other row —
12
+ * settled thinking, in-progress thinking, AI text output, user
13
+ * messages — ends a run, so completed thinking SEPARATES runs:
14
+ * [read][thinking][write] folds into two independent bars ([read] and
15
+ * [write]) instead of one merged bar. With splitThink OFF the original
16
+ * behavior returns: a step that only settled Think rows occupy is
17
+ * transparent to the merge, so [read][thinking][write] folds the same
18
+ * way [read][write] does.
19
+ * 2. Settled Think rows (`[data-variant="think"][data-state="ok"]`, the
20
+ * product's own disclosure marker) are hidden entirely by default (the
21
+ * emptied row is removed from layout so no gap remains). With
22
+ * "保留思考" on they stay visible: between the folded bars in split
23
+ * mode, or folded with the run and re-inserted in their original order
24
+ * between the calls on expand in merge mode.
25
+ * 3. Streaming Think rows (`data-state="running"`) stay fully visible as
26
+ * their own independent row — in-progress thinking is never folded and
27
+ * keeps the calls around it apart until it completes.
28
+ * 4. Animations: expanding cascades the run's rows downward in a
29
+ * waterfall (staggered spring fall — rows land with a slight overshoot
30
+ * bounce); collapsing cascades them back up (staggered rise — each
31
+ * row fades and lifts on the SAME easing curve as its height shrink,
32
+ * so a tall card never looks "stuck" while its opacity is already
33
+ * gone) while each row's HEIGHT simultaneously shrinks to zero, so
34
+ * the content below the run follows upward continuously — no blank
35
+ * hole and no end snap. Cleanup runs exactly one step after the LAST
36
+ * row's animation finishes — no dead wait before the run disappears.
37
+ * When the reader is pinned near the bottom, expanding keeps the
38
+ * bar's viewport position fixed (the product's sticky-bottom follow
39
+ * would otherwise shove the bar out of the top).
40
+ *
41
+ * Performance (near-zero steady-state cost):
42
+ * - NO page-wide observer. The old body-subtree observer fired on every DOM
43
+ * mutation anywhere in the app; it is gone. Flow mounts/unmounts are
44
+ * caught by tiny childList-only observers on the flow parents (direct
45
+ * children only, no subtree) plus a visible-only safety rescan that
46
+ * backs off (3s → 10s) while the page is quiet.
47
+ * - Two-phase flow observers. A flow with no tool calls and no think
48
+ * blocks keeps a childList-only observer that fires only on row
49
+ * add/remove/reorder — while text streams inside its rows it costs
50
+ * nothing. Subtree + `data-state` attribute coverage engages only once
51
+ * the flow actually contains foldable content, and never downgrades.
52
+ * - Classified mutations: row adds/removes and think `data-state` flips
53
+ * run a full merge pass; content changes inside a tool-call row refresh
54
+ * ONLY the bar of the run whose LAST row was mutated, and only while
55
+ * that run is collapsed (throttled ~8/s). Expanded runs and non-last
56
+ * rows cost nothing. The single-appended-text-node case short-circuits
57
+ * with zero DOM walks, so assistant/think text streaming costs nothing.
58
+ * - Passes are rAF-batched, one per frame, dirty flows only; row
59
+ * assessments are cached per row (zero layout reads in steady state);
60
+ * bar parts are cached on the bar (no per-update selector lookups).
61
+ * - Visibility gating: while the tab is hidden the engine holds NO
62
+ * observers and NO timers — literally zero work. On return to
63
+ * visibility everything re-attaches and one refresh catches up.
64
+ *
65
+ * Settings (Settings → 插件 → 工具折叠, persisted through the DSH settings
66
+ * service into ~/.dsh/settings.yaml):
67
+ * - durMs: expand/collapse animation duration (0–2000, default 240);
68
+ * - keepThink: settled think stays visible instead of hidden (default off);
69
+ * - splitThink: settled think separates tool-call groups into independent
70
+ * bars (default on; off restores the old merge-across-thinking fold);
71
+ * - stats: live performance meter in the card (default off).
72
+ *
73
+ * Persistence is layered, in order of preference:
74
+ * 1. the official `settingsScope` transport (when the deployment exposes
75
+ * the `toolfold` namespace to the web client);
76
+ * 2. this plugin's host route `GET/POST /api/dsh-toolfold/settings`
77
+ * (registered by the host half, backed by the DSH settings service);
78
+ * 3. browser localStorage — degraded fallback for pages without the host
79
+ * half (remote browsers, dynamic-plugin dev mode).
80
+ *
81
+ * The engine works on the rendered DOM only (stable product attributes:
82
+ * `[data-chat-flow]`, `[data-chat-flow-kind]`, `[data-variant="think"]`,
83
+ * `data-state`, `[data-disclosure-row]`), so it degrades gracefully when the
84
+ * product markup changes: the worst case is that folding stops applying.
85
+ *
86
+ * Lifecycle: `installCollapseEngine(document, settings, timers)` returns a
87
+ * disposer; the plugin wires it through `ctx.effect` so stop/update removes
88
+ * the style tag, the observers, the timers, the bars and every injected
89
+ * class. Timers come from the Cordis `timer` service (ctx.timeout /
90
+ * ctx.interval disposers) because the dynamic client environment shadows the
91
+ * browser timer globals.
92
+ */
93
+ window.__ModuleLoader__.load({
94
+ id: 'dsh-toolfold',
95
+ factory: function (require) {
96
+ var module = { exports: {} };
97
+ var exports = module.exports;
98
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
99
+
100
+ // React is a platform seed word in the web shell; the settings card
101
+ // needs it, the folding engine does not.
102
+ var React = null;
103
+ try {
104
+ React = require('react');
105
+ } catch (err) {
106
+ React = null;
107
+ }
108
+
109
+ // ------------------------------------------------------------------
110
+ // Settings. View preferences, persisted in localStorage; shared by the
111
+ // folding engine and the settings card through one snapshot store.
112
+ // ------------------------------------------------------------------
113
+ var SETTINGS_KEY = 'dsh-toolfold.settings.v1';
114
+ /** Pre-rename key; migrated once so existing preferences survive. */
115
+ var LEGACY_SETTINGS_KEY = 'dsh-codex-collapse.settings.v1';
116
+ var DEFAULT_SETTINGS = { durMs: 240, keepThink: false, splitThink: true, stats: false };
117
+
118
+ function loadSettings() {
119
+ var base = { durMs: DEFAULT_SETTINGS.durMs, keepThink: DEFAULT_SETTINGS.keepThink, splitThink: DEFAULT_SETTINGS.splitThink };
120
+ if (typeof localStorage === 'undefined') return base;
121
+ var raw = null;
122
+ try {
123
+ raw = localStorage.getItem(SETTINGS_KEY);
124
+ if (raw === null) {
125
+ var legacy = localStorage.getItem(LEGACY_SETTINGS_KEY);
126
+ if (legacy !== null) {
127
+ raw = legacy;
128
+ localStorage.setItem(SETTINGS_KEY, legacy);
129
+ localStorage.removeItem(LEGACY_SETTINGS_KEY);
130
+ }
131
+ }
132
+ } catch (err) {
133
+ return base;
134
+ }
135
+ if (raw === null) return base;
136
+ try {
137
+ var parsed = JSON.parse(raw);
138
+ var dur = Number(parsed.durMs);
139
+ return {
140
+ durMs: Number.isFinite(dur) ? Math.max(0, Math.min(2000, Math.round(dur))) : DEFAULT_SETTINGS.durMs,
141
+ keepThink: parsed.keepThink === true,
142
+ splitThink: parsed.splitThink !== false,
143
+ stats: parsed.stats === true
144
+ };
145
+ } catch (err) {
146
+ return base;
147
+ }
148
+ }
149
+
150
+ /** Minimal SnapshotStore: stable getSnapshot until update replaces it. */
151
+ function createSettingsStore() {
152
+ var snapshot = loadSettings();
153
+ var listeners = [];
154
+ return {
155
+ getSnapshot: function () { return snapshot; },
156
+ subscribe: function (listener) {
157
+ listeners.push(listener);
158
+ return function () {
159
+ var index = listeners.indexOf(listener);
160
+ if (index !== -1) listeners.splice(index, 1);
161
+ };
162
+ },
163
+ update: function (patch) {
164
+ snapshot = {
165
+ durMs: patch.durMs === undefined ? snapshot.durMs : patch.durMs,
166
+ keepThink: patch.keepThink === undefined ? snapshot.keepThink : patch.keepThink,
167
+ splitThink: patch.splitThink === undefined ? snapshot.splitThink : patch.splitThink === true,
168
+ stats: patch.stats === undefined ? snapshot.stats : patch.stats
169
+ };
170
+ if (typeof localStorage !== 'undefined') {
171
+ try {
172
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(snapshot));
173
+ } catch (err) { /* storage full/blocked: settings stay in-memory */ }
174
+ }
175
+ for (var i = 0; i < listeners.length; i++) listeners[i]();
176
+ }
177
+ };
178
+ }
179
+
180
+ // ------------------------------------------------------------------
181
+ // DSH settings bridge. The folding engine and the settings card read one
182
+ // store; this bridge decides WHERE the store's values come from and go:
183
+ // the official settingsScope transport when the deployment exposes the
184
+ // namespace, else the host half's route (backed by the DSH settings
185
+ // service → ~/.dsh/settings.yaml), else localStorage. `status()` tells
186
+ // the card which tier is live.
187
+ // ------------------------------------------------------------------
188
+ var DSH_API = '/api/dsh-toolfold/settings';
189
+
190
+ function clampDur(value) {
191
+ var dur = Number(value);
192
+ return Number.isFinite(dur) ? Math.max(0, Math.min(2000, Math.round(dur))) : undefined;
193
+ }
194
+
195
+ /** Merge one fully-resolved DSH section into the local store. */
196
+ function adoptSection(store, section) {
197
+ store.update({
198
+ durMs: clampDur(section.durMs),
199
+ keepThink: section.keepThink === undefined ? undefined : section.keepThink === true,
200
+ splitThink: section.splitThink === undefined ? undefined : section.splitThink === true,
201
+ stats: section.stats === undefined ? undefined : section.stats === true
202
+ });
203
+ }
204
+
205
+ function createSettingsBridge(ctx, store) {
206
+ var scope = null;
207
+ var scopeReady = false;
208
+ var routeOk = false;
209
+ var disposers = [];
210
+
211
+ // 1) Official settingsScope transport (settingsScope / webUiSettings).
212
+ var binder = null;
213
+ try {
214
+ if (typeof ctx.get === 'function') {
215
+ binder = ctx.get('webUiSettings') || ctx.get('settingsScope') || null;
216
+ }
217
+ } catch (err) {
218
+ binder = null;
219
+ }
220
+ if (binder !== null && typeof binder.bind === 'function') {
221
+ try {
222
+ scope = binder.bind({ namespace: 'toolfold' });
223
+ } catch (err) {
224
+ scope = null;
225
+ }
226
+ }
227
+ if (scope !== null && typeof scope.subscribe === 'function' && typeof scope.getSnapshot === 'function') {
228
+ disposers.push(scope.subscribe(function () {
229
+ var snap = scope.getSnapshot();
230
+ if (snap === null || typeof snap !== 'object') return;
231
+ if (snap.status === 'ready' && snap.value !== null && typeof snap.value === 'object') {
232
+ scopeReady = true;
233
+ routeOk = false; // the official transport outranks the route
234
+ adoptSection(store, snap.value);
235
+ } else if (snap.status === 'unavailable') {
236
+ scopeReady = false;
237
+ void routeLoad();
238
+ }
239
+ }));
240
+ }
241
+
242
+ // 2) The host half's route (registered by lib/index.js).
243
+ function routeLoad() {
244
+ if (typeof fetch !== 'function') return Promise.resolve(false);
245
+ return fetch(DSH_API, { method: 'GET', cache: 'no-store' })
246
+ .then(function (res) { return res.ok ? res.json() : null; })
247
+ .then(function (body) {
248
+ if (body === null || body.ok !== true || body.value === null || typeof body.value !== 'object') {
249
+ routeOk = false;
250
+ return false;
251
+ }
252
+ routeOk = true;
253
+ adoptSection(store, body.value.value === undefined ? body.value : body.value.value);
254
+ return true;
255
+ })
256
+ .catch(function () { routeOk = false; return false; });
257
+ }
258
+
259
+ function routeWrite(field, value, op) {
260
+ if (typeof fetch !== 'function') return Promise.resolve(false);
261
+ var payload = op === 'unset'
262
+ ? { op: 'unset', field: field }
263
+ : { op: 'set', field: field, value: value };
264
+ return fetch(DSH_API, {
265
+ method: 'POST',
266
+ cache: 'no-store',
267
+ headers: { 'content-type': 'application/json' },
268
+ body: JSON.stringify(payload)
269
+ })
270
+ .then(function (res) { return res.ok ? res.json() : null; })
271
+ .then(function (body) {
272
+ if (body === null || body.ok !== true || body.value === null || typeof body.value !== 'object') return false;
273
+ routeOk = true;
274
+ adoptSection(store, body.value.value === undefined ? body.value : body.value.value);
275
+ return true;
276
+ })
277
+ .catch(function () { return false; });
278
+ }
279
+
280
+ return {
281
+ /** Apply one field change: optimistic local update, then persist through the live tier. */
282
+ write: function (field, value) {
283
+ var patch = {};
284
+ patch[field] = value;
285
+ store.update(patch);
286
+ if (scopeReady && scope !== null && typeof scope.set === 'function') {
287
+ scope.set(field, value).catch(function () {});
288
+ } else {
289
+ void routeWrite(field, value, 'set');
290
+ }
291
+ },
292
+ unset: function (field) {
293
+ if (scopeReady && scope !== null && typeof scope.unset === 'function') {
294
+ scope.unset(field).catch(function () {});
295
+ } else {
296
+ void routeWrite(field, undefined, 'unset');
297
+ }
298
+ },
299
+ /** 'dsh' when a host-backed tier is live, 'local' otherwise. */
300
+ status: function () { return scopeReady || routeOk ? 'dsh' : 'local'; },
301
+ load: routeLoad,
302
+ dispose: function () {
303
+ for (var i = 0; i < disposers.length; i++) disposers[i]();
304
+ }
305
+ };
306
+ }
307
+
308
+ // ------------------------------------------------------------------
309
+ // Styles. Class names carry the `ccx` prefix; colors reuse the
310
+ // product's --dsw-* semantic aliases with plain fallbacks. Durations
311
+ // ride --ccx-dur / --ccx-step custom properties (set by the engine).
312
+ // ------------------------------------------------------------------
313
+ var STYLE_ID = 'dsh-toolfold/style';
314
+ var css = [
315
+ '[data-chat-flow] [data-chat-flow-kind].ccxMerged{display:none}',
316
+ '[data-chat-flow] [data-chat-flow-kind="assistant-step"].ccxEmpty{display:none}',
317
+ '[data-chat-flow]:not(.ccxKeepThink) [data-variant="think"][data-state="ok"]{display:none}',
318
+ '[data-chat-flow] .ccxBar{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:3px 0;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left}',
319
+ '[data-chat-flow] .ccxBar:focus-visible{outline:2px solid var(--dsw-static-deepseek-500,#4d6bfe);outline-offset:1px}',
320
+ '.ccxBarCall{flex:1 1 auto;min-width:0;display:flex;align-items:center;pointer-events:none;max-width:1400px;overflow:hidden;transition:max-width var(--ccx-dur,260ms) ease,opacity calc(var(--ccx-dur,260ms)*0.77) ease}',
321
+ '[data-chat-flow] .ccxBar.ccxExpanded .ccxBarCall{max-width:0;opacity:0}',
322
+ '.ccxBarCall > *{flex:1 1 auto;min-width:0;width:100%}',
323
+ '.ccxBarIcon{flex:none;font-size:10px;line-height:20px;opacity:.75;transition:transform var(--ccx-dur,240ms) ease}',
324
+ '.ccxBarLabel{flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:transform var(--ccx-dur,240ms) ease}',
325
+ '@keyframes ccxFall{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}',
326
+ '[data-chat-flow] [data-chat-flow-kind].ccxFalling{animation:ccxFall var(--ccx-dur,240ms) cubic-bezier(0.34,1.56,0.64,1) both;animation-delay:calc(var(--ccx-i,0)*var(--ccx-step,45ms))}',
327
+ '@keyframes ccxRise{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-20px)}}',
328
+ '[data-chat-flow] [data-chat-flow-kind].ccxCollapsing{animation:ccxRise var(--ccx-dur,240ms) cubic-bezier(0,0,0.58,1) both;animation-delay:calc(var(--ccx-i,0)*var(--ccx-step,45ms))}',
329
+ '@media (prefers-reduced-motion:reduce){[data-chat-flow] [data-chat-flow-kind].ccxFalling,[data-chat-flow] [data-chat-flow-kind].ccxCollapsing{animation:none}.ccxBarCall,.ccxBarIcon,.ccxBarLabel{transition:none}}',
330
+ // Settings card chrome — mirrors the product's PluginCard / field
331
+ // styles token-for-token, so the card is indistinguishable from the
332
+ // built-in ones (bordered 12px card, header with name over
333
+ // description, rotating chevron, field rows with label/hint).
334
+ '.ccxCard{list-style:none;border:1px solid var(--dsw-alias-border-l2,#e4e4e7);border-radius:12px;background:var(--dsw-alias-bg-layer-3,#fff);transition:border-color .16s,background .16s}',
335
+ '.ccxCard:hover{border-color:var(--dsw-alias-label-dimmed,#a1a1aa)}',
336
+ '.ccxCardOpen{background:var(--dsw-alias-bg-layer-2,#fafafa);border-color:var(--dsw-alias-label-dimmed,#a1a1aa)}',
337
+ '.ccxHeader{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}',
338
+ '.ccxHeader:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:-2px}',
339
+ '.ccxHeadText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}',
340
+ '.ccxName{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary,#222)}',
341
+ '.ccxDescription{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#888)}',
342
+ '.ccxChevron{flex:none;font-size:14px;line-height:1;color:var(--dsw-alias-label-tertiary,#888);transition:transform .16s}',
343
+ '.ccxChevronOpen{transform:rotate(180deg)}',
344
+ '.ccxBody{border-top:1px solid var(--dsw-alias-border-l2,#e4e4e7);margin:0 16px;padding:4px 0 8px}',
345
+ '.ccxField{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
346
+ '.ccxField + .ccxField{border-top:1px solid var(--dsw-alias-border-l2,#e4e4e7)}',
347
+ '.ccxFieldHead{display:flex;align-items:center;gap:8px}',
348
+ '.ccxFieldLabel{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary,#222)}',
349
+ '.ccxFieldHint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#888)}',
350
+ '.ccxBadge{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f0f0f2);color:var(--dsw-alias-label-secondary,#666)}',
351
+ '.ccxRange{width:100%;height:34px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe);cursor:pointer}',
352
+ '.ccxToggle{width:16px;height:16px;margin:0;flex:none;accent-color:var(--dsw-alias-brand-primary,#4d6bfe);cursor:pointer}',
353
+ '.ccxStatRow{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0;font-size:12px;line-height:1.5}',
354
+ '.ccxStatValue{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#666)}'
355
+ ].join('');
356
+
357
+ /**
358
+ * Install the folding engine on a document.
359
+ * @param doc - the browser document of the Web GUI page.
360
+ * @param settings - the shared settings store (getSnapshot/subscribe).
361
+ * @returns a disposer that removes every side effect.
362
+ */
363
+ function installCollapseEngine(doc, settings, timers) {
364
+ var FLOW_SELECTOR = '[data-chat-flow]';
365
+ var BADGE_ATTR = 'data-ccx-badge';
366
+ var MERGED_CLASS = 'ccxMerged';
367
+ var EMPTY_CLASS = 'ccxEmpty';
368
+ var FALLING_CLASS = 'ccxFalling';
369
+ var COLLAPSING_CLASS = 'ccxCollapsing';
370
+ var KEEP_CLASS = 'ccxKeepThink';
371
+ /** Ease-out for the row height shrink (no overshoot: heights clamp). */
372
+ var COLLAPSE_EASE = 'cubic-bezier(0,0,0.58,1)';
373
+
374
+ var styleTag = null;
375
+ var adoptedStyle = false;
376
+ /** flow element -> observation mode ('light' | 'full') */
377
+ var flows = new Map();
378
+ /** flow element -> its attached MutationObserver (absent while hidden) */
379
+ var flowObservers = new Map();
380
+ /** parent element -> childList-only observer (flow mounts/unmounts) */
381
+ var parentObservers = new Map();
382
+ /** flow element -> Set of expanded run node keys */
383
+ var expanded = new Map();
384
+ /** flow element -> Map<run head key, bar element> */
385
+ var barsByFlow = new Map();
386
+ /** flow element -> Set of run head keys mid collapse animation */
387
+ var collapsing = new Map();
388
+ /** flows whose DOM changed since the last pass (pending re-pass) */
389
+ var dirtyFlows = new Set();
390
+ /** per-flow reason for the pending pass: content | think | rows */
391
+ var flowReasons = new Map();
392
+ /** flow element -> Set of tool rows touched by content mutations */
393
+ var contentRowsByFlow = new Map();
394
+ /** per-flow counter for synthetic run keys (rows lacking a node key) */
395
+ var syntheticKeys = new Map();
396
+ /** flows may have appeared or disappeared since the last pass */
397
+ var structureDirty = true;
398
+ /** observers live only while the tab is visible */
399
+ var active = true;
400
+ /** slow safety rescan while visible (catches remounted flows) */
401
+ var rescanTimer = null;
402
+ /** rescan delay, backing off while nothing changes (3s..10s) */
403
+ var rescanDelay = 3000;
404
+ var rafId = 0;
405
+ var pending = false;
406
+ var disposed = false;
407
+ var offSettings = null;
408
+ /** deferred boot init (one frame after activation) */
409
+ var booted = false;
410
+ var bootRaf = 0;
411
+ /** cached MediaQueryList for prefers-reduced-motion */
412
+ var motionMql = null;
413
+ /** cache of confirmed tool-call rows (closestToolCall positive hits) */
414
+ var toolRowCache = new WeakMap();
415
+ /** opt-in live cost stats (settings card); created at engine init */
416
+ var statsObj = null;
417
+
418
+ function statsBegin() {
419
+ if (statsObj === null || !statsObj.enabled) return null;
420
+ return performance.now();
421
+ }
422
+
423
+ function statsEnd(key, count, t0) {
424
+ if (t0 === null) return;
425
+ statsObj[key] += count;
426
+ statsObj[key + 'Ms'] += performance.now() - t0;
427
+ }
428
+
429
+ function ensureStats() {
430
+ if (statsObj !== null) return statsObj;
431
+ statsObj = {
432
+ enabled: settings.getSnapshot().stats === true,
433
+ start: Date.now(),
434
+ obs: 0, obsMs: 0,
435
+ refresh: 0, refreshMs: 0,
436
+ pass: 0, passMs: 0,
437
+ scan: 0, scanMs: 0,
438
+ clone: 0, cloneMs: 0,
439
+ skip: 0
440
+ };
441
+ return statsObj;
442
+ }
443
+
444
+ function reducedMotion() {
445
+ if (motionMql === null) {
446
+ motionMql = (typeof matchMedia === 'function') ? matchMedia('(prefers-reduced-motion: reduce)') : null;
447
+ }
448
+ return motionMql !== null && motionMql.matches;
449
+ }
450
+
451
+ function durMs() {
452
+ var value = settings.getSnapshot().durMs;
453
+ return reducedMotion() ? 0 : value;
454
+ }
455
+
456
+ function stepMs() {
457
+ var value = settings.getSnapshot().durMs;
458
+ return reducedMotion() ? 0 : Math.max(0, Math.min(60, Math.round(value * 45 / 240)));
459
+ }
460
+
461
+ /** Push the current durations into --ccx-dur / --ccx-step. */
462
+ function applySettings() {
463
+ var style = doc.documentElement.style;
464
+ style.setProperty('--ccx-dur', durMs() + 'ms');
465
+ style.setProperty('--ccx-step', stepMs() + 'ms');
466
+ }
467
+
468
+ // ----------------------------------------------------------------
469
+ // Style tag (self-managed; removed on dispose).
470
+ // ----------------------------------------------------------------
471
+ function insertStyle() {
472
+ var existing = doc.querySelector('style[data-plugin-css="' + STYLE_ID + '"]');
473
+ if (existing !== null) {
474
+ styleTag = existing;
475
+ adoptedStyle = true;
476
+ return;
477
+ }
478
+ var tag = doc.createElement('style');
479
+ tag.setAttribute('data-plugin', 'dsh-toolfold');
480
+ tag.setAttribute('data-plugin-css', STYLE_ID);
481
+ tag.textContent = css;
482
+ doc.head.appendChild(tag);
483
+ styleTag = tag;
484
+ adoptedStyle = false;
485
+ }
486
+
487
+ // ----------------------------------------------------------------
488
+ // Scheduling: dirty-flag based, rAF-batched, one pass per frame at
489
+ // most. Only flows whose DOM actually changed are re-passed.
490
+ // ----------------------------------------------------------------
491
+ var REASON_RANK = { content: 1, think: 2, rows: 3 };
492
+
493
+ function markDirty(flow, reason) {
494
+ if (disposed) return;
495
+ var prev = flowReasons.get(flow);
496
+ if (prev === undefined || REASON_RANK[prev] < REASON_RANK[reason]) {
497
+ flowReasons.set(flow, reason);
498
+ }
499
+ dirtyFlows.add(flow);
500
+ // Any flow event means the page is alive: keep the safety rescan
501
+ // prompt so newly mounted containers are discovered quickly.
502
+ if (rescanDelay > 3000) {
503
+ rescanDelay = 3000;
504
+ if (rescanTimer !== null) {
505
+ timers.cancel(rescanTimer);
506
+ rescanTimer = null;
507
+ scheduleNextRescan();
508
+ }
509
+ }
510
+ schedule();
511
+ }
512
+
513
+ function schedule() {
514
+ if (pending || disposed) return;
515
+ pending = true;
516
+ if (typeof requestAnimationFrame === 'function') {
517
+ rafId = requestAnimationFrame(function () {
518
+ pending = false;
519
+ refresh();
520
+ });
521
+ } else {
522
+ pending = false;
523
+ refresh();
524
+ }
525
+ }
526
+
527
+ // ----------------------------------------------------------------
528
+ // Flow discovery and per-flow observers.
529
+ // ----------------------------------------------------------------
530
+ /**
531
+ * Whether an added/removed node can change folding outcomes: a row
532
+ * (`[data-chat-flow-kind]`) or a think block (`[data-variant="think"]`).
533
+ */
534
+ function nodeHasMarker(node) {
535
+ if (node.nodeType !== 1) return false;
536
+ if (typeof node.matches === 'function'
537
+ && node.matches('[data-chat-flow-kind],[data-variant="think"]')) return true;
538
+ if (typeof node.querySelector === 'function'
539
+ && node.querySelector('[data-chat-flow-kind],[data-variant="think"]') !== null) return true;
540
+ return false;
541
+ }
542
+
543
+ /**
544
+ * Nearest ancestor (or self) that is a tool-call row. Plain attribute
545
+ * walk — the MutationObserver hot path never invokes the selector
546
+ * engine. The RESULT is cached on the start element (both positive and
547
+ * negative), so streaming — which keeps mutating the same containers —
548
+ * amortizes the walk to O(1) per container after the first event.
549
+ * Staleness would require reparenting a container into a tool row,
550
+ * which the product never does; worst case one missed content refresh.
551
+ */
552
+ function closestToolCall(el) {
553
+ var start = (el.nodeType === 1) ? el : el.parentElement;
554
+ if (start === null) return null;
555
+ var hit = toolRowCache.get(start);
556
+ if (hit !== undefined) return hit;
557
+ var node = start;
558
+ while (node !== null && node.nodeType === 1) {
559
+ var cached = toolRowCache.get(node);
560
+ if (cached !== undefined) {
561
+ node = cached;
562
+ break;
563
+ }
564
+ if (node.getAttribute('data-chat-flow-kind') === 'tool-call') break;
565
+ node = node.parentElement;
566
+ }
567
+ toolRowCache.set(start, node);
568
+ return node;
569
+ }
570
+
571
+ /**
572
+ * Classify a mutation batch by what it can change:
573
+ * - 'rows': row-level add/remove/reorder, or added/removed nodes that
574
+ * are or contain rows / think blocks — the merge must be rebuilt;
575
+ * - 'think': a `data-state` flip on a think element (settled thinking
576
+ * starts hiding, empties its assistant row, and in merge mode stops
577
+ * separating runs) — the merge must be rebuilt;
578
+ * - 'content': a mutation inside a tool-call row (result streaming) —
579
+ * only that run's bar clone can be stale, no merge rebuild needed;
580
+ * - null: content streaming inside any other row (think text tokens,
581
+ * markdown spans) — cannot change any folding outcome.
582
+ * The single appended-text-node case short-circuits with zero DOM
583
+ * walks, so agent/think streaming costs nothing at all. Content
584
+ * mutations report the affected tool row(s) through `collect`, so the
585
+ * refresh touches only the bar of the run whose LAST row changed.
586
+ */
587
+ function mutationReason(records, flow, collect) {
588
+ if (records.length === 1) {
589
+ var only = records[0];
590
+ if (only.type === 'childList' && only.removedNodes.length === 0
591
+ && only.addedNodes.length === 1 && only.addedNodes[0].nodeType === 3) {
592
+ return null;
593
+ }
594
+ }
595
+ var best = 0; // 0 none, 1 content, 2 think, 3 rows
596
+ for (var i = 0; i < records.length; i++) {
597
+ var record = records[i];
598
+ if (record.type === 'attributes') {
599
+ if (record.attributeName === 'data-state'
600
+ && record.target !== null && record.target.nodeType === 1
601
+ && typeof record.target.matches === 'function'
602
+ && record.target.matches('[data-variant="think"]')) {
603
+ if (best < 2) best = 2;
604
+ }
605
+ continue;
606
+ }
607
+ if (record.type === 'characterData') {
608
+ var toolRow = closestToolCall(record.target);
609
+ if (toolRow !== null) {
610
+ if (collect !== undefined) collect(toolRow);
611
+ if (best < 1) best = 1;
612
+ }
613
+ continue;
614
+ }
615
+ if (record.type !== 'childList') continue;
616
+ var target = record.target;
617
+ if (target === flow) {
618
+ best = 3;
619
+ continue;
620
+ }
621
+ if (target !== null && target.nodeType === 1) {
622
+ var row = closestToolCall(target);
623
+ if (row !== null) {
624
+ if (collect !== undefined) collect(row);
625
+ if (best < 1) best = 1;
626
+ continue;
627
+ }
628
+ }
629
+ var lists = [record.addedNodes, record.removedNodes];
630
+ for (var l = 0; l < lists.length && best < 3; l++) {
631
+ var nodes = lists[l];
632
+ for (var j = 0; j < nodes.length; j++) {
633
+ if (nodeHasMarker(nodes[j])) { best = 3; break; }
634
+ }
635
+ }
636
+ }
637
+ if (best === 0) return null;
638
+ if (best === 1) return 'content';
639
+ if (best === 2) return 'think';
640
+ return 'rows';
641
+ }
642
+
643
+ /**
644
+ * Attach (or replace) the per-flow observer for a mode. 'light' is a
645
+ * childList-only observer on the flow's direct children — the only
646
+ * events that can change folding are row adds/removes/reorders, so a
647
+ * pure-chat flow costs nothing while text streams inside its rows.
648
+ * 'full' adds subtree childList and `data-state` attribute coverage,
649
+ * which foldable content (tool cards, think blocks) actually needs.
650
+ */
651
+ function attachFlowObserver(flow, mode) {
652
+ var existing = flowObservers.get(flow);
653
+ if (existing !== null && existing !== undefined) existing.disconnect();
654
+ if (typeof MutationObserver !== 'function') {
655
+ flowObservers.set(flow, null);
656
+ return;
657
+ }
658
+ var observer;
659
+ if (mode === 'light') {
660
+ observer = new MutationObserver(function () {
661
+ var t0 = statsBegin();
662
+ markDirty(flow, 'rows');
663
+ statsEnd('obs', 1, t0);
664
+ });
665
+ observer.observe(flow, { childList: true });
666
+ } else {
667
+ observer = new MutationObserver(function (records) {
668
+ var t0 = statsBegin();
669
+ var contentSet = undefined;
670
+ var reason = mutationReason(records, flow, function (row) {
671
+ if (contentSet === undefined) {
672
+ contentSet = contentRowsByFlow.get(flow);
673
+ if (contentSet === undefined) {
674
+ contentSet = new Set();
675
+ contentRowsByFlow.set(flow, contentSet);
676
+ }
677
+ }
678
+ contentSet.add(row);
679
+ });
680
+ if (reason !== null) {
681
+ markDirty(flow, reason);
682
+ } else if (statsObj !== null && statsObj.enabled) {
683
+ statsObj.skip += 1;
684
+ }
685
+ statsEnd('obs', 1, t0);
686
+ });
687
+ observer.observe(flow, {
688
+ childList: true,
689
+ subtree: true,
690
+ attributes: true,
691
+ attributeFilter: ['data-state']
692
+ });
693
+ }
694
+ flowObservers.set(flow, observer);
695
+ }
696
+
697
+ /** Switch a flow's observation mode (attaches only while visible). */
698
+ function setFlowMode(flow, mode) {
699
+ if (flows.get(flow) === mode) return;
700
+ flows.set(flow, mode);
701
+ if (active) attachFlowObserver(flow, mode);
702
+ }
703
+
704
+ function discover() {
705
+ var list = doc.querySelectorAll(FLOW_SELECTOR);
706
+ for (var i = 0; i < list.length; i++) {
707
+ var flow = list[i];
708
+ if (flows.has(flow)) continue;
709
+ var needFull = typeof flow.querySelector === 'function'
710
+ && flow.querySelector('[data-chat-flow-kind="tool-call"],[data-variant="think"]') !== null;
711
+ flows.set(flow, needFull ? 'full' : 'light');
712
+ expanded.set(flow, new Set());
713
+ barsByFlow.set(flow, new Map());
714
+ collapsing.set(flow, new Set());
715
+ if (active) attachFlowObserver(flow, needFull ? 'full' : 'light');
716
+ // Newly discovered flows always need an initial pass.
717
+ markDirty(flow, 'rows');
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Flow mount/unmount detection WITHOUT a page-wide observer: each
723
+ * known flow's parent gets a childList-only observer (direct children
724
+ * only, no subtree), so it fires only when a sibling flow appears or
725
+ * disappears — never on chat churn. Parents that appeared while the
726
+ * engine was idle are covered by the slow visible-only rescan.
727
+ */
728
+ function observeParents() {
729
+ if (!active || typeof MutationObserver !== 'function') return;
730
+ flows.forEach(function (mode, flow) {
731
+ var parent = flow.parentElement;
732
+ if (parent === null || parentObservers.has(parent)) return;
733
+ var observer = new MutationObserver(function (records) {
734
+ var t0 = statsBegin();
735
+ var relevant = false;
736
+ for (var i = 0; i < records.length && !relevant; i++) {
737
+ var record = records[i];
738
+ if (record.type !== 'childList') continue;
739
+ var lists = [record.addedNodes, record.removedNodes];
740
+ for (var l = 0; l < lists.length && !relevant; l++) {
741
+ var nodes = lists[l];
742
+ for (var j = 0; j < nodes.length; j++) {
743
+ var node = nodes[j];
744
+ if (node.nodeType !== 1 || typeof node.matches !== 'function') continue;
745
+ if (node.matches(FLOW_SELECTOR)
746
+ || (typeof node.querySelector === 'function'
747
+ && node.querySelector(FLOW_SELECTOR) !== null)) {
748
+ relevant = true;
749
+ break;
750
+ }
751
+ }
752
+ }
753
+ }
754
+ if (relevant) {
755
+ structureDirty = true;
756
+ schedule();
757
+ }
758
+ statsEnd('obs', 1, t0);
759
+ });
760
+ observer.observe(parent, { childList: true });
761
+ parentObservers.set(parent, observer);
762
+ });
763
+ }
764
+
765
+ // ----------------------------------------------------------------
766
+ // Slow safety rescan + visibility gating. The rescan is discovery-
767
+ // only (one attribute scan + map diff), schedules a pass only when a
768
+ // flow actually appeared, and backs off 3s → 10s while the page is
769
+ // quiet (any flow event resets it). While the tab is hidden the
770
+ // engine holds NO observers and NO timers: literally zero work. On
771
+ // return to visibility everything re-attaches and one refresh pass
772
+ // catches up with whatever changed meanwhile.
773
+ // ----------------------------------------------------------------
774
+ function rescanTick() {
775
+ rescanTimer = null;
776
+ if (disposed || !active || doc.hidden) return;
777
+ var t0 = statsBegin();
778
+ // Discovery-only tick: one `[data-chat-flow]` scan + map diff. No
779
+ // pass is scheduled unless a flow was actually discovered (which
780
+ // marks it dirty), and the delay backs off (3s → 10s) while the
781
+ // page is stable — an idle page costs one attribute scan every
782
+ // ~10s at most. Light→full observer upgrades are driven by row
783
+ // events (pass outcomes), never by per-flow subtree scans.
784
+ var before = flows.size;
785
+ discover();
786
+ pruneFlows();
787
+ observeParents();
788
+ if (flows.size === before) {
789
+ // Nothing mounted/unmounted. Back off only while at least one
790
+ // flow is known — with none known, first discovery must stay
791
+ // prompt.
792
+ if (flows.size > 0) rescanDelay = Math.min(rescanDelay * 2, 10000);
793
+ } else {
794
+ rescanDelay = 3000;
795
+ }
796
+ statsEnd('scan', 1, t0);
797
+ scheduleNextRescan();
798
+ }
799
+
800
+ function scheduleNextRescan() {
801
+ if (rescanTimer !== null) return;
802
+ rescanTimer = timers.after(rescanTick, rescanDelay);
803
+ }
804
+
805
+ function startRescan() {
806
+ rescanDelay = 3000;
807
+ scheduleNextRescan();
808
+ }
809
+
810
+ function stopRescan() {
811
+ if (rescanTimer !== null) {
812
+ timers.cancel(rescanTimer);
813
+ rescanTimer = null;
814
+ }
815
+ }
816
+
817
+ function disconnectObservers() {
818
+ flowObservers.forEach(function (observer) {
819
+ if (observer !== null && observer !== undefined) observer.disconnect();
820
+ });
821
+ flowObservers.clear();
822
+ parentObservers.forEach(function (observer) {
823
+ observer.disconnect();
824
+ });
825
+ parentObservers.clear();
826
+ }
827
+
828
+ function onVisibility() {
829
+ setActive(!doc.hidden);
830
+ }
831
+
832
+ function setActive(on) {
833
+ if (on === active) return;
834
+ active = on;
835
+ if (on) {
836
+ if (!booted) {
837
+ bootInit();
838
+ return;
839
+ }
840
+ flows.forEach(function (mode, flow) {
841
+ if (doc.contains(flow)) attachFlowObserver(flow, mode);
842
+ });
843
+ observeParents();
844
+ startRescan();
845
+ structureDirty = true;
846
+ schedule();
847
+ } else {
848
+ stopRescan();
849
+ disconnectObservers();
850
+ }
851
+ }
852
+
853
+ // ----------------------------------------------------------------
854
+ // Row helpers.
855
+ // ----------------------------------------------------------------
856
+ function rowList(flow) {
857
+ var rows = [];
858
+ var children = flow.children;
859
+ for (var i = 0; i < children.length; i++) {
860
+ var el = children[i];
861
+ if (el.nodeType === 1 && el.hasAttribute('data-chat-flow-kind')) rows.push(el);
862
+ }
863
+ return rows;
864
+ }
865
+
866
+ /**
867
+ * Per-row assessment of think-only content, cached by a signature of
868
+ * the row's think blocks (count + data-states + keepThink + splitThink
869
+ * flags). Reassessed only when the signature changes; the check is
870
+ * purely structural, so steady-state passes perform zero layout reads.
871
+ *
872
+ * In split mode (default) a row that only settled Think rows occupy
873
+ * ENDS the run it follows — completed thinking separates runs, it
874
+ * never merges across them. In merge mode it is transparent: it folds
875
+ * WITH the surrounding tool calls. Either way it is empty (hidden)
876
+ * when think is not preserved; with "保留思考" on it stays visible —
877
+ * between the bars in split mode, inside the run on expand in merge
878
+ * mode.
879
+ */
880
+ var rowInfo = new WeakMap();
881
+ var NEUTRAL_ASSESSMENT = { sig: '', thinks: [], transparent: false, empty: false };
882
+
883
+ function thinkSig(thinks, keepThink, splitThink) {
884
+ var sig = (keepThink ? '1' : '0') + (splitThink ? '1' : '0');
885
+ for (var i = 0; i < thinks.length; i++) {
886
+ sig += ':' + (thinks[i].getAttribute('data-state') || '');
887
+ }
888
+ return sig;
889
+ }
890
+
891
+ /**
892
+ * Whether an element contains nothing but think blocks and empty
893
+ * wrappers: every text leaf outside a think block is whitespace-only
894
+ * and every non-think element recurses. Structural — no layout reads,
895
+ * immune to wrapper padding/margins that a height check would mistake
896
+ * for content.
897
+ */
898
+ function thinkOnlyContent(el) {
899
+ var nodes = el.childNodes;
900
+ for (var i = 0; i < nodes.length; i++) {
901
+ var node = nodes[i];
902
+ if (node.nodeType === 3) {
903
+ if (node.textContent.trim() !== '') return false;
904
+ } else if (node.nodeType === 1) {
905
+ if (node.matches('[data-variant="think"]')) continue;
906
+ if (!thinkOnlyContent(node)) return false;
907
+ }
908
+ }
909
+ return true;
910
+ }
911
+
912
+ function assessRow(row, keepThink, splitThink) {
913
+ if (row.getAttribute('data-chat-flow-kind') !== 'assistant-step') return NEUTRAL_ASSESSMENT;
914
+ var cached = rowInfo.get(row);
915
+ if (cached !== undefined && cached.sig === thinkSig(cached.thinks, keepThink, splitThink)) {
916
+ return cached;
917
+ }
918
+ var thinks = row.querySelectorAll('[data-variant="think"]');
919
+ var assessment = {
920
+ sig: thinkSig(thinks, keepThink, splitThink),
921
+ thinks: thinks,
922
+ transparent: false,
923
+ empty: false
924
+ };
925
+ if (thinks.length > 0 && thinkOnlyContent(row)) {
926
+ var allOk = true;
927
+ for (var i = 0; i < thinks.length; i++) {
928
+ if (thinks[i].getAttribute('data-state') !== 'ok') { allOk = false; break; }
929
+ }
930
+ if (allOk) {
931
+ // A row made of settled think alone: transparent (folds with
932
+ // the surrounding calls) only in merge mode; in split mode it
933
+ // ends the run. Hidden unless 保留思考 keeps it visible.
934
+ assessment.transparent = !splitThink;
935
+ assessment.empty = !keepThink;
936
+ }
937
+ }
938
+ rowInfo.set(row, assessment);
939
+ return assessment;
940
+ }
941
+
942
+ // ----------------------------------------------------------------
943
+ // The collapsed-run bar (flow-level element; replaces every card of
944
+ // the run when collapsed). Shows the LAST call's one-line summary.
945
+ // ----------------------------------------------------------------
946
+ /** Strip interactivity from a card-row clone so it is display-only. */
947
+ function sanitizeClone(node) {
948
+ node.removeAttribute('role');
949
+ node.removeAttribute('tabindex');
950
+ node.removeAttribute('aria-expanded');
951
+ node.removeAttribute('data-expandable');
952
+ var buttons = node.querySelectorAll('button');
953
+ for (var i = 0; i < buttons.length; i++) {
954
+ var button = buttons[i];
955
+ var span = doc.createElement('span');
956
+ span.className = button.className;
957
+ span.textContent = button.textContent;
958
+ button.replaceWith(span);
959
+ }
960
+ return node;
961
+ }
962
+
963
+ function createBar(flow, run) {
964
+ var bar = doc.createElement('div');
965
+ bar.className = 'ccxBar';
966
+ bar.setAttribute(BADGE_ATTR, '');
967
+ bar.setAttribute('role', 'button');
968
+ bar.setAttribute('tabindex', '0');
969
+ var call = doc.createElement('span');
970
+ call.className = 'ccxBarCall';
971
+ call.setAttribute('aria-hidden', 'true');
972
+ var icon = doc.createElement('span');
973
+ icon.className = 'ccxBarIcon';
974
+ var label = doc.createElement('span');
975
+ label.className = 'ccxBarLabel';
976
+ bar.appendChild(call);
977
+ bar.appendChild(icon);
978
+ bar.appendChild(label);
979
+ // Cached parts: updateBar/updateBarClone avoid selector lookups.
980
+ bar._ccxIcon = icon;
981
+ bar._ccxLabel = label;
982
+ bar._ccxCall = call;
983
+ var toggle = function () {
984
+ var set = expanded.get(flow);
985
+ if (set === undefined || disposed) return;
986
+ if (set.has(run.headKey)) {
987
+ startCollapse(flow, run, bar);
988
+ } else {
989
+ set.add(run.headKey);
990
+ cancelCollapse(flow, run);
991
+ pass(flow);
992
+ pinBar(bar, run);
993
+ }
994
+ };
995
+ bar.addEventListener('click', toggle);
996
+ bar.addEventListener('keydown', function (event) {
997
+ if (event.key !== 'Enter' && event.key !== ' ') return;
998
+ event.preventDefault();
999
+ toggle();
1000
+ });
1001
+ return bar;
1002
+ }
1003
+
1004
+ /** Waterfall entrance: the run's rows cascade in top-down on expand. */
1005
+ function animateFall(bar, run) {
1006
+ var rows = run.rows;
1007
+ var dur = durMs();
1008
+ var step = stepMs();
1009
+ for (var i = 0; i < rows.length; i++) {
1010
+ rows[i].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
1011
+ rows[i].style.setProperty('--ccx-i', String(i));
1012
+ }
1013
+ for (var j = 0; j < rows.length; j++) rows[j].classList.add(FALLING_CLASS);
1014
+ if (bar._ccxFallTimer !== undefined) timers.cancel(bar._ccxFallTimer);
1015
+ bar._ccxFallTimer = timers.after(function () {
1016
+ bar._ccxFallTimer = undefined;
1017
+ if (disposed || !bar.isConnected) return;
1018
+ for (var k = 0; k < rows.length; k++) rows[k].classList.remove(FALLING_CLASS);
1019
+ }, Math.max(0, rows.length - 1) * step + dur + 60);
1020
+ }
1021
+
1022
+ /**
1023
+ * Collapse each card's ROW in place: freeze the current height, then
1024
+ * transition height to 0 with the same stagger as the rise animation.
1025
+ * The measured trailing gap to the next visible row is cancelled via
1026
+ * margin-bottom, so everything below the run follows upward
1027
+ * continuously — no blank hole while the cards fade, no snap when the
1028
+ * merge finally applies. Styles are cleared on completion (and on
1029
+ * cancel/dispose) so rows return to auto layout.
1030
+ */
1031
+ function animateRowShrink(rows, dur, step) {
1032
+ if (dur <= 0 || rows.length === 0) return;
1033
+ for (var i = 0; i < rows.length; i++) {
1034
+ var row = rows[i];
1035
+ var h = row.offsetHeight;
1036
+ if (h <= 0) continue;
1037
+ var gap = 0;
1038
+ var next = row.nextElementSibling;
1039
+ while (next !== null && next.offsetHeight === 0) next = next.nextElementSibling;
1040
+ if (next !== null) {
1041
+ gap = Math.max(0, next.getBoundingClientRect().top - row.getBoundingClientRect().bottom);
1042
+ }
1043
+ row.style.height = h + 'px';
1044
+ row.style.overflow = 'hidden';
1045
+ row.style.marginBottom = '0px';
1046
+ row.style.transition = 'height ' + dur + 'ms ' + COLLAPSE_EASE + ',margin-bottom ' + dur + 'ms ' + COLLAPSE_EASE;
1047
+ row.style.transitionDelay = (i * step) + 'ms';
1048
+ void row.offsetHeight; // commit the frozen height before shrinking
1049
+ row.style.height = '0px';
1050
+ row.style.marginBottom = (-gap) + 'px';
1051
+ }
1052
+ }
1053
+
1054
+ /** Remove the shrink animation's inline styles (restores auto layout). */
1055
+ function clearRowShrink(row) {
1056
+ row.style.removeProperty('height');
1057
+ row.style.removeProperty('overflow');
1058
+ row.style.removeProperty('margin-bottom');
1059
+ row.style.removeProperty('transition');
1060
+ row.style.removeProperty('transition-delay');
1061
+ }
1062
+
1063
+ /** Rise-out exit: cards cascade back up, then the run collapses. */
1064
+ function startCollapse(flow, run, bar) {
1065
+ var set = expanded.get(flow);
1066
+ if (set === undefined || !set.has(run.headKey)) return;
1067
+ set.delete(run.headKey);
1068
+ var per = collapsing.get(flow);
1069
+ if (per === undefined) {
1070
+ per = new Set();
1071
+ collapsing.set(flow, per);
1072
+ }
1073
+ per.add(run.headKey);
1074
+ var rows = run.rows;
1075
+ var dur = durMs();
1076
+ var step = stepMs();
1077
+ for (var i = 0; i < rows.length; i++) {
1078
+ rows[i].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
1079
+ rows[i].style.setProperty('--ccx-i', String(i));
1080
+ }
1081
+ for (var j = 0; j < rows.length; j++) rows[j].classList.add(COLLAPSING_CLASS);
1082
+ animateRowShrink(rows, dur, step);
1083
+ // The bar flips to collapsed visuals now; the cards stay visible for
1084
+ // the rise (pass skips ccxMerged while the head key is collapsing).
1085
+ pass(flow);
1086
+ if (bar._ccxCollapseTimer !== undefined) timers.cancel(bar._ccxCollapseTimer);
1087
+ bar._ccxCollapseTimer = timers.after(function () {
1088
+ bar._ccxCollapseTimer = undefined;
1089
+ if (disposed || !flow.isConnected) return;
1090
+ var perNow = collapsing.get(flow);
1091
+ if (perNow !== undefined) {
1092
+ perNow.delete(run.headKey);
1093
+ if (perNow.size === 0) collapsing.delete(flow);
1094
+ }
1095
+ for (var k = 0; k < rows.length; k++) {
1096
+ rows[k].classList.remove(COLLAPSING_CLASS);
1097
+ clearRowShrink(rows[k]);
1098
+ }
1099
+ pass(flow); // applies ccxMerged now
1100
+ }, Math.max(0, rows.length - 1) * step + dur + 60);
1101
+ }
1102
+
1103
+ /** Abort a pending collapse (the user re-expanded mid-animation). */
1104
+ function cancelCollapse(flow, run) {
1105
+ var per = collapsing.get(flow);
1106
+ if (per !== undefined) {
1107
+ per.delete(run.headKey);
1108
+ if (per.size === 0) collapsing.delete(flow);
1109
+ }
1110
+ var rows = run.rows;
1111
+ for (var i = 0; i < rows.length; i++) {
1112
+ rows[i].classList.remove(COLLAPSING_CLASS);
1113
+ clearRowShrink(rows[i]);
1114
+ }
1115
+ }
1116
+
1117
+ // ----------------------------------------------------------------
1118
+ // Scroll pinning. The product's chat view follows its own column
1119
+ // height growth while the reader is pinned to the bottom; expanding a
1120
+ // group near the bottom would therefore shove the bar out of the top
1121
+ // of the viewport. Keep the bar's viewport position fixed for the
1122
+ // duration of the fall animation; stop as soon as the user scrolls.
1123
+ // ----------------------------------------------------------------
1124
+ function findScrollport(el) {
1125
+ var host = el.closest('[data-conversation-scroll]');
1126
+ if (host !== null) return host;
1127
+ var node = el.parentElement;
1128
+ while (node !== null) {
1129
+ var style = getComputedStyle(node);
1130
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll')
1131
+ && node.scrollHeight > node.clientHeight) return node;
1132
+ node = node.parentElement;
1133
+ }
1134
+ return null;
1135
+ }
1136
+
1137
+ function pinBar(bar, run) {
1138
+ if (typeof requestAnimationFrame !== 'function') return;
1139
+ var sp = findScrollport(bar);
1140
+ if (sp === null) return;
1141
+ var savedTop = sp.scrollTop;
1142
+ var targetTop = bar.getBoundingClientRect().top - sp.getBoundingClientRect().top;
1143
+ // Restore the reader position immediately: the sticky-bottom follow
1144
+ // re-pins only while the reader is still flagged at-bottom, and the
1145
+ // scroll event our write queues clears that flag.
1146
+ sp.scrollTop = savedTop;
1147
+ var dur = durMs();
1148
+ var step = stepMs();
1149
+ var until = Date.now() + Math.max(0, run.rows.length - 1) * step + dur + 260;
1150
+ var stopped = false;
1151
+ var cancel = function () {
1152
+ stopped = true;
1153
+ sp.removeEventListener('wheel', cancel);
1154
+ sp.removeEventListener('touchstart', cancel);
1155
+ };
1156
+ sp.addEventListener('wheel', cancel, { passive: true });
1157
+ sp.addEventListener('touchstart', cancel, { passive: true });
1158
+ var frame = function () {
1159
+ if (stopped || disposed || !bar.isConnected) {
1160
+ cancel();
1161
+ return;
1162
+ }
1163
+ var current = bar.getBoundingClientRect().top - sp.getBoundingClientRect().top;
1164
+ var delta = current - targetTop;
1165
+ if (Math.abs(delta) > 0.5) sp.scrollTop += delta;
1166
+ if (Date.now() < until) requestAnimationFrame(frame);
1167
+ else cancel();
1168
+ };
1169
+ requestAnimationFrame(frame);
1170
+ }
1171
+
1172
+ /** Refresh the bar for one run: copy, aria, and the last-call clone. */
1173
+ function updateBar(bar, run) {
1174
+ // The run this bar stands in for, kept current by every full pass so
1175
+ // the light 'content' pass can refresh clones without rebuilding.
1176
+ bar._ccxRun = run;
1177
+ // The whole run folds into the bar, so the count is the run's TOTAL
1178
+ // tool-call count (every card of the group is hidden when collapsed).
1179
+ var total = run.toolRows.length;
1180
+ bar.setAttribute('aria-expanded', run.expanded ? 'true' : 'false');
1181
+ bar.setAttribute('aria-label', (run.expanded ? '收起' : '展开') + ' ' + total + ' 个工具调用');
1182
+ bar.title = run.expanded ? '点击收起' : '点击展开';
1183
+ bar._ccxIcon.textContent = run.expanded ? '▾' : '▸';
1184
+ bar._ccxLabel.textContent = run.expanded
1185
+ ? '已展开 ' + total + ' 个工具调用 · 点击收起'
1186
+ : '已折叠 ' + total + ' 个工具调用 · 点击展开';
1187
+ // Expanding collapses the summary clone (fade + shrink) so the label
1188
+ // slides left, and the hidden cards cascade in below the bar.
1189
+ var wasExpanded = bar._ccxPrevExpanded === true;
1190
+ bar.classList.toggle('ccxExpanded', run.expanded);
1191
+ if (run.expanded && !wasExpanded) animateFall(bar, run);
1192
+ bar._ccxPrevExpanded = run.expanded;
1193
+ if (!run.expanded) updateBarClone(bar);
1194
+ }
1195
+
1196
+ /** Replace the bar's clone contents; counted as one clone op. */
1197
+ function replaceClone(call, node) {
1198
+ var t0 = statsBegin();
1199
+ while (call.firstChild !== null) call.removeChild(call.firstChild);
1200
+ call.appendChild(node);
1201
+ statsEnd('clone', 1, t0);
1202
+ }
1203
+
1204
+ /**
1205
+ * Clone the last call's collapsed one-line row (product chrome) so
1206
+ * the bar itself displays the last tool call. Re-clone only when the
1207
+ * source row's markup changed (e.g. a tool result just arrived), and
1208
+ * at most every 120ms while a result streams in — the clone's
1209
+ * freshness is invisible beyond that, but serialization plus clone
1210
+ * DOM work would otherwise run on every frame of the stream.
1211
+ */
1212
+ function updateBarClone(bar) {
1213
+ var run = bar._ccxRun;
1214
+ if (run === undefined) return;
1215
+ var call = bar._ccxCall;
1216
+ var source = run.lastRow.querySelector('[data-disclosure-row]');
1217
+ if (source !== null) {
1218
+ var sig = source.innerHTML;
1219
+ if (bar._ccxSig === sig) return;
1220
+ var now = Date.now();
1221
+ if (bar._ccxCloneAt !== undefined && now - bar._ccxCloneAt < 120) return;
1222
+ bar._ccxCloneAt = now;
1223
+ bar._ccxSig = sig;
1224
+ replaceClone(call, sanitizeClone(source.cloneNode(true)));
1225
+ return;
1226
+ }
1227
+ // Bash (and any future sample) uses a custom header without
1228
+ // data-disclosure-row (data-sample="bash"). Without this branch the
1229
+ // fallback below finds no [data-tool] and the bar's call slot stays
1230
+ // empty — exactly the "WSL bash shows only the count" symptom.
1231
+ var bashSource = run.lastRow.querySelector('[data-sample="bash"]');
1232
+ if (bashSource !== null) {
1233
+ var bsig = bashSource.innerHTML;
1234
+ if (bar._ccxSig === bsig) return;
1235
+ var bnow = Date.now();
1236
+ if (bar._ccxCloneAt !== undefined && bnow - bar._ccxCloneAt < 120) return;
1237
+ bar._ccxCloneAt = bnow;
1238
+ bar._ccxSig = bsig;
1239
+ replaceClone(call, sanitizeClone(bashSource.cloneNode(true)));
1240
+ return;
1241
+ }
1242
+ // Generic fallback for custom cards (e.g. cordis run) that render
1243
+ // outside the standard DisclosureRow/tool patterns.
1244
+ var card = run.lastRow.querySelector('[data-tool]');
1245
+ var head = card !== null && card.firstElementChild !== null ? card.firstElementChild : null;
1246
+ if (head !== null) {
1247
+ var hsig = head.innerHTML;
1248
+ if (bar._ccxSig !== hsig) {
1249
+ var now2 = Date.now();
1250
+ if (bar._ccxCloneAt === undefined || now2 - bar._ccxCloneAt >= 120) {
1251
+ bar._ccxCloneAt = now2;
1252
+ bar._ccxSig = hsig;
1253
+ replaceClone(call, sanitizeClone(head.cloneNode(true)));
1254
+ }
1255
+ }
1256
+ return;
1257
+ }
1258
+ // Last resort: whatever the row rendered first (covers future custom
1259
+ // tool views that use neither marker).
1260
+ var fallback = run.lastRow.firstElementChild;
1261
+ if (fallback !== null) {
1262
+ var fsig = fallback.innerHTML;
1263
+ if (bar._ccxSig !== fsig) {
1264
+ var fnow = Date.now();
1265
+ if (bar._ccxCloneAt === undefined || fnow - bar._ccxCloneAt >= 120) {
1266
+ bar._ccxCloneAt = fnow;
1267
+ bar._ccxSig = fsig;
1268
+ replaceClone(call, sanitizeClone(fallback.cloneNode(true)));
1269
+ }
1270
+ }
1271
+ return;
1272
+ }
1273
+ if (bar._ccxSig !== '') {
1274
+ bar._ccxSig = '';
1275
+ while (call.firstChild !== null) call.removeChild(call.firstChild);
1276
+ }
1277
+ }
1278
+
1279
+ /** Create/update/remove the flow-level bars for the merged runs. */
1280
+ function syncBars(flow, runs) {
1281
+ var registry = barsByFlow.get(flow);
1282
+ if (registry === undefined) {
1283
+ registry = new Map();
1284
+ barsByFlow.set(flow, registry);
1285
+ }
1286
+ var wanted = new Set();
1287
+ for (var r = 0; r < runs.length; r++) {
1288
+ var run = runs[r];
1289
+ if (run.headKey === null) continue;
1290
+ wanted.add(run.headKey);
1291
+ var bar = registry.get(run.headKey);
1292
+ if (bar === undefined || !flow.contains(bar)) {
1293
+ bar = createBar(flow, run);
1294
+ registry.set(run.headKey, bar);
1295
+ }
1296
+ updateBar(bar, run);
1297
+ // The bar is the group HEADER: it stays before the run's first row,
1298
+ // so on expand the cards cascade downward below it. Guard against
1299
+ // re-inserting when already in place (insertBefore always mutates).
1300
+ var anchor = run.toolRows[0];
1301
+ if (bar.nextSibling !== anchor) flow.insertBefore(bar, anchor);
1302
+ }
1303
+ registry.forEach(function (bar, key) {
1304
+ if (!wanted.has(key)) {
1305
+ bar.remove();
1306
+ registry.delete(key);
1307
+ }
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * Light pass for 'content'-dirty flows: the merge structure is
1313
+ * unchanged, so only bar clones can be stale. Touches ONLY the bars
1314
+ * of runs whose LAST row was mutated and which are collapsed — an
1315
+ * expanded run shows its own cards (no clone needed) and earlier
1316
+ * cards of a run are hidden (their content never reaches a bar).
1317
+ * No run rebuild, no class toggles, no layout.
1318
+ */
1319
+ function barRefresh(flow, contentRows) {
1320
+ var registry = barsByFlow.get(flow);
1321
+ if (registry === undefined || contentRows === undefined) return;
1322
+ registry.forEach(function (bar) {
1323
+ var run = bar._ccxRun;
1324
+ if (run === undefined || run.expanded || !bar.isConnected) return;
1325
+ if (contentRows.has(run.lastRow)) updateBarClone(bar);
1326
+ });
1327
+ }
1328
+
1329
+ // ----------------------------------------------------------------
1330
+ // The pass: merge runs, sync bars, clean empty rows.
1331
+ // ----------------------------------------------------------------
1332
+ function pass(flow) {
1333
+ var t0 = statsBegin();
1334
+ var rows = rowList(flow);
1335
+ var set = expanded.get(flow);
1336
+ var per = collapsing.get(flow);
1337
+ var keepThink = settings.getSnapshot().keepThink;
1338
+ var splitThink = settings.getSnapshot().splitThink;
1339
+
1340
+ // Prune expansion keys that no longer exist in this flow. Synthetic
1341
+ // keys are flow-local and never row-bound, so they are kept.
1342
+ if (set !== undefined && set.size > 0) {
1343
+ var stale = [];
1344
+ set.forEach(function (key) {
1345
+ if (key.indexOf('ccx-run-') === 0) return;
1346
+ var found = false;
1347
+ for (var i = 0; i < rows.length; i++) {
1348
+ if (rows[i].getAttribute('data-chat-flow-key') === key) { found = true; break; }
1349
+ }
1350
+ if (!found) stale.push(key);
1351
+ });
1352
+ for (var s = 0; s < stale.length; s++) set.delete(stale[s]);
1353
+ }
1354
+
1355
+ // Runs: maximal sequences of tool-call rows with only transparent
1356
+ // (settled-thinking-only) rows between them — in split mode (the
1357
+ // default) no row is transparent, so ANY other row (settled or
1358
+ // in-progress thinking, AI text, user/steering/command content)
1359
+ // ends a run and completed thinking separates the calls into
1360
+ // independent bars. sawTool/sawThink feed the light→full observer
1361
+ // upgrade: a flow with foldable content needs subtree + data-state
1362
+ // coverage.
1363
+ var runs = [];
1364
+ var current = [];
1365
+ var sawTool = false;
1366
+ var sawThink = false;
1367
+ for (var i = 0; i < rows.length; i++) {
1368
+ var row = rows[i];
1369
+ var kind = row.getAttribute('data-chat-flow-kind');
1370
+ if (kind === 'tool-call') {
1371
+ sawTool = true;
1372
+ current.push(row);
1373
+ } else if (kind === 'assistant-step') {
1374
+ var assessment = assessRow(row, keepThink, splitThink);
1375
+ if (assessment.thinks.length > 0) sawThink = true;
1376
+ if (current.length > 0 && assessment.transparent) {
1377
+ current.push(row);
1378
+ } else {
1379
+ if (current.length > 0) runs.push(current);
1380
+ current = [];
1381
+ }
1382
+ } else {
1383
+ if (current.length > 0) runs.push(current);
1384
+ current = [];
1385
+ }
1386
+ }
1387
+ if (current.length > 0) runs.push(current);
1388
+
1389
+ var mergedRows = new Set();
1390
+ var mergedRuns = [];
1391
+ for (var r = 0; r < runs.length; r++) {
1392
+ var run = runs[r];
1393
+ var toolRows = [];
1394
+ var thinkRows = [];
1395
+ for (var t = 0; t < run.length; t++) {
1396
+ if (run[t].getAttribute('data-chat-flow-kind') === 'tool-call') toolRows.push(run[t]);
1397
+ else thinkRows.push(run[t]);
1398
+ }
1399
+ if (toolRows.length < 2) continue;
1400
+ // The node key is the stable expansion identity; rows without one
1401
+ // (defensive: the product always sets it) get a flow-local
1402
+ // synthetic key so a run can never hide without a bar.
1403
+ var headKey = toolRows[0].getAttribute('data-chat-flow-key');
1404
+ if (headKey === null || headKey === '') {
1405
+ var synth = syntheticKeys.get(flow);
1406
+ if (synth === undefined) synth = 0;
1407
+ syntheticKeys.set(flow, synth + 1);
1408
+ headKey = 'ccx-run-' + synth;
1409
+ }
1410
+ var expandedRun = set !== undefined && headKey !== null && set.has(headKey);
1411
+ var collapsingRun = per !== undefined && headKey !== null && per.has(headKey);
1412
+ // Every card of the run hides when collapsed — the bar stands in
1413
+ // for the whole group and displays the last call itself. In merge
1414
+ // mode the run's settled-think rows fold with it too: hidden
1415
+ // while collapsed, shown in their original order between the
1416
+ // calls when expanded (they ride the same cascade). In split mode
1417
+ // think rows never enter a run (they separate runs), so with
1418
+ // "保留思考" on they stay visible between the bars. A run mid
1419
+ // collapse animation keeps its rows visible for the rise.
1420
+ var hide = !expandedRun && !collapsingRun;
1421
+ for (var k = 0; k < toolRows.length; k++) {
1422
+ toolRows[k].classList.toggle(MERGED_CLASS, hide);
1423
+ if (hide) toolRows[k].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
1424
+ mergedRows.add(toolRows[k]);
1425
+ }
1426
+ for (var w = 0; w < thinkRows.length; w++) {
1427
+ thinkRows[w].classList.toggle(MERGED_CLASS, hide);
1428
+ if (hide) thinkRows[w].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
1429
+ mergedRows.add(thinkRows[w]);
1430
+ }
1431
+ mergedRuns.push({
1432
+ headKey: headKey,
1433
+ toolRows: toolRows,
1434
+ thinkRows: thinkRows,
1435
+ // The run's rows in DOM order: cards and (merge mode) settled-
1436
+ // think rows interleaved exactly as they appear, used by the
1437
+ // waterfall.
1438
+ rows: run,
1439
+ lastRow: toolRows[toolRows.length - 1],
1440
+ expanded: expandedRun
1441
+ });
1442
+ }
1443
+
1444
+ // Remove stale merge/animation classes from rows that left a run.
1445
+ for (var m = 0; m < rows.length; m++) {
1446
+ if (!mergedRows.has(rows[m])) rows[m].classList.remove(MERGED_CLASS, FALLING_CLASS, COLLAPSING_CLASS);
1447
+ }
1448
+
1449
+ // Drop collapse-animation markers whose run no longer merges.
1450
+ if (per !== undefined && per.size > 0) {
1451
+ var keys = [];
1452
+ per.forEach(function (key) { keys.push(key); });
1453
+ for (var p = 0; p < keys.length; p++) {
1454
+ var still = false;
1455
+ for (var q = 0; q < mergedRuns.length; q++) {
1456
+ if (mergedRuns[q].headKey === keys[p]) { still = true; break; }
1457
+ }
1458
+ if (!still) per.delete(keys[p]);
1459
+ }
1460
+ if (per.size === 0) collapsing.delete(flow);
1461
+ }
1462
+
1463
+ syncBars(flow, mergedRuns);
1464
+
1465
+ // Hide assistant rows that became empty after settled-think folding
1466
+ // (the column's 16px flex gap would otherwise leave a hole). With
1467
+ // "保留思考" on nothing is hidden so nothing can be empty — remove
1468
+ // any stale marker instead. Decisions come from the cached row
1469
+ // assessment, so this loop performs no layout reads.
1470
+ for (var x = 0; x < rows.length; x++) {
1471
+ var candidate = rows[x];
1472
+ if (candidate.getAttribute('data-chat-flow-kind') !== 'assistant-step') continue;
1473
+ if (keepThink) {
1474
+ candidate.classList.remove(EMPTY_CLASS);
1475
+ continue;
1476
+ }
1477
+ var assessment = assessRow(candidate, keepThink, splitThink);
1478
+ if (assessment.empty) candidate.classList.add(EMPTY_CLASS);
1479
+ else candidate.classList.remove(EMPTY_CLASS);
1480
+ }
1481
+ statsEnd('pass', 1, t0);
1482
+ return { sawTool: sawTool, sawThink: sawThink };
1483
+ }
1484
+
1485
+ /** Drop observers/registries for flows no longer in the document. */
1486
+ function pruneFlows() {
1487
+ var flowsNow = [];
1488
+ flows.forEach(function (mode, flow) { flowsNow.push(flow); });
1489
+ for (var i = 0; i < flowsNow.length; i++) {
1490
+ var flow = flowsNow[i];
1491
+ if (doc.contains(flow)) continue;
1492
+ var observer = flowObservers.get(flow);
1493
+ if (observer !== null && observer !== undefined) observer.disconnect();
1494
+ flowObservers.delete(flow);
1495
+ flows.delete(flow);
1496
+ expanded.delete(flow);
1497
+ barsByFlow.delete(flow);
1498
+ collapsing.delete(flow);
1499
+ dirtyFlows.delete(flow);
1500
+ flowReasons.delete(flow);
1501
+ contentRowsByFlow.delete(flow);
1502
+ syntheticKeys.delete(flow);
1503
+ }
1504
+ // Parent observers: drop those whose parent is gone or hosts no flow.
1505
+ parentObservers.forEach(function (observer, parent) {
1506
+ if (!doc.contains(parent)) {
1507
+ observer.disconnect();
1508
+ parentObservers.delete(parent);
1509
+ return;
1510
+ }
1511
+ var used = false;
1512
+ flows.forEach(function (mode, flow) {
1513
+ if (flow.parentElement === parent) used = true;
1514
+ });
1515
+ if (!used) {
1516
+ observer.disconnect();
1517
+ parentObservers.delete(parent);
1518
+ }
1519
+ });
1520
+ }
1521
+
1522
+ function refresh() {
1523
+ if (disposed || !active) return;
1524
+ var t0 = statsBegin();
1525
+ if (structureDirty) {
1526
+ structureDirty = false;
1527
+ discover();
1528
+ pruneFlows();
1529
+ observeParents();
1530
+ }
1531
+ var keep = settings.getSnapshot().keepThink;
1532
+ var pending = [];
1533
+ dirtyFlows.forEach(function (flow) { pending.push(flow); });
1534
+ dirtyFlows.clear();
1535
+ for (var i = 0; i < pending.length; i++) {
1536
+ var flow = pending[i];
1537
+ if (!flows.has(flow) || !doc.contains(flow)) continue;
1538
+ flow.classList.toggle(KEEP_CLASS, keep);
1539
+ var reason = flowReasons.get(flow);
1540
+ flowReasons.delete(flow);
1541
+ var contentRows = contentRowsByFlow.get(flow);
1542
+ if (contentRows !== undefined) contentRowsByFlow.delete(flow);
1543
+ if (reason === 'content') {
1544
+ if (contentRows !== undefined) barRefresh(flow, contentRows);
1545
+ } else {
1546
+ var outcome = pass(flow);
1547
+ // Light→full upgrade: foldable content appeared (or the flow
1548
+ // already had it when the rescan discovered it in light mode).
1549
+ // Never downgrades.
1550
+ if (flows.get(flow) === 'light' && (outcome.sawTool || outcome.sawThink)) {
1551
+ setFlowMode(flow, 'full');
1552
+ }
1553
+ }
1554
+ }
1555
+ statsEnd('refresh', 1, t0);
1556
+ }
1557
+
1558
+ // ----------------------------------------------------------------
1559
+ // Public lifecycle.
1560
+ // ----------------------------------------------------------------
1561
+ function dispose() {
1562
+ if (disposed) return;
1563
+ disposed = true;
1564
+ stopRescan();
1565
+ if (typeof doc.removeEventListener === 'function') {
1566
+ doc.removeEventListener('visibilitychange', onVisibility);
1567
+ }
1568
+ if (offSettings !== null) offSettings();
1569
+ if (rafId !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(rafId);
1570
+ if (bootRaf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(bootRaf);
1571
+ disconnectObservers();
1572
+ barsByFlow.forEach(function (registry) {
1573
+ registry.forEach(function (bar) {
1574
+ if (bar._ccxFallTimer !== undefined) timers.cancel(bar._ccxFallTimer);
1575
+ if (bar._ccxCollapseTimer !== undefined) timers.cancel(bar._ccxCollapseTimer);
1576
+ });
1577
+ });
1578
+ flows.clear();
1579
+ flowObservers.clear();
1580
+ parentObservers.clear();
1581
+ expanded.clear();
1582
+ barsByFlow.clear();
1583
+ collapsing.clear();
1584
+ dirtyFlows.clear();
1585
+ flowReasons.clear();
1586
+ contentRowsByFlow.clear();
1587
+ syntheticKeys.clear();
1588
+ // Remove injected UI and classes so the flow returns to normal.
1589
+ var flowsNow = doc.querySelectorAll(FLOW_SELECTOR);
1590
+ for (var f = 0; f < flowsNow.length; f++) {
1591
+ flowsNow[f].classList.remove(KEEP_CLASS);
1592
+ var bars = flowsNow[f].querySelectorAll('[' + BADGE_ATTR + ']');
1593
+ for (var b = 0; b < bars.length; b++) bars[b].remove();
1594
+ var tagged = flowsNow[f].querySelectorAll('.' + MERGED_CLASS + ',.' + EMPTY_CLASS + ',.' + FALLING_CLASS + ',.' + COLLAPSING_CLASS);
1595
+ for (var t = 0; t < tagged.length; t++) tagged[t].classList.remove(MERGED_CLASS, EMPTY_CLASS, FALLING_CLASS, COLLAPSING_CLASS);
1596
+ }
1597
+ // Clear any shrink-animation inline styles left on tool rows.
1598
+ var allToolRows = doc.querySelectorAll('[data-chat-flow-kind="tool-call"]');
1599
+ for (var tr = 0; tr < allToolRows.length; tr++) clearRowShrink(allToolRows[tr]);
1600
+ if (styleTag !== null && !adoptedStyle) styleTag.remove();
1601
+ styleTag = null;
1602
+ doc.documentElement.style.removeProperty('--ccx-dur');
1603
+ doc.documentElement.style.removeProperty('--ccx-step');
1604
+ }
1605
+
1606
+ // Boot work (style tag, flow discovery, first passes) is deferred one
1607
+ // frame so plugin activation never blocks the page's first paint. If
1608
+ // the tab is hidden at boot the rAF never fires: the visibility
1609
+ // listener still arms, and becoming visible runs bootInit directly.
1610
+ function bootInit() {
1611
+ if (booted || disposed) return;
1612
+ booted = true;
1613
+ applySettings();
1614
+ settings.stats = ensureStats();
1615
+ insertStyle();
1616
+ discover();
1617
+ observeParents();
1618
+ startRescan();
1619
+ structureDirty = true;
1620
+ schedule();
1621
+ }
1622
+ if (typeof doc.addEventListener === 'function') {
1623
+ doc.addEventListener('visibilitychange', onVisibility);
1624
+ }
1625
+ if (doc.hidden) setActive(false);
1626
+ offSettings = settings.subscribe(function () {
1627
+ applySettings();
1628
+ if (statsObj !== null) {
1629
+ var statsOn = settings.getSnapshot().stats === true;
1630
+ if (statsOn && !statsObj.enabled) {
1631
+ statsObj.obs = 0; statsObj.obsMs = 0;
1632
+ statsObj.refresh = 0; statsObj.refreshMs = 0;
1633
+ statsObj.pass = 0; statsObj.passMs = 0;
1634
+ statsObj.scan = 0; statsObj.scanMs = 0;
1635
+ statsObj.clone = 0; statsObj.cloneMs = 0;
1636
+ statsObj.skip = 0;
1637
+ statsObj.start = Date.now();
1638
+ }
1639
+ statsObj.enabled = statsOn;
1640
+ }
1641
+ flows.forEach(function (mode, flow) { markDirty(flow, 'rows'); });
1642
+ schedule();
1643
+ });
1644
+ if (typeof requestAnimationFrame === 'function') {
1645
+ bootRaf = requestAnimationFrame(bootInit);
1646
+ } else {
1647
+ bootInit();
1648
+ }
1649
+ return dispose;
1650
+ }
1651
+
1652
+ // ------------------------------------------------------------------
1653
+ // Settings card: Settings → 插件 → 工具折叠. Rendered with the same
1654
+ // chrome as the product's own plugin cards (the .ccxCard rules in the
1655
+ // style sheet above), so it is visually indistinguishable from them.
1656
+ // ------------------------------------------------------------------
1657
+ /**
1658
+ * One plugin card under Settings → 插件, owning the folding preferences.
1659
+ * @param props - injected face: useCcxSettings snapshot hook, setDur,
1660
+ * setKeepThink; every change applies live through the shared store.
1661
+ * @returns the card.
1662
+ */
1663
+ function SettingsCard(props) {
1664
+ var state = props.useCcxSettings(function (snapshot) { return snapshot; });
1665
+ var openCell = React.useState(false);
1666
+ var isOpen = openCell[0];
1667
+ var setOpen = openCell[1];
1668
+ var nowCell = React.useState(0);
1669
+ var setNow = nowCell[1];
1670
+ // While the stats toggle is on, re-render once per second so the
1671
+ // cumulative cost figures stay live (ticker rides the timer service).
1672
+ React.useEffect(function () {
1673
+ if (!state.stats) return;
1674
+ var handle = props.tick(function () { setNow(Date.now()); }, 1000);
1675
+ return function () { props.cancelTick(handle); };
1676
+ }, [state.stats]);
1677
+ var cardClass = isOpen ? 'ccxCard ccxCardOpen' : 'ccxCard';
1678
+ var header = React.createElement('button', {
1679
+ type: 'button',
1680
+ className: 'ccxHeader',
1681
+ 'aria-expanded': isOpen,
1682
+ 'aria-label': (isOpen ? '收起' : '展开') + ': 工具折叠',
1683
+ onClick: function () { setOpen(!isOpen); }
1684
+ },
1685
+ React.createElement('span', { className: 'ccxHeadText' },
1686
+ React.createElement('span', { className: 'ccxName' }, '工具折叠'),
1687
+ React.createElement('span', { className: 'ccxDescription' }, '折叠工具调用与思考的显示设置')),
1688
+ React.createElement('span', { className: isOpen ? 'ccxChevron ccxChevronOpen' : 'ccxChevron' }, '▾'));
1689
+ if (!isOpen) return React.createElement('li', { className: cardClass }, header);
1690
+ var durField = React.createElement('div', { className: 'ccxField' },
1691
+ React.createElement('div', { className: 'ccxFieldHead' },
1692
+ React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-dur' }, '展开动画时长'),
1693
+ React.createElement('span', { className: 'ccxBadge' }, state.durMs + ' ms')),
1694
+ React.createElement('input', {
1695
+ id: 'ccx-dur', type: 'range', className: 'ccxRange', min: 0, max: 1000, step: 10,
1696
+ value: state.durMs,
1697
+ onChange: function (event) { props.setDur(Number(event.target.value)); }
1698
+ }),
1699
+ React.createElement('p', { className: 'ccxFieldHint' }, '折叠与展开的弹性动画时长,0 为瞬时切换'));
1700
+ var thinkField = React.createElement('div', { className: 'ccxField' },
1701
+ React.createElement('div', { className: 'ccxFieldHead' },
1702
+ React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-keep-think' }, '保留思考'),
1703
+ React.createElement('input', {
1704
+ id: 'ccx-keep-think', type: 'checkbox', className: 'ccxToggle', checked: state.keepThink,
1705
+ onChange: function (event) { props.setKeepThink(event.target.checked); }
1706
+ })),
1707
+ React.createElement('p', { className: 'ccxFieldHint' }, '保留已完成的思考内容(分隔模式下显示在两条折叠条之间,合并模式下展开时按原顺序插回)'));
1708
+ var splitField = React.createElement('div', { className: 'ccxField' },
1709
+ React.createElement('div', { className: 'ccxFieldHead' },
1710
+ React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-split-think' }, '思考分隔调用组'),
1711
+ React.createElement('input', {
1712
+ id: 'ccx-split-think', type: 'checkbox', className: 'ccxToggle', checked: state.splitThink,
1713
+ onChange: function (event) { props.setSplitThink(event.target.checked); }
1714
+ })),
1715
+ React.createElement('p', { className: 'ccxFieldHint' }, '开启(默认):已完成的思考把前后两组工具调用隔开、各自独立折叠;关闭:思考并入所在工具组一起折叠'));
1716
+ var statsField = React.createElement('div', { className: 'ccxField' },
1717
+ React.createElement('div', { className: 'ccxFieldHead' },
1718
+ React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-stats' }, '性能统计'),
1719
+ React.createElement('input', {
1720
+ id: 'ccx-stats', type: 'checkbox', className: 'ccxToggle', checked: state.stats,
1721
+ onChange: function (event) { props.setStats(event.target.checked); }
1722
+ })),
1723
+ React.createElement('p', { className: 'ccxFieldHint' }, '统计插件自身耗时并实时显示在本卡片内(默认关;开启才产生极小的计时开销)'));
1724
+ var storageHint = props.bridgeStatus === undefined || props.bridgeStatus() === 'dsh'
1725
+ ? '设置保存在 DSH 主机配置(~/.dsh/settings.yaml),由 DSH 设置服务持久化'
1726
+ : '未检测到 DSH 设置服务,设置仅保存在本浏览器(localStorage)';
1727
+ var body = React.createElement('div', { className: 'ccxBody' },
1728
+ durField, thinkField, splitField, statsField,
1729
+ React.createElement('p', { className: 'ccxFieldHint' }, storageHint));
1730
+ var children = [header, body];
1731
+ if (state.stats) {
1732
+ var engineStats = props.stats();
1733
+ if (engineStats !== undefined) {
1734
+ var elapsed = Math.max(1, (Date.now() - engineStats.start) / 1000);
1735
+ var rows = [
1736
+ ['观察回调', 'obs', engineStats.obs, engineStats.obsMs],
1737
+ ['引擎刷新', 'refresh', engineStats.refresh, engineStats.refreshMs],
1738
+ ['合并重算', 'pass', engineStats.pass, engineStats.passMs],
1739
+ ['安全重扫', 'scan', engineStats.scan, engineStats.scanMs],
1740
+ ['摘要克隆', 'clone', engineStats.clone, engineStats.cloneMs]
1741
+ ];
1742
+ var statRows = rows.map(function (row) {
1743
+ return React.createElement('div', { className: 'ccxStatRow', key: row[1] },
1744
+ React.createElement('span', { className: 'ccxFieldLabel' }, row[0]),
1745
+ React.createElement('span', { className: 'ccxStatValue' },
1746
+ row[2] + ' 次 · ' + row[3].toFixed(1) + ' ms(' + (row[3] / elapsed).toFixed(2) + ' ms/s)'));
1747
+ });
1748
+ children.push(React.createElement('div', { className: 'ccxBody' },
1749
+ statRows,
1750
+ React.createElement('p', { className: 'ccxFieldHint' }, '流式变更直接忽略(零开销短路):' + engineStats.skip + ' 批次'),
1751
+ React.createElement('p', { className: 'ccxFieldHint' }, '累计自开启统计起算;标签页隐藏时引擎全部暂停,不计入')));
1752
+ }
1753
+ }
1754
+ return React.createElement('li', { className: cardClass }, children);
1755
+ }
1756
+
1757
+ var plugin = {
1758
+ name: 'toolfold',
1759
+ // Declared so the fiber waits for the slots service and `ctx.slots` is
1760
+ // available in apply — the documented dynamic-package pattern. `timer`
1761
+ // is the Cordis timer service: browser timer globals (setTimeout /
1762
+ // setInterval / clearTimeout / clearInterval) are shadowed by teaching
1763
+ // traps in the dynamic client environment, so every engine timer goes
1764
+ // through ctx.timeout / ctx.interval disposers.
1765
+ inject: ['slots', 'timer'],
1766
+ apply: function (ctx) {
1767
+ if (typeof document === 'undefined') return;
1768
+ var settings = createSettingsStore();
1769
+ // Diagnostic/test hook: the settings store (and its live stats
1770
+ // counters) is reachable as window.__toolfoldSettings.
1771
+ if (typeof window !== 'undefined') window.__toolfoldSettings = settings;
1772
+ // DSH settings bridge: official settingsScope → host route →
1773
+ // localStorage (see the createSettingsBridge doc above).
1774
+ var bridge = createSettingsBridge(ctx, settings);
1775
+ if (typeof window !== 'undefined') window.__toolfoldBridge = bridge;
1776
+ var timers = {
1777
+ after: function (callback, delay) { return ctx.timeout(callback, delay); },
1778
+ every: function (callback, delay) { return ctx.interval(callback, delay); },
1779
+ cancel: function (handle) {
1780
+ if (handle !== undefined && handle !== null) handle();
1781
+ }
1782
+ };
1783
+ ctx.effect(function () {
1784
+ var offEngine = installCollapseEngine(document, settings, timers);
1785
+ return function () {
1786
+ offEngine();
1787
+ bridge.dispose();
1788
+ };
1789
+ });
1790
+ // Probe the host-backed tiers once; the official scope subscribe
1791
+ // above and this call both converge on the same adoptSection path.
1792
+ void bridge.load();
1793
+ if (React === null) return;
1794
+ var slots = ctx.slots;
1795
+ if (slots === undefined) return;
1796
+ try {
1797
+ // Generator form, exactly like the product's own entries; slots.inject
1798
+ // waits for the declaration and re-runs on redeclaration, so the card
1799
+ // appears whenever Settings → 插件 is open while this plugin runs.
1800
+ slots.inject('settings.plugin.item', function* () {
1801
+ yield slots.register(
1802
+ {
1803
+ name: 'settings.plugin.item',
1804
+ key: 'toolfold',
1805
+ id: 'toolfold',
1806
+ order: 30,
1807
+ label: '工具折叠',
1808
+ inject: function () {
1809
+ return {
1810
+ hooks: { ccxSettings: settings },
1811
+ setDur: function (ms) { bridge.write('durMs', ms); },
1812
+ setKeepThink: function (on) { bridge.write('keepThink', on); },
1813
+ setSplitThink: function (on) { bridge.write('splitThink', on); },
1814
+ setStats: function (on) { bridge.write('stats', on); },
1815
+ stats: function () { return settings.stats; },
1816
+ bridgeStatus: function () { return bridge.status(); },
1817
+ tick: function (cb, ms) { return ctx.interval(cb, ms); },
1818
+ cancelTick: function (handle) {
1819
+ if (handle !== undefined && handle !== null) handle();
1820
+ }
1821
+ };
1822
+ }
1823
+ },
1824
+ SettingsCard
1825
+ );
1826
+ });
1827
+ } catch (err) {
1828
+ // console.error mirrors into the load report, so a registration
1829
+ // failure is visible instead of silently vanishing.
1830
+ console.error('toolfold settings card failed to register:', err);
1831
+ }
1832
+ }
1833
+ };
1834
+
1835
+ module.exports = plugin;
1836
+ return module.exports;
1837
+ }
1838
+ });