File size: 29,523 Bytes
ad51766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
/* eslint-disable */
/**
 * Hy3 custom chat renderer.
 *
 * The DOM under ``#hy-chat`` is owned exclusively by this module —
 * nothing in Gradio touches it. The server pushes JSON deltas via the
 * hidden ``#hy-chat-delta`` ``gr.HTML`` component; we observe its
 * mutations, parse, and apply each op to the chat container.
 *
 * Design notes
 * ────────────
 *   1. Append-only DOM. Each new bubble is its own ``.message-row``.
 *      The browser's per-row layout/paint isolation (``contain`` +
 *      ``content-visibility`` from ``_perf.css``) keeps rendering cost
 *      O(visible) regardless of conversation length.
 *
 *   2. Frozen / streaming split inside each assistant bubble. As content
 *      streams in, every paragraph (or fenced code / display math) that
 *      has CLOSED gets rendered ONCE into a stable ``.hy-frozen`` DOM
 *      subtree and never touched again — KaTeX, ``marked``, and the
 *      syntax highlighter all run exactly once per closed block. Only
 *      the trailing in-progress paragraph re-renders on each delta,
 *      and that block is small. As a result already-rendered formulas
 *      never flicker / re-layout mid-stream.
 *
 *   3. Wire payload is ~just the new characters. A 100-char delta is
 *      ~150 bytes on the wire, so WebSocket back-pressure essentially
 *      goes away.
 *
 * Op contract — keep in sync with chat.py:
 *
 *   {type: "reset"}
 *   {type: "user", text: "..."}
 *   {type: "assistant_begin", id: "a-N"}
 *   {type: "reasoning_delta", id: "a-N", delta: "..."}
 *   {type: "content_delta",   id: "a-N", delta: "...", thinking_done?: bool}
 *   {type: "assistant_end",   id: "a-N"}
 *   {type: "tool_call",       id: "a-N", markdown: "..."}
 *   {type: "remove_bubble",   id: "a-N"}
 */
(() => {
  if (window.__HY_CHAT_INIT) return;
  window.__HY_CHAT_INIT = true;

  // ─── State ─────────────────────────────────────────────────────────────
  const state = {
    host: null,            // <div id="hy-chat">
    bubbles: new Map(),    // bubble_id -> bubble record (see ensureBubble)
    lastSeq: 0,            // de-dup gate against re-fires of the same payload
    // Current chat epoch — bumped server-side every time the user clicks
    // "+" / new_chat. Each delta payload carries the epoch it was
    // produced under; deltas whose epoch doesn't match ours are dropped
    // (they're tail bytes from a stream the user already abandoned).
    // Initialised lazily from the first payload that arrives so legacy
    // payloads without an epoch field still apply.
    currentEpoch: null,
  };

  // ─── DOM helpers ───────────────────────────────────────────────────────
  function ensureHost() {
    if (state.host && document.contains(state.host)) return state.host;
    state.host = document.getElementById("hy-chat");
    return state.host;
  }

  function escapeHtml(s) {
    return String(s)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");
  }

  // ─── Markdown + math renderer ──────────────────────────────────────────
  // We DELIBERATELY do not sanitize here. The rest of the app depends
  // on the model being able to emit raw HTML (e.g.
  // <details class="thinking-block">, the HTML-preview workflow on raw
  // HTML code blocks). The model is trusted output for this product
  // surface; if that ever changes, swap marked for a sanitizing parser
  // like markdown-it + DOMPurify.
  const MARKED_OPTS = { gfm: true, breaks: false };

  const KATEX_DELIMS = [
    { left: "$$", right: "$$", display: true },
    { left: "$", right: "$", display: false },
    // We pre-convert \[…\] / \(…\) → $$…$$ / $…$ via normalizeMathDelims
    // BEFORE marked.parse runs (see below) — so the auto-render scan
    // never needs to worry about them. They are NOT listed here on
    // purpose: marked treats ``\[`` and ``\(`` as backslash escapes
    // and silently drops the leading ``\`` in the rendered HTML, so by
    // the time KaTeX scans the DOM the original bracket delimiters are
    // already gone. Pre-conversion is the only reliable fix.
  ];

  // ─── Math handling: two-stage pipeline ────────────────────────────────
  //
  // The model emits LaTeX in any of four delimiter styles: ``$$…$$``,
  // ``\[…\]``, ``$…$``, ``\(…\)``. We need KaTeX (via auto-render) to
  // pick all of them up while still letting ``marked.parse`` handle the
  // surrounding markdown.
  //
  // Two problems block the naïve approach:
  //
  //   1. ``marked`` treats ``\[``, ``\]``, ``\(``, ``\)`` as backslash
  //      escapes and silently drops the ``\``. By the time KaTeX scans
  //      the rendered HTML the bracket delimiters are gone.
  //
  //   2. Even inside ``$$…$$``, ``marked`` strips backslashes before
  //      ``\{``, ``\}``, ``\_``, etc. — so things like ``\{(0:0:1:0)\}``
  //      lose their literal-brace escapes and KaTeX renders an empty
  //      group instead of the intended literal braces.
  //
  // The fix is two passes that together keep marked away from the math
  // content:
  //
  //   Stage A — ``normalizeMathDelims`` converts ``\[/\]`` → ``$$`` and
  //   ``\(/\)`` → ``$`` OUTSIDE fenced/inline code blocks. After this
  //   pass every math region is delimited with dollars.
  //
  //   Stage B — ``protectMath`` extracts every ``$$…$$`` / ``$…$`` from
  //   the text and replaces it with a unique placeholder token (a NUL
  //   character that ``marked`` will leave alone). After ``marked.parse``
  //   we substitute the original math source back in. ``ignoredTags``
  //   already keeps KaTeX out of ``<pre>``/``<code>``, so the only
  //   regions that end up containing dollars in the rendered HTML are
  //   the math ones — exactly what we want.
  //
  // The placeholder uses NUL as a delimiter because ``marked`` cannot
  // produce a literal NUL on its own (every input source we'd see is
  // valid Unicode text without NULs), so the ``restoreMath`` regex is
  // unambiguous.
  function normalizeMathDelims(text) {
    if (!text) return text;
    let out = "";
    let i = 0;
    const n = text.length;
    while (i < n) {
      const ch = text[i];

      // Triple-backtick or triple-tilde fenced block — passthrough until
      // matching closing fence on its own (or anywhere on a) line.
      if ((ch === "`" && text.startsWith("```", i)) ||
          (ch === "~" && text.startsWith("~~~", i))) {
        const marker = text.substr(i, 3);
        out += marker;
        i += 3;
        const close = text.indexOf(marker, i);
        if (close < 0) {
          // Unclosed fence — emit rest verbatim and stop.
          out += text.slice(i);
          return out;
        }
        out += text.slice(i, close + 3);
        i = close + 3;
        continue;
      }

      // Inline code: single or double backticks, ended by the same run
      // length. Common case is a single backtick.
      if (ch === "`") {
        let runLen = 1;
        while (i + runLen < n && text[i + runLen] === "`") runLen++;
        const open = text.substr(i, runLen);
        out += open;
        i += runLen;
        const close = text.indexOf(open, i);
        if (close < 0) {
          out += text.slice(i);
          return out;
        }
        out += text.slice(i, close + runLen);
        i = close + runLen;
        continue;
      }

      // Backslash-bracket / backslash-paren delimiters. Must be the
      // FIRST conversion check after the code-fence skip so we don't
      // mistake e.g. ``\[`` inside a code fence for math.
      if (ch === "\\") {
        if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
        if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
        if (text[i + 1] === "(") { out += "$";  i += 2; continue; }
        if (text[i + 1] === ")") { out += "$";  i += 2; continue; }
      }

      out += ch;
      i++;
    }
    return out;
  }

  // Inline math regex requires the opening ``$`` to be IMMEDIATELY
  // followed by a non-space, non-``$`` char (avoids matching prices like
  // ``I have $5 and $10``) and the closing ``$`` to be IMMEDIATELY
  // preceded by a non-space, non-``$`` char. Display math (``$$…$$``)
  // is matched first (the alternative is left-most-greedy in a JS
  // regex, but with non-greedy bodies and ``$$`` being a longer prefix
  // it wins ties naturally).
  const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;

  function protectMath(text) {
    const stash = [];
    const escaped = text.replace(MATH_RE, (m) => {
      const idx = stash.length;
      stash.push(m);
      return `\u0000M${idx}\u0000`;
    });
    return { escaped, stash };
  }

  function restoreMath(html, stash) {
    if (!stash.length) return html;
    return html.replace(/\u0000M(\d+)\u0000/g, (_, n) => stash[+n]);
  }

  function renderMarkdownInto(el, text) {
    if (!text) {
      el.innerHTML = "";
      return;
    }
    const normalized = normalizeMathDelims(text);
    const { escaped, stash } = protectMath(normalized);
    const rawMd = (window.marked && window.marked.parse)
      ? window.marked.parse(escaped, MARKED_OPTS)
      : escapeHtml(escaped);
    el.innerHTML = restoreMath(rawMd, stash);

    // Math
    if (window.renderMathInElement) {
      try {
        window.renderMathInElement(el, {
          delimiters: KATEX_DELIMS,
          throwOnError: false,
          // Skip math-detection inside elements that should never contain
          // math (matches KaTeX auto-render defaults).
          ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
        });
      } catch (err) {
        console.warn("[hy-chat] katex render error:", err);
      }
    }

    // Syntax highlighting (idempotent if hljs not loaded yet)
    if (window.hljs && window.hljs.highlightElement) {
      const codes = el.querySelectorAll("pre code");
      for (let i = 0; i < codes.length; i++) {
        const c = codes[i];
        if (c.dataset.hljsDone) continue;
        try {
          window.hljs.highlightElement(c);
        } catch (err) {
          // hljs throws if the language is unknown — harmless, ignore.
        }
        c.dataset.hljsDone = "1";
      }
    }
  }

  // ─── Frozen / streaming split ──────────────────────────────────────────
  // Find the highest "safe" boundary in the current content buffer up to
  // which we can freeze the rendered DOM and never touch it again. A
  // boundary is safe iff the markdown above it forms a complete set of
  // top-level blocks — i.e. we are NOT inside an open fenced code block
  // (``` / ~~~), display math ($$), or LaTeX env (\begin{…}).
  //
  // Returns the index in ``text`` immediately after the last safe block
  // separator (\n\n).
  function findFreezeBoundary(text) {
    const len = text.length;
    let i = 0;
    let inFence = false;       // inside ``` … ``` or ~~~ … ~~~
    let fenceMarker = null;
    let inMath = false;        // inside $$ … $$
    let inEnv = false;         // inside \begin{…} … \end{…}
    let lastSafe = 0;

    while (i < len) {
      const ch = text[i];

      // Fence detection (must check before single-char paths)
      if (!inMath && !inEnv) {
        if (ch === "`" && text.startsWith("```", i)) {
          if (!inFence) { inFence = true; fenceMarker = "```"; }
          else if (fenceMarker === "```") { inFence = false; fenceMarker = null; }
          i += 3;
          continue;
        }
        if (ch === "~" && text.startsWith("~~~", i)) {
          if (!inFence) { inFence = true; fenceMarker = "~~~"; }
          else if (fenceMarker === "~~~") { inFence = false; fenceMarker = null; }
          i += 3;
          continue;
        }
      }

      if (inFence) { i++; continue; }

      // Display math $$ … $$
      if (!inEnv && ch === "$" && text[i + 1] === "$") {
        inMath = !inMath;
        i += 2;
        continue;
      }

      if (inMath) { i++; continue; }

      // \begin{env} … \end{env}
      if (ch === "\\") {
        if (text.startsWith("\\begin{", i)) { inEnv = true; i += 7; continue; }
        if (text.startsWith("\\end{", i))   { inEnv = false; i += 5; continue; }
      }

      if (inEnv) { i++; continue; }

      // Paragraph break outside any block — candidate freeze point.
      if (ch === "\n" && text[i + 1] === "\n") {
        // Skip past the entire run of blank lines so we don't freeze
        // mid-blank-paragraph.
        let j = i + 2;
        while (j < len && (text[j] === "\n" || text[j] === " " || text[j] === "\t")) j++;
        lastSafe = j;
        i = j;
        continue;
      }

      i++;
    }

    return lastSafe;
  }

  // ─── Bubble lifecycle ──────────────────────────────────────────────────
  function ensureBubble(id) {
    return state.bubbles.get(id);
  }

  // Re-fire ``hy-chat:updated`` so the scroll-lock in app.js reconsiders
  // whether to re-pin to the bottom now that geometry may have changed.
  // Coalesced via rAF so a flurry of layout changes (e.g. KaTeX +
  // hljs + table column-width pass all in the same frame) only
  // dispatches once.
  let _nudgePending = false;
  function nudgeScrollSoon() {
    if (_nudgePending) return;
    _nudgePending = true;
    requestAnimationFrame(() => {
      _nudgePending = false;
      if (!state.host) return;
      state.host.dispatchEvent(new CustomEvent("hy-chat:updated", {
        bubbles: false,
      }));
    });
  }

  // Forces ``row`` to flush any pending layout, then schedules a
  // single rAF-coalesced ``hy-chat:updated`` dispatch. Used both
  // when a ``<details>`` toggles and as the ``ResizeObserver``
  // callback for each row.
  function nudgeScrollAfterToggle(b) {
    if (!b || !b.row) return;
    void b.row.offsetHeight;
    nudgeScrollSoon();
  }

  // Per-row ResizeObserver. Many things that change a row's height
  // happen ASYNCHRONOUSLY after the streaming op that created the
  // content: KaTeX glyphs render once their fonts arrive, hljs
  // decorates code blocks on the next idle tick, browsers do a
  // second column-width pass on tables once all rows are in. None
  // of these go through ``applyDelta`` so the scroll-lock would
  // otherwise miss them, leaving the user stuck at a now-stale
  // ``scrollHeight - clientHeight``. Wiring a ResizeObserver per
  // row covers all of them with one mechanism.
  const _rowResizeObserver =
    typeof ResizeObserver !== "undefined"
      ? new ResizeObserver(() => nudgeScrollSoon())
      : null;

  function createAssistantBubble(id) {
    const row = document.createElement("div");
    row.className = "message-row bot-row";
    row.dataset.testid = "bot";
    row.dataset.role = "assistant";
    row.dataset.bubbleId = id;

    const msg = document.createElement("div");
    msg.className = "message bot";

    const md = document.createElement("div");
    md.className = "markdown hy-markdown";

    const typing = document.createElement("div");
    typing.className = "typing-indicator hy-typing";
    typing.innerHTML = "<span></span><span></span><span></span>";
    md.appendChild(typing);

    msg.appendChild(md);
    row.appendChild(msg);
    state.host.appendChild(row);
    if (_rowResizeObserver) _rowResizeObserver.observe(row);

    const record = {
      row, md, typing,
      // Reasoning (optional, lazily created)
      reasoningEl: null,
      reasoningSummaryEl: null,
      reasoningBodyEl: null,
      reasoningBuf: "",
      thinkingDone: false,
      // Content (lazily created on first content_delta)
      frozenEl: null,
      streamingEl: null,
      contentBuf: "",
      frozenLen: 0,
      // Tool-call append region (lazily created)
      toolCallContainer: null,
      ended: false,
    };
    state.bubbles.set(id, record);
    return record;
  }

  function ensureReasoning(b) {
    if (b.reasoningEl) return;
    const det = document.createElement("details");
    det.className = "thinking-block";
    det.open = true;
    const sum = document.createElement("summary");
    sum.textContent = (window.HY_I18N && window.HY_I18N.thinking) || "Thinking...";
    det.appendChild(sum);
    const body = document.createElement("div");
    body.className = "hy-reasoning-body";
    body.style.whiteSpace = "pre-wrap";
    body.style.wordBreak = "break-word";
    det.appendChild(body);
    b.reasoningEl = det;
    b.reasoningSummaryEl = sum;
    b.reasoningBodyEl = body;
    if (b.typing && b.typing.parentNode) b.typing.remove();
    // Reasoning always goes ABOVE content, even if content has already
    // started rendering (rare — most models stream reasoning first).
    b.md.insertBefore(det, b.md.firstChild);
    // Keep the parent chat's scroll geometry in sync whenever the user
    // (or markThinkingDone) toggles this block. See nudgeScrollAfterToggle.
    det.addEventListener("toggle", () => nudgeScrollAfterToggle(b));
  }

  function ensureContent(b) {
    if (b.frozenEl) return;
    if (b.typing && b.typing.parentNode) b.typing.remove();
    const frozen = document.createElement("div");
    frozen.className = "hy-frozen";
    const streaming = document.createElement("div");
    streaming.className = "hy-streaming";
    b.md.appendChild(frozen);
    b.md.appendChild(streaming);
    b.frozenEl = frozen;
    b.streamingEl = streaming;
  }

  function markThinkingDone(b) {
    if (!b.reasoningEl || b.thinkingDone) return;
    b.thinkingDone = true;
    b.reasoningSummaryEl.textContent =
      (window.HY_I18N && window.HY_I18N.thinking_done) || "Thought";
    b.reasoningEl.open = false;
  }

  // ─── Op handlers ───────────────────────────────────────────────────────
  function opReset() {
    if (_rowResizeObserver) {
      for (const b of state.bubbles.values()) {
        if (b.row) _rowResizeObserver.unobserve(b.row);
      }
    }
    state.host.innerHTML = "";
    state.bubbles.clear();
    state.lastSeq = 0;
    // currentEpoch is updated by applyDelta BEFORE op dispatch when a
    // reset op is in the payload — see the epoch handling there. We
    // intentionally do NOT clear it here, otherwise we'd reopen the
    // window for stale post-reset deltas.
  }

  function opUser(op) {
    const row = document.createElement("div");
    row.className = "message-row user-row";
    row.dataset.testid = "user";
    row.dataset.role = "user";

    const msg = document.createElement("div");
    msg.className = "message user user-message";

    const md = document.createElement("div");
    md.className = "markdown hy-markdown hy-user-text";
    // Render as plain text — never run user input through marked / KaTeX.
    md.style.whiteSpace = "pre-wrap";
    md.style.wordBreak = "break-word";
    md.textContent = op.text || "";

    msg.appendChild(md);
    row.appendChild(msg);
    state.host.appendChild(row);
  }

  function opAssistantBegin(op) {
    if (state.bubbles.has(op.id)) return; // idempotent on retry
    createAssistantBubble(op.id);
  }

  function opReasoningDelta(op) {
    let b = ensureBubble(op.id);
    if (!b) b = createAssistantBubble(op.id);
    ensureReasoning(b);
    const d = op.delta || "";
    if (!d) return;
    b.reasoningBuf += d;
    // Reasoning is shown as raw text — fast and never re-renders math.
    // Append-only via textContent means even very long reasoning stays
    // O(delta) per update; the DOM tree is just a single text node.
    b.reasoningBodyEl.textContent = b.reasoningBuf;
    // Keep the latest reasoning line visible inside the (max-height-
    // scrollable) details body.
    b.reasoningBodyEl.scrollTop = b.reasoningBodyEl.scrollHeight;
  }

  function opContentDelta(op) {
    let b = ensureBubble(op.id);
    if (!b) b = createAssistantBubble(op.id);
    if (op.thinking_done) markThinkingDone(b);
    ensureContent(b);
    const d = op.delta || "";
    if (!d) return;
    b.contentBuf += d;

    // Compute new freeze boundary. If it advanced past the last frozen
    // length, render JUST the newly-frozen chunk (text since last
    // boundary) ONCE and APPEND its DOM to the frozen container. The
    // already-frozen DOM is never touched — KaTeX, hljs, and marked
    // run exactly once per closed block.
    //
    // Why we can render the new chunk in isolation: the boundary is
    // always at a paragraph break (\n\n) outside any open code fence /
    // display math / LaTeX env, so each chunk is a complete set of
    // top-level markdown blocks that ``marked`` parses identically
    // whether or not it has the prior context.
    const text = b.contentBuf;
    const newBoundary = findFreezeBoundary(text);
    if (newBoundary > b.frozenLen) {
      const newChunk = text.slice(b.frozenLen, newBoundary);
      const temp = document.createElement("div");
      renderMarkdownInto(temp, newChunk);
      // Move children from temp into frozenEl. Moving (not cloning)
      // preserves the KaTeX/hljs work we just did, and crucially does
      // NOT re-trigger any rendering of the already-frozen DOM.
      while (temp.firstChild) {
        b.frozenEl.appendChild(temp.firstChild);
      }
      b.frozenLen = newBoundary;
    }
    // Streaming segment (everything after the boundary) is re-rendered
    // in full on each delta. It's bounded to roughly one paragraph, so
    // the KaTeX / marked / hljs work is O(paragraph) — and since
    // ``innerHTML = ...`` wipes the previous DOM, KaTeX never
    // double-renders the same formula here either.
    const streamingText = text.slice(b.frozenLen);
    renderMarkdownInto(b.streamingEl, streamingText);
  }

  function opAssistantEnd(op) {
    const b = ensureBubble(op.id);
    if (!b) return;
    b.ended = true;
    if (b.typing && b.typing.parentNode) b.typing.remove();
    // Defer one extra nudge to the next animation frame: post-stream
    // KaTeX/hljs decoration of the just-flushed tail can run after
    // applyDelta returns, growing the bubble's height beyond what
    // ``hy-chat:updated`` already saw at the end of this op.
    nudgeScrollSoon();
    // Final flush: take whatever is still in the streaming segment and
    // append it to the frozen container as one last chunk, then empty
    // streaming. Crucially we do NOT re-render the already-frozen DOM
    // — that would re-run KaTeX over every formula the user has been
    // looking at and produce a final visible flicker.
    if (b.contentBuf) {
      ensureContent(b);
      const tail = b.contentBuf.slice(b.frozenLen);
      if (tail) {
        const temp = document.createElement("div");
        renderMarkdownInto(temp, tail);
        while (temp.firstChild) {
          b.frozenEl.appendChild(temp.firstChild);
        }
      }
      b.frozenLen = b.contentBuf.length;
      b.streamingEl.innerHTML = "";
    } else if (b.streamingEl) {
      // No content at all (might be tool-call only) — clean up.
      b.streamingEl.innerHTML = "";
    }
    if (b.reasoningEl && !b.thinkingDone) markThinkingDone(b);
  }

  function opToolCall(op) {
    let b = ensureBubble(op.id);
    if (!b) b = createAssistantBubble(op.id);
    if (b.typing && b.typing.parentNode) b.typing.remove();
    if (!b.toolCallContainer) {
      const c = document.createElement("div");
      c.className = "hy-tool-calls";
      b.md.appendChild(c);
      b.toolCallContainer = c;
    }
    const tc = document.createElement("div");
    tc.className = "hy-tool-call";
    renderMarkdownInto(tc, op.markdown || "");
    b.toolCallContainer.appendChild(tc);
  }

  function opRemoveBubble(op) {
    const b = ensureBubble(op.id);
    if (!b) return;
    if (_rowResizeObserver && b.row) _rowResizeObserver.unobserve(b.row);
    if (b.row && b.row.parentNode) b.row.parentNode.removeChild(b.row);
    state.bubbles.delete(op.id);
  }

  function applyOp(op) {
    if (!op || !op.type) return;
    if (!ensureHost()) return;
    switch (op.type) {
      case "reset":           opReset(); break;
      case "user":            opUser(op); break;
      case "assistant_begin": opAssistantBegin(op); break;
      case "reasoning_delta": opReasoningDelta(op); break;
      case "content_delta":   opContentDelta(op); break;
      case "assistant_end":   opAssistantEnd(op); break;
      case "tool_call":       opToolCall(op); break;
      case "remove_bubble":   opRemoveBubble(op); break;
      default:
        console.warn("[hy-chat] unknown op type:", op.type);
    }
  }

  function applyDelta(payload) {
    if (!payload || typeof payload !== "object") return;
    const seq = typeof payload.seq === "number" ? payload.seq : null;
    // Dedup: Gradio occasionally re-fires the same component value on
    // reconnect. Since each delta has a fresh global seq, anything ≤
    // lastSeq is a re-fire we've already applied.
    if (seq !== null && seq <= state.lastSeq) return;
    if (seq !== null) state.lastSeq = seq;
    const ops = Array.isArray(payload.ops) ? payload.ops : [];

    // ── Stale-epoch drop ────────────────────────────────────────────
    // The user clicked "+" mid-stream. The reset payload carries a NEW
    // epoch; we adopt it and apply the reset. Any later payload from
    // the cancelled generator still carries the OLD epoch and is
    // dropped here — without this, late content_delta ops would
    // ``createAssistantBubble`` for the old bubble id and a ghost
    // bubble would appear in the freshly-cleared chat.
    const epoch = payload.epoch;
    if (epoch !== undefined && epoch !== null) {
      const hasReset = ops.some((op) => op && op.type === "reset");
      if (hasReset) {
        state.currentEpoch = epoch;
      } else if (state.currentEpoch === null) {
        // First payload of the session: adopt whatever epoch we see.
        state.currentEpoch = epoch;
      } else if (epoch !== state.currentEpoch) {
        return;
      }
    }

    for (let i = 0; i < ops.length; i++) {
      try {
        applyOp(ops[i]);
      } catch (err) {
        console.error("[hy-chat] op failed:", ops[i], err);
      }
    }
    // Notify subscribers (the scroll lock and code-block injector in
    // app.js) that the chat DOM changed. We dispatch a single CustomEvent
    // per applied delta — coarser than per-op so subscribers can do their
    // own coalescing on top.
    if (state.host) {
      state.host.dispatchEvent(new CustomEvent("hy-chat:updated", {
        bubbles: false,
      }));
    }
  }

  // ─── Wire up the delta channel ─────────────────────────────────────────
  function startDeltaObserver() {
    const deltaHost = document.getElementById("hy-chat-delta");
    if (!deltaHost) {
      setTimeout(startDeltaObserver, 200);
      return;
    }
    let lastValue = "";
    function handle() {
      // Gradio may put the value in textContent or in a nested element
      // depending on version. Walk the subtree to be defensive.
      //
      // While a generator is actively yielding, Gradio also injects a
      // status / elapsed-time text node ("0.0s", "0.1s", …) INSIDE the
      // output component's wrapper. That node bubbles into textContent
      // and ticks every ~100ms, which would (a) make JSON.parse choke
      // on the leading "0.0s" prefix and (b) re-fire the observer on
      // every timer tick. Slice from the first '{' to the last '}' to
      // skip the status overlay entirely.
      const raw = (deltaHost.textContent || "");
      const start = raw.indexOf("{");
      const end = raw.lastIndexOf("}");
      if (start < 0 || end < start) return;
      const v = raw.slice(start, end + 1);
      if (!v || v === lastValue) return;
      lastValue = v;
      let payload;
      try {
        payload = JSON.parse(v);
      } catch (err) {
        // Real malformed payload (rare): keep visibility, but at debug
        // level so the timer overlay can't ever spam the console.
        console.debug("[hy-chat] bad delta JSON:", v.slice(0, 200), err);
        return;
      }
      applyDelta(payload);
    }
    new MutationObserver(handle).observe(deltaHost, {
      childList: true,
      subtree: true,
      characterData: true,
    });
    handle(); // pick up initial value if any
  }

  function init() {
    if (!ensureHost()) {
      setTimeout(init, 200);
      return;
    }
    startDeltaObserver();
  }

  // Public surface for the rest of the page (app.js).
  window.HY_CHAT = {
    init,
    applyDelta,
    getHost: ensureHost,
  };

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () => setTimeout(init, 200));
  } else {
    setTimeout(init, 200);
  }
})();