wop commited on
Commit
bf4cd90
Β·
verified Β·
1 Parent(s): e6b729c

Update templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +340 -165
templates/index.html CHANGED
@@ -195,7 +195,7 @@
195
 
196
  /* ── Turns ── */
197
  .turn { display: flex; gap: 10px; margin-bottom: var(--turn-gap); align-items: flex-start; }
198
- .turn.new-turn { animation: fadeUp 280ms var(--ease-out) both; }
199
  .turn.user { justify-content: flex-end; }
200
  .avatar {
201
  width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
@@ -208,7 +208,7 @@
208
  max-width: min(620px, calc(100vw - 100px)); border: 1px solid var(--border);
209
  border-radius: var(--radius-lg); padding: var(--bubble-padding);
210
  line-height: 1.6; font-size: var(--font-size-base);
211
- white-space: pre-wrap; word-break: break-word; background: rgba(255,255,255,.03);
212
  }
213
  .turn.assistant .bubble { border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); }
214
  .turn.user .bubble {
@@ -236,46 +236,43 @@
236
  border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg);
237
  padding: var(--bubble-padding); background: rgba(45,212,191,.04);
238
  line-height: 1.6; font-size: var(--font-size-base);
239
- white-space: pre-wrap; word-break: break-word; outline: none;
240
  }
241
  .best-answer-meta { margin-top: var(--space-1); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
242
 
243
  /* ── Shared Markdown Elements ── */
244
- .bubble p, .best-answer-bubble p, .other-answer-text p { margin: 3px 0; white-space: normal; }
 
245
 
246
- .bubble h1, .bubble h2, .bubble h3, .bubble h4, .bubble h5, .bubble h6,
247
- .best-answer-bubble h1, .best-answer-bubble h2, .best-answer-bubble h3,
248
- .best-answer-bubble h4, .best-answer-bubble h5, .best-answer-bubble h6,
249
- .other-answer-text h1, .other-answer-text h2, .other-answer-text h3 {
250
  line-height: 1.3; margin: 8px 0 3px; white-space: normal;
251
  }
252
- .bubble h1, .best-answer-bubble h1 { font-size: 1.35em; font-weight: 800; }
253
- .bubble h2, .best-answer-bubble h2 { font-size: 1.2em; font-weight: 700; }
254
- .bubble h3, .best-answer-bubble h3 { font-size: 1.05em; font-weight: 700; color: var(--accent); }
255
- .bubble h4, .best-answer-bubble h4 { font-size: .95em; font-weight: 700; }
256
- .bubble h5, .best-answer-bubble h5, .bubble h6, .best-answer-bubble h6 { font-size: .88em; font-weight: 700; }
257
 
258
- .bubble ul, .bubble ol, .best-answer-bubble ul, .best-answer-bubble ol,
259
- .other-answer-text ul, .other-answer-text ol { margin: 3px 0 3px 18px; padding: 0; white-space: normal; }
260
- .bubble li, .best-answer-bubble li, .other-answer-text li { margin: 1px 0; white-space: normal; }
261
 
262
  .task-item { list-style: none; margin-left: -18px; }
263
  .task-item input[type="checkbox"] { accent-color: var(--accent2); margin-right: 6px; pointer-events: none; cursor: default; }
264
 
265
- .bubble code, .best-answer-bubble code, .other-answer-text code {
266
  font-family: var(--mono); font-size: .87em;
267
  background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1);
268
  border-radius: var(--radius-xs); padding: 1px 5px;
269
  }
270
 
271
  .code-block-wrapper { position: relative; margin: 6px 0; }
272
- .bubble pre, .best-answer-bubble pre, .other-answer-text pre {
273
  background: rgba(0,0,0,.38); border: 1px solid var(--border);
274
  border-radius: var(--radius-sm); padding: 10px 12px;
275
  overflow-x: auto; white-space: pre; font-family: var(--mono);
276
  font-size: .84em; line-height: 1.5; margin: 0;
277
  }
278
- .bubble pre code, .best-answer-bubble pre code, .other-answer-text pre code {
279
  background: none; border: none; padding: 0; font-size: inherit;
280
  }
281
  .code-lang-label {
@@ -296,26 +293,38 @@
296
  .copy-code-btn:hover { color: var(--text); border-color: rgba(108,131,255,.4); }
297
  .copy-code-btn.copied { color: var(--good); border-color: rgba(45,212,191,.4); opacity: 1; }
298
 
299
- .bubble blockquote, .best-answer-bubble blockquote, .other-answer-text blockquote {
300
  border-left: 3px solid var(--accent); background: rgba(124,166,255,.04);
301
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
302
  margin: 6px 0; padding: 6px 12px; color: var(--muted); white-space: normal; font-style: italic;
303
  }
304
- .bubble hr, .best-answer-bubble hr { border: none; border-top: 1px solid var(--border2); margin: 8px 0; }
305
- .bubble a, .best-answer-bubble a, .other-answer-text a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
306
- .bubble a[href^="http"]::after, .best-answer-bubble a[href^="http"]::after { content: " β†—"; font-size: .75em; opacity: .5; }
307
- .bubble table, .best-answer-bubble table, .other-answer-text table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; white-space: normal; }
308
- .bubble th, .bubble td, .best-answer-bubble th, .best-answer-bubble td,
309
- .other-answer-text th, .other-answer-text td { border: 1px solid var(--border2); padding: 6px 10px; text-align: left; }
310
- .bubble th, .best-answer-bubble th { background: rgba(255,255,255,.05); font-weight: 600; }
311
  sup { font-size: .75em; vertical-align: super; line-height: 0; }
312
  sub { font-size: .75em; vertical-align: sub; line-height: 0; }
 
 
 
 
 
 
 
313
  .md-img {
314
- display: block; max-width: 100%; min-width: 60px; max-height: 480px;
315
- width: auto; height: auto; border-radius: var(--radius-md); border: 1px solid var(--border);
316
- margin: 6px 0; object-fit: contain; cursor: zoom-in; transition: opacity 150ms ease;
 
 
 
 
 
 
 
317
  }
318
- .md-img:hover { opacity: .9; }
319
  p.md-gap { min-height: 0.35em; margin: 0 !important; padding: 0; }
320
 
321
  .quality-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: 4px; }
@@ -323,34 +332,47 @@
323
  .quality-dot.filled { background: var(--accent2); }
324
 
325
  /* ── Thinking Block ── */
326
- .thinking-dropdown { margin: 6px 0; border-radius: var(--radius-md); overflow: hidden; }
327
  .thinking-summary {
328
  display: flex; align-items: center; gap: 8px; padding: 8px 12px;
329
  background: rgba(255,255,255,.04); border: 1px solid var(--border);
330
  border-radius: var(--radius-md); cursor: pointer; user-select: none;
331
  font-size: 12px; font-family: var(--mono); color: var(--muted);
332
- transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out);
333
  list-style: none;
334
  }
 
335
  .thinking-summary:hover { background: rgba(255,255,255,.06); border-color: var(--border2); }
336
  .thinking-summary::-webkit-details-marker { display: none; }
337
  .thinking-summary .thinking-arrow {
338
  display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px;
339
  }
340
  details.thinking-dropdown[open] .thinking-arrow { transform: rotate(90deg); }
 
 
 
341
  .thinking-summary .thinking-spinner {
342
  width: 12px; height: 12px; border: 2px solid var(--border2);
343
  border-top-color: var(--accent); border-radius: 50%;
344
  animation: spin-thinking .8s linear infinite;
345
  }
346
  @keyframes spin-thinking { to { transform: rotate(360deg); } }
 
 
 
 
 
 
 
 
347
  .thinking-content {
348
- padding: 10px 14px; font-size: 13px; line-height: 1.6;
349
  color: var(--muted); border: 1px solid var(--border); border-top: none;
350
  border-radius: 0 0 var(--radius-md) var(--radius-md);
351
- background: rgba(255,255,255,.02); white-space: pre-wrap; word-break: break-word;
352
- max-height: 400px; overflow-y: auto;
353
  }
 
354
  .thinking-content p { color: var(--muted); }
355
 
356
  /* ── Vote ── */
@@ -464,7 +486,7 @@
464
  .other-answer-card {
465
  border: 1px solid var(--border); border-radius: var(--radius-md);
466
  padding: 10px 12px; margin-top: 6px; background: rgba(255,255,255,.02);
467
- animation: fadeUp 200ms var(--ease-out) both; position: relative;
468
  }
469
  .other-answer-card.related {
470
  background: linear-gradient(180deg, rgba(108,131,255,.05), rgba(255,255,255,.02));
@@ -474,7 +496,7 @@
474
  display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
475
  color: var(--muted); font-family: var(--mono); font-size: 10px; margin-bottom: 6px;
476
  }
477
- .other-answer-text { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
478
 
479
  /* ── Preview lines ── */
480
  .preview-block { display: flex; gap: 8px; align-items: flex-start; margin-top: 6px; }
@@ -513,7 +535,7 @@
513
  .version-card {
514
  border: 1px solid var(--border); background: rgba(255,255,255,.02);
515
  border-radius: var(--radius-md); padding: 8px 10px; margin-top: var(--space-1);
516
- animation: fadeUp 180ms var(--ease-out) both;
517
  }
518
  .version-head {
519
  font-size: 10px; color: var(--muted); font-family: var(--mono);
@@ -551,7 +573,7 @@
551
  /* ── Typing indicator ── */
552
  .typing-indicator {
553
  display: flex; gap: 10px; margin-bottom: 6px; align-items: flex-start;
554
- animation: fadeUp 250ms var(--ease-out) both;
555
  }
556
  .typing-dots {
557
  display: flex; gap: 4px; align-items: center; padding: 12px 16px;
@@ -685,10 +707,15 @@
685
  }
686
  .anim-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); }
687
  .anim-preview { width: 24px; height: 8px; border-radius: 4px; background: var(--border2); overflow: hidden; position: relative; }
688
- .anim-option.active .anim-preview::after {
689
  content: ""; position: absolute; inset: 0;
690
  background: linear-gradient(90deg, var(--accent), var(--accent2));
691
- animation: anim-preview-slide 1s ease infinite alternate;
 
 
 
 
 
692
  }
693
  @keyframes anim-preview-slide { from { transform: translateX(-100%); } to { transform: translateX(0); } }
694
 
@@ -968,8 +995,19 @@
968
  animMode: localStorage.getItem('hi_anim') || 'none',
969
  density: localStorage.getItem('hi_density') || 'comfortable',
970
  lastAction: null, originalTitle: document.title,
 
 
 
971
  };
972
 
 
 
 
 
 
 
 
 
973
  /* ═══════════════════════════════════════════
974
  UTILITIES
975
  ═══════════════════════════════════════════ */
@@ -1004,6 +1042,25 @@
1004
  try { await fn(...a); } finally { setTimeout(() => { blocked = false; }, ms); }
1005
  };
1006
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
 
1008
  /* ═══════════════════════════════════════════
1009
  TOAST / STATUS
@@ -1057,9 +1114,14 @@
1057
  /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
1058
  (m, imgAlt, imgSrc, linkText, linkHref) => {
1059
  const idx = tokens.length;
1060
- tokens.push(imgSrc !== undefined
1061
- ? `<img class="md-img" src="${imgSrc}" alt="${esc(imgAlt)}" loading="lazy">`
1062
- : `<a href="${linkHref}" target="_blank" rel="noopener noreferrer">${esc(linkText)}</a>`);
 
 
 
 
 
1063
  return `\x00${idx}\x00`;
1064
  }
1065
  );
@@ -1090,27 +1152,28 @@
1090
  * Returns { segments: [ {type:'text'|'thinking', content:string} ] }
1091
  */
1092
  function extractThinkingBlocks(md) {
 
1093
  const segments = [];
1094
  const openTag = '<|thinking|>';
1095
  const closeTag = '</|thinking|>';
1096
  let cursor = 0;
1097
- while (cursor < md.length) {
1098
- const openIdx = md.indexOf(openTag, cursor);
1099
  if (openIdx === -1) {
1100
- segments.push({ type: 'text', content: md.slice(cursor) });
1101
  break;
1102
  }
1103
  if (openIdx > cursor) {
1104
- segments.push({ type: 'text', content: md.slice(cursor, openIdx) });
1105
  }
1106
- const closeIdx = md.indexOf(closeTag, openIdx + openTag.length);
1107
  if (closeIdx === -1) {
1108
  // Unclosed thinking block β€” treat rest as thinking
1109
- segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length) });
1110
- cursor = md.length;
1111
  break;
1112
  }
1113
- segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length, closeIdx) });
1114
  cursor = closeIdx + closeTag.length;
1115
  }
1116
  return segments;
@@ -1218,7 +1281,7 @@
1218
  out.push(`<p>${renderInlineMarkdown(raw)}</p>`);
1219
  }
1220
  closeLists(); closeQuote(); closeTable();
1221
- if (inCode) out.push(`<div class="code-block-wrapper"><pre><code>${codeBuf.join('\n')}</code></pre></div>`);
1222
  return out.join('');
1223
  }
1224
 
@@ -1227,20 +1290,19 @@
1227
  * Returns HTML with <details> dropdowns for each thinking block.
1228
  * `thinkingDuration` is the number of seconds to display (null = still thinking).
1229
  */
 
 
 
 
 
 
1230
  function renderMarkdownWithThinking(md) {
1231
- const segments = extractThinkingBlocks(md);
1232
- return segments.map(seg => {
1233
- if (seg.type === 'thinking') {
1234
- const trimmed = seg.content.trim();
1235
- if (!trimmed) return '';
1236
- const thinkingHtml = renderMarkdown(trimmed);
1237
- return `<details class="thinking-dropdown">
1238
- <summary class="thinking-summary"><span class="thinking-arrow" aria-hidden="true">β–Ά</span> <span>thought</span></summary>
1239
- <div class="thinking-content">${thinkingHtml}</div>
1240
- </details>`;
1241
- }
1242
- const trimmed = seg.content.trim();
1243
- return trimmed ? renderMarkdown(trimmed) : '';
1244
  }).join('');
1245
  }
1246
 
@@ -1313,106 +1375,112 @@
1313
  /* ═══════════════════════════════════════════
1314
  ANIMATE TEXT (chunked autoregressive)
1315
  ═══════════════════════════════════════════ */
1316
- async function animateText(el, text) {
1317
- if (!el) return;
1318
- const mode = S.animMode;
1319
- const delays = { none:0, ai:18, human:30, diffusion:50, 'diffusion-v2':70 };
1320
- const delay = delays[mode] ?? 0;
1321
-
1322
- const segments = extractThinkingBlocks(text);
1323
-
1324
- if (mode === 'none') {
1325
- el.innerHTML = renderMarkdownWithThinking(text, null);
 
 
 
 
 
1326
  finalizeThinkingBlocks(el);
1327
- bindCodeCopyButtons(el);
1328
- return;
1329
  }
1330
 
1331
  el.innerHTML = '';
 
 
 
 
1332
 
1333
- for (const seg of segments) {
1334
  if (seg.type === 'thinking') {
1335
- await animateThinkingBlock(el, seg.content, delay);
1336
- } else {
1337
- const textContainer = document.createElement('div');
1338
- el.appendChild(textContainer);
1339
- await animateMarkdownChunked(textContainer, seg.content, delay);
1340
  }
 
 
 
 
 
1341
  }
1342
 
1343
  finalizeThinkingBlocks(el);
1344
- bindCodeCopyButtons(el);
1345
  }
1346
 
 
 
1347
 
1348
- /**
1349
- * Animate a thinking block: show a "thinking…" dropdown that updates
1350
- * in real-time, then when complete, display final duration.
1351
- */
1352
- async function animateThinkingBlock(parentEl, thinkingText, delay) {
1353
  const details = document.createElement('details');
1354
- details.className = 'thinking-dropdown';
1355
- details.open = true; // start opened
1356
-
1357
- const summary = document.createElement('summary');
1358
- summary.className = 'thinking-summary';
1359
- summary.innerHTML = `<span class="thinking-spinner" aria-hidden="true"></span> <span class="thinking-label">thinking…</span>`;
1360
-
1361
- const contentDiv = document.createElement('div');
1362
- contentDiv.className = 'thinking-content';
1363
-
1364
- details.appendChild(summary);
1365
- details.appendChild(contentDiv);
1366
  parentEl.appendChild(details);
 
 
1367
  scrollBottom();
1368
 
 
1369
  const startTime = performance.now();
1370
-
1371
- await animateMarkdownChunked(contentDiv, thinkingText.trim(), delay);
 
 
1372
 
1373
  const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
 
 
 
 
 
1374
 
1375
- summary.innerHTML = `<span class="thinking-arrow" aria-hidden="true">β–Ά</span> <span>thinking for ${elapsed}s</span>`;
1376
- details.open = false; // auto-close after finishing
 
 
 
 
 
1377
  }
1378
 
1379
- /**
1380
- * Animate markdown by splitting it into small line-based chunks
1381
- * and rendering progressively, so it feels autoregressive.
1382
- */
1383
- async function animateMarkdownChunked(el, mdText, delay) {
1384
- if (!mdText.trim()) return;
1385
 
1386
- const lines = mdText.replace(/\r\n/g,'\n').split('\n');
1387
  let buffer = '';
1388
- // We'll accumulate lines and re-render periodically
1389
- // Chunk size: 1-3 lines at a time for natural feel
1390
- const chunkSize = delay > 40 ? 1 : delay > 15 ? 2 : 3;
1391
 
1392
- for (let i = 0; i < lines.length; i += chunkSize) {
1393
- const chunk = lines.slice(i, i + chunkSize).join('\n');
 
1394
  buffer += (buffer ? '\n' : '') + chunk;
1395
 
1396
- // Render current buffer
1397
- const html = renderMarkdown(buffer);
1398
- el.innerHTML = html;
1399
- bindCodeCopyButtons(el);
1400
  scrollBottom();
1401
 
1402
- if (delay > 0 && i + chunkSize < lines.length) {
1403
- await sleep(delay);
 
1404
  }
1405
  }
 
1406
  }
1407
 
1408
- /**
1409
- * After all animation is done, finalize any thinking blocks
1410
- * that might still show spinners (for non-animated renders).
1411
- */
1412
- function finalizeThinkingBlocks(el) {
1413
- // For statically rendered thinking blocks, we just need to make sure
1414
- // the duration is set. This is handled in renderMarkdownWithThinking
1415
- // for instant mode. For animated mode, animateThinkingBlock handles it.
1416
  }
1417
 
1418
  /* ═══════════════════════════════════════════
@@ -1458,6 +1526,43 @@
1458
  });
1459
  });
1460
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1461
 
1462
  /* ═══════════════════════════════════════════
1463
  ANSWER HELPERS
@@ -1550,7 +1655,7 @@
1550
  <div id="writeEditorPane" role="tabpanel" aria-labelledby="writeTabEdit">
1551
  <textarea class="write-textarea" id="writeTextarea" placeholder="Write your answer here… Markdown is supported." rows="4" aria-label="Your answer" maxlength="5000"></textarea>
1552
  </div>
1553
- <div id="writePreviewPane" role="tabpanel" aria-labelledby="writeTabPreview" class="write-preview"></div>
1554
  <div class="char-count" id="writeCharCount"><span id="writeCharCur">0</span> / 5000</div>
1555
  <div class="write-actions">
1556
  <button class="write-submit" id="writeSubmit">Submit answer</button>
@@ -1564,7 +1669,7 @@
1564
  const rawText = v.text||'';
1565
  const label = isBest ? `<span class="chip good">βœ“ best answer</span>` : `<span class="chip muted">answer ${idx+1}</span>`;
1566
  const bubbleId = isBest ? 'id="bestAnswerText"' : '';
1567
- const bubbleClass = isBest ? 'best-answer-bubble' : 'bubble';
1568
  const glowClass = isBest ? 'answer-new-glow' : '';
1569
  return `
1570
  <div ${bubbleId} class="${bubbleClass} ${glowClass}" tabindex="-1">${isBest ? '' : renderMarkdownWithThinking(rawText)}</div>
@@ -1595,7 +1700,7 @@
1595
  <span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span>
1596
  <span>${relativeTime(v.created_at)}</span> ${renderQualityDots(v.text||'')}
1597
  </div>
1598
- <div class="other-answer-text">${renderMarkdownWithThinking(v.text||'')}</div>
1599
  ${renderVoteRow(a.id, v)}
1600
  <div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);">
1601
  ${renderVersions(a)} ${renderPropose(a.id)}
@@ -1637,19 +1742,20 @@
1637
  MAIN RENDER
1638
  ═══════════════════════════════════════════ */
1639
  async function renderConversation(questionText, doAnimate) {
 
1640
  const tr=$('transcript'), wl=$('welcome');
1641
  const frag = document.createDocumentFragment();
1642
 
1643
  if (!S.conversation) {
1644
  wl.style.display=''; tr.replaceChildren();
1645
  setJumpLatest(false); document.title=S.originalTitle;
1646
- updateWelcomeState(); return;
1647
  }
1648
 
1649
  wl.style.display='none';
1650
  const q = questionText||S.conversation.question||'';
1651
  document.title = q.slice(0,60)+' β€” '+S.originalTitle;
1652
- if (S.conversation.id) history.replaceState({cid:S.conversation.id},'',`/q/${S.conversation.id}`);
1653
 
1654
  const isNew = !S.conversation.created_at || (Date.now()-new Date(S.conversation.created_at).getTime())<10000;
1655
  const questionNote = isNew
@@ -1686,24 +1792,29 @@
1686
  }
1687
 
1688
  tr.replaceChildren(frag);
 
1689
 
1690
  if (answers.length) {
1691
  const bestV = activeVersion(answers[0]);
1692
  if (bestV) {
1693
  const el = $('bestAnswerText');
1694
- if (doAnimate) {
1695
- await animateText(el, bestV.text||'');
1696
- } else if (el) {
1697
- el.innerHTML = renderMarkdownWithThinking(bestV.text||'');
1698
- bindCodeCopyButtons(el);
 
1699
  }
1700
  }
1701
  }
 
1702
 
1703
  const rm = $('relatedMount');
1704
  if (rm && S.relatedAnswers.length) rm.innerHTML = renderRelated(S.relatedAnswers);
1705
 
 
1706
  bindHandlers(); scrollBottom(); restoreDraft();
 
1707
  }
1708
 
1709
  /* ═══════════════════════════════════════════
@@ -1768,8 +1879,6 @@
1768
  if (e.target.closest('#writeSubmit')) { await handleWriteSubmit(); return; }
1769
  if (e.target.closest('#writeTabEdit')) { handleWriteTab('edit'); return; }
1770
  if (e.target.closest('#writeTabPreview')) { handleWriteTab('preview'); return; }
1771
- const img = e.target.closest('.md-img');
1772
- if (img) { openLightbox(img.src, img.alt); return; }
1773
  });
1774
 
1775
  tr.addEventListener('input', e => {
@@ -1780,8 +1889,9 @@
1780
  updateCharCount(ta,'writeCharCur',5000);
1781
  saveDraft(ta.value);
1782
  const preview=$('writePreviewPane');
1783
- if (preview?.classList.contains('write-preview') && !preview.style.display?.includes('none'))
1784
- preview.innerHTML = renderMarkdown(ta.value);
 
1785
  } else {
1786
  const panel = ta.closest('.propose-panel');
1787
  if (panel) {
@@ -1925,8 +2035,12 @@
1925
  editorPane.style.display='none'; previewPane.style.display='';
1926
  previewPane.classList.add('active');
1927
  const ta=$('writeTextarea');
1928
- previewPane.innerHTML = ta ? renderMarkdown(ta.value) : '<p style="color:var(--muted)">Nothing to preview.</p>';
1929
- bindCodeCopyButtons(previewPane);
 
 
 
 
1930
  }
1931
  }
1932
 
@@ -2000,6 +2114,7 @@
2000
  ASK / SUBMIT
2001
  ═══════════════════════════════════════════ */
2002
  async function askQuestion(q) {
 
2003
  showStatusWithEscalation(); showTyping();
2004
  S.loading=true; $('sendBtn').disabled=true; closeAutocomplete();
2005
  S.lastAction=()=>askQuestion(q);
@@ -2009,7 +2124,7 @@
2009
  S.conversation=res.conversation; S.currentQuestion=q;
2010
  S.relatedAnswers=Array.isArray(res.related)?res.related:[];
2011
  save(); toast(res.matched?'βœ“ Existing answer found':'βœ“ New question created','good');
2012
- await renderConversation(q,true);
2013
  }
2014
 
2015
  async function submitPrompt() {
@@ -2027,8 +2142,7 @@
2027
  /* ═══════════════════════════════════════════
2028
  LOAD SAVED
2029
  ═══════════════════════════════════════════ */
2030
- async function loadSaved() {
2031
- const id=localStorage.getItem('hi_last_cid'); if(!id) return;
2032
  const tr=$('transcript'), wl=$('welcome');
2033
  wl.style.display='none';
2034
  tr.innerHTML=`<div class="skeleton-wrap">
@@ -2036,13 +2150,50 @@
2036
  <div class="skeleton skeleton-line long"></div>
2037
  <div class="skeleton skeleton-line medium"></div>
2038
  <div class="skeleton skeleton-line short"></div></div>`;
2039
- showStatus('Loading conversation…');
 
 
 
 
 
 
 
 
 
 
 
 
 
2040
  const res=await callAPI('get_conversation',{conversation_id:id});
2041
  hideStatus();
2042
  if(res.ok&&res.conversation){
2043
  S.conversation=res.conversation; S.currentQuestion=res.conversation.question||'';
2044
- S.relatedAnswers=[]; renderConversation(S.currentQuestion,false);
2045
- } else { tr.innerHTML=''; wl.style.display=''; updateWelcomeState(); localStorage.removeItem('hi_last_cid'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2046
  }
2047
 
2048
  /* ═══════════════════════════════════════════
@@ -2052,11 +2203,13 @@
2052
  if(qsa('textarea').some(t=>t.value.trim())){
2053
  if(!await confirmModal('Start a new chat?','You have unsaved content. It will be lost.')) return;
2054
  }
 
2055
  S.conversation=null; S.currentQuestion=''; S.relatedAnswers=[]; S.atBottom=true;
 
2056
  localStorage.removeItem('hi_last_cid');
2057
  $('transcript').innerHTML=''; $('welcome').style.display='';
2058
  updateWelcomeState(); setJumpLatest(false); $('prompt').value='';
2059
- autoGrow($('prompt')); history.replaceState({},'','/');
2060
  document.title=S.originalTitle; $('prompt').focus();
2061
  }
2062
 
@@ -2089,10 +2242,30 @@
2089
  // Animation options
2090
  const animOpts=qsa('.anim-option',$('animSegment'));
2091
  function syncAnim(){
2092
- animOpts.forEach(o=>{const a=S.animMode===o.getAttribute('data-anim');o.classList.toggle('active',a);o.setAttribute('aria-checked',String(a));});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2093
  }
2094
  animOpts.forEach(o=>{
2095
- o.addEventListener('click',()=>{S.animMode=o.getAttribute('data-anim');localStorage.setItem('hi_anim',S.animMode);syncAnim();});
2096
  o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}});
2097
  });
2098
 
@@ -2197,14 +2370,15 @@
2197
  function initPullToRefresh(){
2198
  const chat=$('chat'); let pullStart=0;
2199
  chat.addEventListener('touchstart',e=>{pullStart=chat.scrollTop===0?e.touches[0].clientY:0;},{passive:true});
2200
- chat.addEventListener('touchend',e=>{
2201
  if(!pullStart) return;
2202
  if(e.changedTouches[0].clientY-pullStart>80&&S.conversation){
2203
- pullStart=0; showStatus('Refreshing…');
2204
- callAPI('get_conversation',{conversation_id:S.conversation.id}).then(res=>{
2205
- hideStatus();
2206
- if(res.ok&&res.conversation){S.conversation=res.conversation;renderConversation(S.currentQuestion,false);toast('Refreshed','good');}
2207
  });
 
2208
  }
2209
  pullStart=0;
2210
  },{passive:true});
@@ -2250,11 +2424,12 @@
2250
 
2251
  const d=window.__HI_INIT__||{};
2252
  if(d.client_id) S.clientId=d.client_id;
2253
- updateWelcomeState(); await loadSaved(); prompt.focus();
 
2254
  }
2255
 
2256
  init();
2257
  })();
2258
  </script>
2259
  </body>
2260
- </html>
 
195
 
196
  /* ── Turns ── */
197
  .turn { display: flex; gap: 10px; margin-bottom: var(--turn-gap); align-items: flex-start; }
198
+ .turn.new-turn { animation: fadeUp 380ms var(--ease-out) both; }
199
  .turn.user { justify-content: flex-end; }
200
  .avatar {
201
  width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
 
208
  max-width: min(620px, calc(100vw - 100px)); border: 1px solid var(--border);
209
  border-radius: var(--radius-lg); padding: var(--bubble-padding);
210
  line-height: 1.6; font-size: var(--font-size-base);
211
+ background: rgba(255,255,255,.03);
212
  }
213
  .turn.assistant .bubble { border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); }
214
  .turn.user .bubble {
 
236
  border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg);
237
  padding: var(--bubble-padding); background: rgba(45,212,191,.04);
238
  line-height: 1.6; font-size: var(--font-size-base);
239
+ outline: none;
240
  }
241
  .best-answer-meta { margin-top: var(--space-1); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
242
 
243
  /* ── Shared Markdown Elements ── */
244
+ .md-body { white-space: normal; word-break: break-word; overflow-wrap: anywhere; }
245
+ .md-body p { margin: 3px 0; white-space: normal; }
246
 
247
+ .md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 {
 
 
 
248
  line-height: 1.3; margin: 8px 0 3px; white-space: normal;
249
  }
250
+ .md-body h1 { font-size: 1.35em; font-weight: 800; }
251
+ .md-body h2 { font-size: 1.2em; font-weight: 700; }
252
+ .md-body h3 { font-size: 1.05em; font-weight: 700; color: var(--accent); }
253
+ .md-body h4 { font-size: .95em; font-weight: 700; }
254
+ .md-body h5, .md-body h6 { font-size: .88em; font-weight: 700; }
255
 
256
+ .md-body ul, .md-body ol { margin: 3px 0 3px 18px; padding: 0; white-space: normal; }
257
+ .md-body li { margin: 1px 0; white-space: normal; }
 
258
 
259
  .task-item { list-style: none; margin-left: -18px; }
260
  .task-item input[type="checkbox"] { accent-color: var(--accent2); margin-right: 6px; pointer-events: none; cursor: default; }
261
 
262
+ .md-body code {
263
  font-family: var(--mono); font-size: .87em;
264
  background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1);
265
  border-radius: var(--radius-xs); padding: 1px 5px;
266
  }
267
 
268
  .code-block-wrapper { position: relative; margin: 6px 0; }
269
+ .md-body pre {
270
  background: rgba(0,0,0,.38); border: 1px solid var(--border);
271
  border-radius: var(--radius-sm); padding: 10px 12px;
272
  overflow-x: auto; white-space: pre; font-family: var(--mono);
273
  font-size: .84em; line-height: 1.5; margin: 0;
274
  }
275
+ .md-body pre code {
276
  background: none; border: none; padding: 0; font-size: inherit;
277
  }
278
  .code-lang-label {
 
293
  .copy-code-btn:hover { color: var(--text); border-color: rgba(108,131,255,.4); }
294
  .copy-code-btn.copied { color: var(--good); border-color: rgba(45,212,191,.4); opacity: 1; }
295
 
296
+ .md-body blockquote {
297
  border-left: 3px solid var(--accent); background: rgba(124,166,255,.04);
298
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
299
  margin: 6px 0; padding: 6px 12px; color: var(--muted); white-space: normal; font-style: italic;
300
  }
301
+ .md-body hr { border: none; border-top: 1px solid var(--border2); margin: 8px 0; }
302
+ .md-body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
303
+ .md-body a[href^="http"]::after { content: " β†—"; font-size: .75em; opacity: .5; }
304
+ .md-body table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; white-space: normal; }
305
+ .md-body th, .md-body td { border: 1px solid var(--border2); padding: 6px 10px; text-align: left; }
306
+ .md-body th { background: rgba(255,255,255,.05); font-weight: 600; }
 
307
  sup { font-size: .75em; vertical-align: super; line-height: 0; }
308
  sub { font-size: .75em; vertical-align: sub; line-height: 0; }
309
+ .md-figure {
310
+ display: inline-flex; flex-direction: column; align-items: flex-start; gap: 6px;
311
+ margin: 8px 0; padding: 8px; max-width: min(100%, 520px);
312
+ border-radius: var(--radius-lg); border: 1px solid rgba(255,255,255,.08);
313
+ background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
314
+ box-shadow: var(--shadow-soft);
315
+ }
316
  .md-img {
317
+ display: block; max-width: 100%; max-height: 480px;
318
+ width: clamp(220px, 38vw, 420px); min-width: 0; height: auto;
319
+ border-radius: var(--radius-md); border: 1px solid var(--border);
320
+ background: rgba(0,0,0,.18); object-fit: contain; cursor: zoom-in;
321
+ transition: transform 180ms var(--ease-out), opacity 150ms ease, border-color 180ms var(--ease-out);
322
+ }
323
+ .md-img:hover { opacity: .94; transform: translateY(-1px); border-color: rgba(108,131,255,.28); }
324
+ .md-figcaption {
325
+ font-size: 11px; line-height: 1.45; color: var(--muted);
326
+ font-family: var(--mono);
327
  }
 
328
  p.md-gap { min-height: 0.35em; margin: 0 !important; padding: 0; }
329
 
330
  .quality-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: 4px; }
 
332
  .quality-dot.filled { background: var(--accent2); }
333
 
334
  /* ── Thinking Block ── */
335
+ .thinking-dropdown { margin: 8px 0; border-radius: var(--radius-md); overflow: hidden; }
336
  .thinking-summary {
337
  display: flex; align-items: center; gap: 8px; padding: 8px 12px;
338
  background: rgba(255,255,255,.04); border: 1px solid var(--border);
339
  border-radius: var(--radius-md); cursor: pointer; user-select: none;
340
  font-size: 12px; font-family: var(--mono); color: var(--muted);
341
+ transition: background 180ms var(--ease-out), border-color 180ms var(--ease-out), color 180ms var(--ease-out);
342
  list-style: none;
343
  }
344
+ .thinking-summary .thinking-label { flex: 1; min-width: 0; }
345
  .thinking-summary:hover { background: rgba(255,255,255,.06); border-color: var(--border2); }
346
  .thinking-summary::-webkit-details-marker { display: none; }
347
  .thinking-summary .thinking-arrow {
348
  display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px;
349
  }
350
  details.thinking-dropdown[open] .thinking-arrow { transform: rotate(90deg); }
351
+ .thinking-dropdown.is-live .thinking-summary {
352
+ color: var(--text); border-color: rgba(108,131,255,.22); background: rgba(108,131,255,.05);
353
+ }
354
  .thinking-summary .thinking-spinner {
355
  width: 12px; height: 12px; border: 2px solid var(--border2);
356
  border-top-color: var(--accent); border-radius: 50%;
357
  animation: spin-thinking .8s linear infinite;
358
  }
359
  @keyframes spin-thinking { to { transform: rotate(360deg); } }
360
+ .thinking-body {
361
+ display: grid; grid-template-rows: 1fr; overflow: hidden;
362
+ transition: grid-template-rows 300ms var(--ease-out), opacity 260ms var(--ease-out);
363
+ opacity: 1;
364
+ }
365
+ .thinking-dropdown:not([open]) .thinking-body {
366
+ grid-template-rows: 0fr; opacity: 0;
367
+ }
368
  .thinking-content {
369
+ min-height: 0; padding: 10px 14px; font-size: 13px; line-height: 1.6;
370
  color: var(--muted); border: 1px solid var(--border); border-top: none;
371
  border-radius: 0 0 var(--radius-md) var(--radius-md);
372
+ background: rgba(255,255,255,.02); max-height: min(38vh, 420px);
373
+ overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
374
  }
375
+ .thinking-dropdown.is-live .thinking-content { max-height: min(45vh, 560px); }
376
  .thinking-content p { color: var(--muted); }
377
 
378
  /* ── Vote ── */
 
486
  .other-answer-card {
487
  border: 1px solid var(--border); border-radius: var(--radius-md);
488
  padding: 10px 12px; margin-top: 6px; background: rgba(255,255,255,.02);
489
+ animation: fadeUp 260ms var(--ease-out) both; position: relative;
490
  }
491
  .other-answer-card.related {
492
  background: linear-gradient(180deg, rgba(108,131,255,.05), rgba(255,255,255,.02));
 
496
  display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
497
  color: var(--muted); font-family: var(--mono); font-size: 10px; margin-bottom: 6px;
498
  }
499
+ .other-answer-text { font-size: 13px; line-height: 1.6; }
500
 
501
  /* ── Preview lines ── */
502
  .preview-block { display: flex; gap: 8px; align-items: flex-start; margin-top: 6px; }
 
535
  .version-card {
536
  border: 1px solid var(--border); background: rgba(255,255,255,.02);
537
  border-radius: var(--radius-md); padding: 8px 10px; margin-top: var(--space-1);
538
+ animation: fadeUp 220ms var(--ease-out) both;
539
  }
540
  .version-head {
541
  font-size: 10px; color: var(--muted); font-family: var(--mono);
 
573
  /* ── Typing indicator ── */
574
  .typing-indicator {
575
  display: flex; gap: 10px; margin-bottom: 6px; align-items: flex-start;
576
+ animation: fadeUp 320ms var(--ease-out) both;
577
  }
578
  .typing-dots {
579
  display: flex; gap: 4px; align-items: center; padding: 12px 16px;
 
707
  }
708
  .anim-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); }
709
  .anim-preview { width: 24px; height: 8px; border-radius: 4px; background: var(--border2); overflow: hidden; position: relative; }
710
+ .anim-preview::after {
711
  content: ""; position: absolute; inset: 0;
712
  background: linear-gradient(90deg, var(--accent), var(--accent2));
713
+ opacity: .4;
714
+ animation: anim-preview-slide var(--anim-preview-duration, 1000ms) var(--anim-preview-ease, ease) infinite alternate;
715
+ }
716
+ .anim-option.active .anim-preview::after { opacity: 1; }
717
+ .anim-option.anim-static .anim-preview::after {
718
+ animation: none; transform: translateX(-38%); opacity: .2;
719
  }
720
  @keyframes anim-preview-slide { from { transform: translateX(-100%); } to { transform: translateX(0); } }
721
 
 
995
  animMode: localStorage.getItem('hi_anim') || 'none',
996
  density: localStorage.getItem('hi_density') || 'comfortable',
997
  lastAction: null, originalTitle: document.title,
998
+ routeConversationId: '',
999
+ renderSessionId: 0,
1000
+ rendering: false,
1001
  };
1002
 
1003
+ const MOTION = Object.freeze({
1004
+ none: { chunkDelay: 0, chunkSize: 6, previewDuration: 0, previewEase: 'linear' },
1005
+ ai: { chunkDelay: 26, chunkSize: 3, previewDuration: 650, previewEase: 'cubic-bezier(.33,.84,.42,1)' },
1006
+ human: { chunkDelay: 44, chunkSize: 2, previewDuration: 980, previewEase: 'cubic-bezier(.22,.61,.36,1)' },
1007
+ diffusion: { chunkDelay: 66, chunkSize: 1, previewDuration: 1320, previewEase: 'cubic-bezier(.25,.46,.45,.94)' },
1008
+ 'diffusion-v2': { chunkDelay: 90, chunkSize: 1, previewDuration: 1760, previewEase: 'cubic-bezier(.16,.84,.27,.99)' },
1009
+ });
1010
+
1011
  /* ═══════════════════════════════════════════
1012
  UTILITIES
1013
  ═══════════════════════════════════════════ */
 
1042
  try { await fn(...a); } finally { setTimeout(() => { blocked = false; }, ms); }
1043
  };
1044
  }
1045
+ function getMotion(mode = S.animMode) { return MOTION[mode] || MOTION.none; }
1046
+ function shouldAnimateResponses() { return S.animMode !== 'none'; }
1047
+ function beginRenderSession() { S.renderSessionId += 1; S.rendering = true; return S.renderSessionId; }
1048
+ function isRenderSessionActive(id) { return id === S.renderSessionId; }
1049
+ function finishRenderSession(id) { if (isRenderSessionActive(id)) S.rendering = false; }
1050
+ function cancelActiveRender() { S.renderSessionId += 1; S.rendering = false; removeTyping(); }
1051
+ function stripEdgeBlankLines(text) {
1052
+ return String(text || '')
1053
+ .replace(/^\s*\n+/, '')
1054
+ .replace(/\n+\s*$/, '')
1055
+ .trimEnd();
1056
+ }
1057
+ function normalizeConversationPath(conversationId = '') {
1058
+ return conversationId ? `/q/${conversationId}` : '/';
1059
+ }
1060
+ function isNearScrollEnd(el, threshold = 28) {
1061
+ if (!el) return true;
1062
+ return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
1063
+ }
1064
 
1065
  /* ═══════════════════════════════════════════
1066
  TOAST / STATUS
 
1114
  /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
1115
  (m, imgAlt, imgSrc, linkText, linkHref) => {
1116
  const idx = tokens.length;
1117
+ if (imgSrc !== undefined) {
1118
+ const caption = String(imgAlt || '').trim();
1119
+ tokens.push(
1120
+ `<figure class="md-figure"><img class="md-img" src="${esc(imgSrc)}" alt="${esc(caption)}" loading="lazy">${caption ? `<figcaption class="md-figcaption">${esc(caption)}</figcaption>` : ''}</figure>`
1121
+ );
1122
+ } else {
1123
+ tokens.push(`<a href="${esc(linkHref)}" target="_blank" rel="noopener noreferrer">${esc(linkText)}</a>`);
1124
+ }
1125
  return `\x00${idx}\x00`;
1126
  }
1127
  );
 
1152
  * Returns { segments: [ {type:'text'|'thinking', content:string} ] }
1153
  */
1154
  function extractThinkingBlocks(md) {
1155
+ const source = String(md || '');
1156
  const segments = [];
1157
  const openTag = '<|thinking|>';
1158
  const closeTag = '</|thinking|>';
1159
  let cursor = 0;
1160
+ while (cursor < source.length) {
1161
+ const openIdx = source.indexOf(openTag, cursor);
1162
  if (openIdx === -1) {
1163
+ segments.push({ type: 'text', content: source.slice(cursor) });
1164
  break;
1165
  }
1166
  if (openIdx > cursor) {
1167
+ segments.push({ type: 'text', content: source.slice(cursor, openIdx) });
1168
  }
1169
+ const closeIdx = source.indexOf(closeTag, openIdx + openTag.length);
1170
  if (closeIdx === -1) {
1171
  // Unclosed thinking block β€” treat rest as thinking
1172
+ segments.push({ type: 'thinking', content: source.slice(openIdx + openTag.length) });
1173
+ cursor = source.length;
1174
  break;
1175
  }
1176
+ segments.push({ type: 'thinking', content: source.slice(openIdx + openTag.length, closeIdx) });
1177
  cursor = closeIdx + closeTag.length;
1178
  }
1179
  return segments;
 
1281
  out.push(`<p>${renderInlineMarkdown(raw)}</p>`);
1282
  }
1283
  closeLists(); closeQuote(); closeTable();
1284
+ if (inCode) out.push(`<div class="code-block-wrapper"><pre><code>${esc(codeBuf.join('\n'))}</code></pre></div>`);
1285
  return out.join('');
1286
  }
1287
 
 
1290
  * Returns HTML with <details> dropdowns for each thinking block.
1291
  * `thinkingDuration` is the number of seconds to display (null = still thinking).
1292
  */
1293
+ function renderThinkingDropdownHtml(thinkingText, label = 'Thoughts') {
1294
+ const content = stripEdgeBlankLines(thinkingText);
1295
+ if (!content) return '';
1296
+ return `<details class="thinking-dropdown"><summary class="thinking-summary"><span class="thinking-arrow" aria-hidden="true">β–Ά</span><span class="thinking-label">${esc(label)}</span></summary><div class="thinking-body"><div class="thinking-content md-body">${renderMarkdown(content)}</div></div></details>`;
1297
+ }
1298
+
1299
  function renderMarkdownWithThinking(md) {
1300
+ return extractThinkingBlocks(md).map(seg => {
1301
+ const cleaned = stripEdgeBlankLines(seg.content);
1302
+ if (!cleaned) return '';
1303
+ return seg.type === 'thinking'
1304
+ ? renderThinkingDropdownHtml(cleaned)
1305
+ : renderMarkdown(cleaned);
 
 
 
 
 
 
 
1306
  }).join('');
1307
  }
1308
 
 
1375
  /* ═══════════════════════════════════════════
1376
  ANIMATE TEXT (chunked autoregressive)
1377
  ═══════════════════════════════════════════ */
1378
+ function setThinkingOpen(details, open) {
1379
+ details._internalToggle = true;
1380
+ details.open = open;
1381
+ requestAnimationFrame(() => { details._internalToggle = false; });
1382
+ }
1383
+ async function waitForRenderDelay(ms, renderId) {
1384
+ if (ms <= 0) return isRenderSessionActive(renderId);
1385
+ await sleep(ms);
1386
+ return isRenderSessionActive(renderId);
1387
+ }
1388
+ async function animateText(el, text, renderId) {
1389
+ if (!el) return false;
1390
+ const motion = getMotion();
1391
+ if (motion.chunkDelay === 0) {
1392
+ renderMarkdownInto(el, text, true);
1393
  finalizeThinkingBlocks(el);
1394
+ return true;
 
1395
  }
1396
 
1397
  el.innerHTML = '';
1398
+ for (const seg of extractThinkingBlocks(text)) {
1399
+ if (!isRenderSessionActive(renderId)) return false;
1400
+ const cleaned = stripEdgeBlankLines(seg.content);
1401
+ if (!cleaned) continue;
1402
 
 
1403
  if (seg.type === 'thinking') {
1404
+ const ok = await animateThinkingBlock(el, cleaned, motion, renderId);
1405
+ if (!ok) return false;
1406
+ continue;
 
 
1407
  }
1408
+
1409
+ const textContainer = document.createElement('div');
1410
+ el.appendChild(textContainer);
1411
+ const ok = await animateMarkdownChunked(textContainer, cleaned, motion, renderId);
1412
+ if (!ok) return false;
1413
  }
1414
 
1415
  finalizeThinkingBlocks(el);
1416
+ return isRenderSessionActive(renderId);
1417
  }
1418
 
1419
+ async function animateThinkingBlock(parentEl, thinkingText, motion, renderId) {
1420
+ if (!thinkingText || !isRenderSessionActive(renderId)) return false;
1421
 
 
 
 
 
 
1422
  const details = document.createElement('details');
1423
+ details.className = 'thinking-dropdown is-live';
1424
+ details.innerHTML = `<summary class="thinking-summary"><span class="thinking-spinner" aria-hidden="true"></span><span class="thinking-label">Thinking…</span></summary><div class="thinking-body"><div class="thinking-content md-body"></div></div>`;
 
 
 
 
 
 
 
 
 
 
1425
  parentEl.appendChild(details);
1426
+ bindRichContent(details);
1427
+ setThinkingOpen(details, true);
1428
  scrollBottom();
1429
 
1430
+ const contentDiv = qs('.thinking-content', details);
1431
  const startTime = performance.now();
1432
+ const ok = await animateMarkdownChunked(contentDiv, thinkingText, motion, renderId, () => {
1433
+ if (details._followContent !== false) contentDiv.scrollTop = contentDiv.scrollHeight;
1434
+ });
1435
+ if (!ok || !isRenderSessionActive(renderId)) return false;
1436
 
1437
  const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
1438
+ details.classList.remove('is-live');
1439
+ const summary = qs('.thinking-summary', details);
1440
+ if (summary) {
1441
+ summary.innerHTML = `<span class="thinking-arrow" aria-hidden="true">β–Ά</span><span class="thinking-label">Thoughts β€’ ${elapsed}s</span>`;
1442
+ }
1443
 
1444
+ if (!details._userToggled && details.open) {
1445
+ const shouldKeepGoing = await waitForRenderDelay(900, renderId);
1446
+ if (!shouldKeepGoing || !isRenderSessionActive(renderId)) return false;
1447
+ if (!details._userToggled) setThinkingOpen(details, false);
1448
+ }
1449
+
1450
+ return true;
1451
  }
1452
 
1453
+ async function animateMarkdownChunked(el, mdText, motion, renderId, afterChunk = null) {
1454
+ const cleaned = stripEdgeBlankLines(mdText);
1455
+ if (!cleaned) return true;
 
 
 
1456
 
1457
+ const lines = cleaned.replace(/\r\n/g,'\n').split('\n');
1458
  let buffer = '';
 
 
 
1459
 
1460
+ for (let i = 0; i < lines.length; i += motion.chunkSize) {
1461
+ if (!isRenderSessionActive(renderId)) return false;
1462
+ const chunk = lines.slice(i, i + motion.chunkSize).join('\n');
1463
  buffer += (buffer ? '\n' : '') + chunk;
1464
 
1465
+ renderMarkdownInto(el, buffer, false);
1466
+ afterChunk?.();
 
 
1467
  scrollBottom();
1468
 
1469
+ if (i + motion.chunkSize < lines.length) {
1470
+ const stillActive = await waitForRenderDelay(motion.chunkDelay, renderId);
1471
+ if (!stillActive) return false;
1472
  }
1473
  }
1474
+ return true;
1475
  }
1476
 
1477
+ function finalizeThinkingBlocks(ctx = document) {
1478
+ bindThinkingDropdowns(ctx);
1479
+ qsa('.thinking-dropdown', ctx).forEach(details => {
1480
+ details.classList.remove('is-live');
1481
+ const label = qs('.thinking-label', details);
1482
+ if (label && !label.textContent.trim()) label.textContent = 'Thoughts';
1483
+ });
 
1484
  }
1485
 
1486
  /* ═══════════════════════════════════════════
 
1526
  });
1527
  });
1528
  }
1529
+ function bindThinkingDropdowns(ctx = document) {
1530
+ qsa('.thinking-dropdown', ctx).forEach(details => {
1531
+ if (details._thinkingBound) return;
1532
+ details._thinkingBound = true;
1533
+ details._userToggled = false;
1534
+ details._followContent = true;
1535
+
1536
+ const content = qs('.thinking-content', details);
1537
+ if (content) {
1538
+ content.addEventListener('scroll', () => {
1539
+ details._followContent = isNearScrollEnd(content, 32);
1540
+ }, { passive: true });
1541
+ }
1542
+ details.addEventListener('toggle', () => {
1543
+ if (details._internalToggle) return;
1544
+ details._userToggled = true;
1545
+ });
1546
+ });
1547
+ }
1548
+ function bindImageLightboxes(ctx = document) {
1549
+ qsa('.md-img', ctx).forEach(img => {
1550
+ if (img._lightboxBound) return;
1551
+ img._lightboxBound = true;
1552
+ img.addEventListener('click', () => openLightbox(img.currentSrc || img.src, img.alt));
1553
+ });
1554
+ }
1555
+ function bindRichContent(ctx = document) {
1556
+ bindCodeCopyButtons(ctx);
1557
+ bindImageLightboxes(ctx);
1558
+ bindThinkingDropdowns(ctx);
1559
+ }
1560
+ function renderMarkdownInto(el, text, withThinking = true) {
1561
+ if (!el) return;
1562
+ el.innerHTML = withThinking ? renderMarkdownWithThinking(text) : renderMarkdown(text);
1563
+ bindRichContent(el);
1564
+ if (withThinking) finalizeThinkingBlocks(el);
1565
+ }
1566
 
1567
  /* ═══════════════════════════════════════════
1568
  ANSWER HELPERS
 
1655
  <div id="writeEditorPane" role="tabpanel" aria-labelledby="writeTabEdit">
1656
  <textarea class="write-textarea" id="writeTextarea" placeholder="Write your answer here… Markdown is supported." rows="4" aria-label="Your answer" maxlength="5000"></textarea>
1657
  </div>
1658
+ <div id="writePreviewPane" role="tabpanel" aria-labelledby="writeTabPreview" class="write-preview md-body"></div>
1659
  <div class="char-count" id="writeCharCount"><span id="writeCharCur">0</span> / 5000</div>
1660
  <div class="write-actions">
1661
  <button class="write-submit" id="writeSubmit">Submit answer</button>
 
1669
  const rawText = v.text||'';
1670
  const label = isBest ? `<span class="chip good">βœ“ best answer</span>` : `<span class="chip muted">answer ${idx+1}</span>`;
1671
  const bubbleId = isBest ? 'id="bestAnswerText"' : '';
1672
+ const bubbleClass = isBest ? 'best-answer-bubble md-body' : 'bubble md-body';
1673
  const glowClass = isBest ? 'answer-new-glow' : '';
1674
  return `
1675
  <div ${bubbleId} class="${bubbleClass} ${glowClass}" tabindex="-1">${isBest ? '' : renderMarkdownWithThinking(rawText)}</div>
 
1700
  <span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span>
1701
  <span>${relativeTime(v.created_at)}</span> ${renderQualityDots(v.text||'')}
1702
  </div>
1703
+ <div class="other-answer-text md-body">${renderMarkdownWithThinking(v.text||'')}</div>
1704
  ${renderVoteRow(a.id, v)}
1705
  <div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);">
1706
  ${renderVersions(a)} ${renderPropose(a.id)}
 
1742
  MAIN RENDER
1743
  ═══════════════════════════════════════════ */
1744
  async function renderConversation(questionText, doAnimate) {
1745
+ const renderId = beginRenderSession();
1746
  const tr=$('transcript'), wl=$('welcome');
1747
  const frag = document.createDocumentFragment();
1748
 
1749
  if (!S.conversation) {
1750
  wl.style.display=''; tr.replaceChildren();
1751
  setJumpLatest(false); document.title=S.originalTitle;
1752
+ updateWelcomeState(); finishRenderSession(renderId); return;
1753
  }
1754
 
1755
  wl.style.display='none';
1756
  const q = questionText||S.conversation.question||'';
1757
  document.title = q.slice(0,60)+' β€” '+S.originalTitle;
1758
+ if (S.conversation.id) history.replaceState({cid:S.conversation.id},'', normalizeConversationPath(S.conversation.id));
1759
 
1760
  const isNew = !S.conversation.created_at || (Date.now()-new Date(S.conversation.created_at).getTime())<10000;
1761
  const questionNote = isNew
 
1792
  }
1793
 
1794
  tr.replaceChildren(frag);
1795
+ bindRichContent(tr);
1796
 
1797
  if (answers.length) {
1798
  const bestV = activeVersion(answers[0]);
1799
  if (bestV) {
1800
  const el = $('bestAnswerText');
1801
+ if (doAnimate && shouldAnimateResponses()) {
1802
+ const completed = await animateText(el, bestV.text||'', renderId);
1803
+ if (!completed) return;
1804
+ } else if (el && isRenderSessionActive(renderId)) {
1805
+ renderMarkdownInto(el, bestV.text||'', true);
1806
+ finalizeThinkingBlocks(el);
1807
  }
1808
  }
1809
  }
1810
+ if (!isRenderSessionActive(renderId)) return;
1811
 
1812
  const rm = $('relatedMount');
1813
  if (rm && S.relatedAnswers.length) rm.innerHTML = renderRelated(S.relatedAnswers);
1814
 
1815
+ bindRichContent(tr);
1816
  bindHandlers(); scrollBottom(); restoreDraft();
1817
+ finishRenderSession(renderId);
1818
  }
1819
 
1820
  /* ═══════════════════════════════════════════
 
1879
  if (e.target.closest('#writeSubmit')) { await handleWriteSubmit(); return; }
1880
  if (e.target.closest('#writeTabEdit')) { handleWriteTab('edit'); return; }
1881
  if (e.target.closest('#writeTabPreview')) { handleWriteTab('preview'); return; }
 
 
1882
  });
1883
 
1884
  tr.addEventListener('input', e => {
 
1889
  updateCharCount(ta,'writeCharCur',5000);
1890
  saveDraft(ta.value);
1891
  const preview=$('writePreviewPane');
1892
+ if (preview?.classList.contains('write-preview') && !preview.style.display?.includes('none')) {
1893
+ renderMarkdownInto(preview, ta.value, true);
1894
+ }
1895
  } else {
1896
  const panel = ta.closest('.propose-panel');
1897
  if (panel) {
 
2035
  editorPane.style.display='none'; previewPane.style.display='';
2036
  previewPane.classList.add('active');
2037
  const ta=$('writeTextarea');
2038
+ previewPane.innerHTML = '';
2039
+ if (ta) {
2040
+ renderMarkdownInto(previewPane, ta.value, true);
2041
+ } else {
2042
+ previewPane.innerHTML = '<p style="color:var(--muted)">Nothing to preview.</p>';
2043
+ }
2044
  }
2045
  }
2046
 
 
2114
  ASK / SUBMIT
2115
  ═══════════════════════════════════════════ */
2116
  async function askQuestion(q) {
2117
+ cancelActiveRender();
2118
  showStatusWithEscalation(); showTyping();
2119
  S.loading=true; $('sendBtn').disabled=true; closeAutocomplete();
2120
  S.lastAction=()=>askQuestion(q);
 
2124
  S.conversation=res.conversation; S.currentQuestion=q;
2125
  S.relatedAnswers=Array.isArray(res.related)?res.related:[];
2126
  save(); toast(res.matched?'βœ“ Existing answer found':'βœ“ New question created','good');
2127
+ await renderConversation(q, shouldAnimateResponses());
2128
  }
2129
 
2130
  async function submitPrompt() {
 
2142
  /* ═══════════════════════════════════════════
2143
  LOAD SAVED
2144
  ═══════════════════════════════════════════ */
2145
+ function showConversationSkeleton() {
 
2146
  const tr=$('transcript'), wl=$('welcome');
2147
  wl.style.display='none';
2148
  tr.innerHTML=`<div class="skeleton-wrap">
 
2150
  <div class="skeleton skeleton-line long"></div>
2151
  <div class="skeleton skeleton-line medium"></div>
2152
  <div class="skeleton skeleton-line short"></div></div>`;
2153
+ }
2154
+ async function loadConversationById(id, opts = {}) {
2155
+ const {
2156
+ clearLocalOnMissing = false,
2157
+ replaceHistoryOnMissing = false,
2158
+ showMissingToast = false,
2159
+ showSkeleton = true,
2160
+ statusText = 'Loading conversation…',
2161
+ } = opts;
2162
+ if (!id) return false;
2163
+
2164
+ cancelActiveRender();
2165
+ if (showSkeleton) showConversationSkeleton();
2166
+ showStatus(statusText);
2167
  const res=await callAPI('get_conversation',{conversation_id:id});
2168
  hideStatus();
2169
  if(res.ok&&res.conversation){
2170
  S.conversation=res.conversation; S.currentQuestion=res.conversation.question||'';
2171
+ S.relatedAnswers=[]; save();
2172
+ await renderConversation(S.currentQuestion,false);
2173
+ return true;
2174
+ }
2175
+
2176
+ S.conversation = null; S.currentQuestion = ''; S.relatedAnswers = [];
2177
+ $('transcript').innerHTML=''; $('welcome').style.display='';
2178
+ updateWelcomeState();
2179
+ if(clearLocalOnMissing) localStorage.removeItem('hi_last_cid');
2180
+ if(replaceHistoryOnMissing) history.replaceState({},'',normalizeConversationPath());
2181
+ if(showMissingToast) toast('Conversation not found','bad');
2182
+ return false;
2183
+ }
2184
+ async function loadInitialConversation() {
2185
+ if (S.routeConversationId) {
2186
+ const fromRoute = await loadConversationById(S.routeConversationId, {
2187
+ replaceHistoryOnMissing: true,
2188
+ showMissingToast: true,
2189
+ });
2190
+ if (fromRoute) return;
2191
+ }
2192
+
2193
+ const savedId = localStorage.getItem('hi_last_cid');
2194
+ if (savedId) {
2195
+ await loadConversationById(savedId, { clearLocalOnMissing: true });
2196
+ }
2197
  }
2198
 
2199
  /* ═══════════════════════════════════════════
 
2203
  if(qsa('textarea').some(t=>t.value.trim())){
2204
  if(!await confirmModal('Start a new chat?','You have unsaved content. It will be lost.')) return;
2205
  }
2206
+ cancelActiveRender();
2207
  S.conversation=null; S.currentQuestion=''; S.relatedAnswers=[]; S.atBottom=true;
2208
+ S.routeConversationId='';
2209
  localStorage.removeItem('hi_last_cid');
2210
  $('transcript').innerHTML=''; $('welcome').style.display='';
2211
  updateWelcomeState(); setJumpLatest(false); $('prompt').value='';
2212
+ autoGrow($('prompt')); history.replaceState({},'',normalizeConversationPath());
2213
  document.title=S.originalTitle; $('prompt').focus();
2214
  }
2215
 
 
2242
  // Animation options
2243
  const animOpts=qsa('.anim-option',$('animSegment'));
2244
  function syncAnim(){
2245
+ animOpts.forEach(o=>{
2246
+ const mode = o.getAttribute('data-anim');
2247
+ const active = S.animMode===mode;
2248
+ const motion = getMotion(mode);
2249
+ o.classList.toggle('active',active);
2250
+ o.classList.toggle('anim-static', mode==='none');
2251
+ o.setAttribute('aria-checked',String(active));
2252
+ o.style.setProperty('--anim-preview-duration', `${Math.max(motion.previewDuration, 1)}ms`);
2253
+ o.style.setProperty('--anim-preview-ease', motion.previewEase);
2254
+ });
2255
+ }
2256
+ async function applyAnimMode(mode) {
2257
+ if (!mode || mode === S.animMode) return;
2258
+ const hadActiveRender = S.rendering;
2259
+ S.animMode=mode;
2260
+ localStorage.setItem('hi_anim',S.animMode);
2261
+ syncAnim();
2262
+ if (S.conversation && hadActiveRender) {
2263
+ cancelActiveRender();
2264
+ await renderConversation(S.currentQuestion, shouldAnimateResponses());
2265
+ }
2266
  }
2267
  animOpts.forEach(o=>{
2268
+ o.addEventListener('click',async ()=>{ await applyAnimMode(o.getAttribute('data-anim')); });
2269
  o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}});
2270
  });
2271
 
 
2370
  function initPullToRefresh(){
2371
  const chat=$('chat'); let pullStart=0;
2372
  chat.addEventListener('touchstart',e=>{pullStart=chat.scrollTop===0?e.touches[0].clientY:0;},{passive:true});
2373
+ chat.addEventListener('touchend',async e=>{
2374
  if(!pullStart) return;
2375
  if(e.changedTouches[0].clientY-pullStart>80&&S.conversation){
2376
+ pullStart=0;
2377
+ const ok = await loadConversationById(S.conversation.id, {
2378
+ showSkeleton: false,
2379
+ statusText: 'Refreshing…',
2380
  });
2381
+ if (ok) toast('Refreshed','good');
2382
  }
2383
  pullStart=0;
2384
  },{passive:true});
 
2424
 
2425
  const d=window.__HI_INIT__||{};
2426
  if(d.client_id) S.clientId=d.client_id;
2427
+ S.routeConversationId = String(d.conversation_id || '').trim();
2428
+ updateWelcomeState(); await loadInitialConversation(); prompt.focus();
2429
  }
2430
 
2431
  init();
2432
  })();
2433
  </script>
2434
  </body>
2435
+ </html>