wezard 2.1.0 → 2.1.2

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/web/chat.css CHANGED
@@ -211,16 +211,14 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
211
211
  .si .ts { margin-left: auto; flex: none; font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums }
212
212
  .si .pv { display: block; font-size: 11.5px; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
213
213
  .si .pv:empty { display: none }
214
-
215
- /* 执行中的 wizard: 头像角上一盏呼吸灯 (同关系图的 .wav .live)。 */
216
- .ci > .av { position: relative }
217
- .ci > .av .live {
218
- position: absolute; right: 1px; bottom: -3px; width: 9px; height: 9px;
219
- border-radius: 50%; background: #1a7f37; border: 2px solid #fff;
220
- animation: pulse 1.4s infinite;
214
+ .si-more {
215
+ display: block; width: 100%; text-align: center; padding: 4px 8px; border: 0;
216
+ background: transparent; cursor: pointer; font-size: 11.5px; color: var(--mute);
221
217
  }
222
- /* 子项的头像太小, 角上挂不住一盏看得清的灯 —— 挪到名字右边, 与回复头上的 .tg-dot 同大。 */
223
- .si .l1 .live {
218
+ .si-more:hover { color: var(--accent); font-weight: 600 }
219
+
220
+ /* 执行中的 wizard: 名字右边一盏呼吸灯, 会话与子项同一位置, 与回复头上的 .tg-dot 同大。 */
221
+ .ci .l1 .live, .si .l1 .live {
224
222
  flex: none; align-self: center; width: 7px; height: 7px;
225
223
  border-radius: 50%; background: #1a7f37; animation: pulse 1.4s infinite;
226
224
  }
@@ -303,6 +301,8 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
303
301
  .mrow .mb .tool-body section > h2, .mrow .mb .diff-meta,
304
302
  .mrow .mb .tool-call > summary:hover { background: var(--bub-soft); border-color: var(--bub-line) }
305
303
  .mrow .mb .md-body pre code { background: transparent }
304
+ /* 不是说给人听的 (工具过程框、保温 ping、reminder) 一律白底 —— 底色只留给真正的对话。 */
305
+ .mrow .mb .steps, .ping-fold .mrow { --bub: #fff; --bub-soft: #f6f8fa; --bub-line: #d0d7de }
306
306
  /* 展开的工具正文 (command / diff / 文件内容) 是裸 section, 吃的是 TURN_CSS 的白底。 */
307
307
  .mrow .mb .tool-body section { background: transparent; border-color: var(--bub-line) }
308
308
  .mrow .mb .bubble details { border-color: var(--bub-line) }
@@ -338,7 +338,7 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
338
338
  .mrow.mark { justify-content: center }
339
339
  .mrow.mark > * { flex: 1; max-width: 520px }
340
340
 
341
- /* 视角切换的那一下: 整列消息淡出再按新视角排回来 —— 这一页唯一的编排动效。 */
341
+ /* 换视角后一条旧消息都不在了 (换到别的会话): 整列淡入。有交集的走 chat.js 的逐条 FLIP (位移 + 底色)。 */
342
342
  .thread-in.flipping { animation: flip .26s ease }
343
343
  @keyframes flip { 0% { opacity: 1 } 45% { opacity: 0; transform: scaleX(.985) } 100% { opacity: 1 } }
344
344
  @media (prefers-reduced-motion: reduce) {
@@ -366,12 +366,13 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
366
366
  /* ── 🏓 keepalive 折叠 ──────────────────────────────────────────────
367
367
  保温 ping 不是对话, 但它是真花销 —— 所以留在时间轴上, 只是收成一行虚线。 */
368
368
  /* summary 的灰底 / 底线是 SHARED_CSS 里通用 details 的 —— 圆角得自己裁, 底线只在展开时才是分割线。 */
369
- .ping-fold { border: 1px dashed #d8dee4; border-radius: 10px; background: #fbfcfd; overflow: hidden }
369
+ .ping-fold { border: 1px dashed #d8dee4; border-radius: 10px; background: #fff; overflow: hidden }
370
370
  .ping-fold:not([open]) > .ping-sum { border-bottom: 0 }
371
371
  .ping-fold > .ping-sum {
372
- display: flex; align-items: center; gap: 8px; padding: 6px 12px;
372
+ display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #fff;
373
373
  cursor: pointer; list-style: none; font-size: 11.5px; color: #8c959f;
374
374
  font-family: ui-monospace, Menlo, monospace; font-variant-numeric: tabular-nums;
375
+ text-transform: none; letter-spacing: 0; /* SHARED_CSS 的 details summary 会转大写 —— 这里是 wizard 名, 大小写敏感 */
375
376
  }
376
377
  .ping-fold > .ping-sum::-webkit-details-marker { display: none }
377
378
  .ping-fold > .ping-sum::after {
@@ -380,7 +381,7 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
380
381
  }
381
382
  .ping-fold[open] > .ping-sum::after { transform: rotate(90deg) }
382
383
  .ping-fold > .ping-sum:hover { color: #57606a }
383
- .ping-fold .pb { color: #57606a; font-weight: 700; flex: none }
384
+ .ping-fold .pb { color: #57606a; font-weight: 700; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
384
385
  .ping-fold .ps { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
385
386
  .ping-fold .pt { flex: none; opacity: .8 }
386
387
  .ping-fold .ping-body {
@@ -722,3 +723,12 @@ span.av.go:hover { background: #2f55c924; box-shadow: 0 0 0 3px #2f55c924 }
722
723
 
723
724
  /* 过程框里的工具行是 nowrap 的 —— 不裁一刀会把整列撑出横向滚动。 */
724
725
  .mb > .turn-group { overflow: hidden }
726
+
727
+ /* 输入尾巴上的 system-reminder: 默认一个 <reminder> 标签, 点一下展开原文 (见 detail-render.splitReminders)。 */
728
+ .rems { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 14px 10px }
729
+ .rem { cursor: pointer }
730
+ .rem-tag { display: inline-block; border-radius: 4px; padding: 0 5px; font: 11px/1.6 ui-monospace, monospace; color: var(--faint); background: #0000000a }
731
+ .rem-raw { display: none; margin: 0; padding: 3px 8px; border-radius: 6px; background: #0000000a; color: var(--mute); white-space: pre-wrap; word-break: break-word; font: 11px/1.5 ui-monospace, monospace; cursor: text }
732
+ .rem.raw { flex-basis: 100% }
733
+ .rem.raw .rem-raw { display: block }
734
+ .rem.raw .rem-tag { display: none }
package/web/chat.js CHANGED
@@ -210,6 +210,11 @@
210
210
  var eb = childBy(ex, 'bubbles'), nb = childBy(nc, 'bubbles');
211
211
  if (eb && nb) {
212
212
  swapHead(ex, nc);
213
+ // 终句刚到: 过程框收起一次。标记留在节点上, 用户之后再点开不会被下一帧收回去。
214
+ if (nc.hasAttribute('data-fold') && !ex.hasAttribute('data-fold')) {
215
+ ex.setAttribute('data-fold', '');
216
+ ex.classList.add('folded');
217
+ }
213
218
  reconcile(eb, nb);
214
219
  ex.setAttribute('data-sig', nc.getAttribute('data-sig') || '');
215
220
  return ex;
@@ -261,6 +266,12 @@
261
266
  if (!s) return;
262
267
  if (e.target.closest('.steps-head') || e.target === s || e.target === childBy(s, 'bubbles')) s.classList.toggle('folded');
263
268
  });
269
+ // reminder 块: 摘要 ⇄ 原文。刚划选了一段原文的那次松手不算点击。
270
+ inner.addEventListener('click', function (e) {
271
+ var r = e.target.closest && e.target.closest('.rem');
272
+ if (!r || String(window.getSelection() || '')) return;
273
+ r.classList.toggle('raw');
274
+ });
264
275
 
265
276
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
266
277
  var convOf = function (key) { return R.convs.filter(function (c) { return c.key === key; })[0]; };
@@ -305,6 +316,8 @@
305
316
  // 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
306
317
  // OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算。
307
318
  var OPEN = {}, OPEN_AT = '';
319
+ // 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按会话各记各的。
320
+ var SUB_FOLD = 5, MORE = {};
308
321
  var reveal = function () {
309
322
  if (CONV && CONV !== OPEN_AT) { OPEN[CONV] = true; OPEN_AT = CONV; }
310
323
  };
@@ -324,18 +337,25 @@
324
337
  var title = c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>';
325
338
  // 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
326
339
  var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }) : [];
340
+ var hidden = talked.length - SUB_FOLD;
341
+ var bar = hidden > 0
342
+ ? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
343
+ : '';
344
+ // 展开条钉在第 SUB_FOLD+1 位, 展开与折叠都不挪: 其余子项展开后接在它下面。
345
+ var sub = function (s) {
346
+ var sel = on && s.role === WITH;
347
+ return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
348
+ 'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
349
+ goSpan('av', s.role, esc(s.label)) +
350
+ line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status)) + '</button>';
351
+ };
352
+ var rest = MORE[c.key] ? talked.slice(SUB_FOLD).map(sub).join('') : '';
327
353
  var subs = talked.length
328
- ? '<div class="subs">' + talked.map(function (s) {
329
- var sel = on && s.role === WITH;
330
- return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
331
- 'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
332
- goSpan('av', s.role, esc(s.label)) +
333
- line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status)) + '</button>';
334
- }).join('') + '</div>'
354
+ ? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
335
355
  : '';
336
356
  var sel = on && !WITH;
337
357
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
338
- avatarOf(c, lampOf(c.status)) + line(title, c.lastTs, c.preview) +
358
+ avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status)) +
339
359
  '</button>' + subs;
340
360
  };
341
361
 
@@ -354,6 +374,10 @@
354
374
  var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '';
355
375
  b.onclick = function () { clickItem(key, w); };
356
376
  });
377
+ convsEl.querySelectorAll('[data-more]').forEach(function (b) {
378
+ var key = b.getAttribute('data-more');
379
+ b.onclick = function () { MORE[key] = !MORE[key]; renderConvs(); };
380
+ });
357
381
  bindGo(convsEl);
358
382
  };
359
383
 
@@ -416,8 +440,9 @@
416
440
  // 名字下面那一行就是出身 —— 有父亲的直接写成「谁的什么」(可点过去), 不再另起一条重复。
417
441
  // 分身 = 从父亲某个 session 节点 fork 出来的 (带着那一刻的上下文);
418
442
  // 子 wizard = 父亲 spawn 的白板, 只有出身、没有继承。
419
- var kind = !r.parent
420
- ? esc(r.kind === 'human' ? '人' : 'wizard')
443
+ // 人不挂身份行 —— 名字本身就够了。
444
+ var kind = r.kind === 'human' ? ''
445
+ : !r.parent ? 'wizard'
421
446
  : r.forkedFrom
422
447
  ? '<span class="go" data-r="' + esc(r.parent.id) + '" title="从它的 session ' + esc(r.forkedFrom) + ' fork, 开局带着那一刻的上下文">⧉ .' + esc(r.parent.name) + ' 的分身 · @' + esc(r.forkedFrom.slice(0, 8)) + '</span>'
423
448
  : '<span class="go" data-r="' + esc(r.parent.id) + '" title="它 spawn 的白板, 没有继承上下文">↳ .' + esc(r.parent.name) + ' 的子 wizard</span>';
@@ -433,7 +458,7 @@
433
458
  $('#rb-who').innerHTML =
434
459
  '<div class="id"><span class="av">' + esc(r.label) + '</span>' +
435
460
  '<span class="l">' + nm(r.id, r.name) +
436
- '<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span></span></div>' +
461
+ (kind ? '<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span>' : '') + '</span></div>' +
437
462
  (r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
438
463
  '<div class="facts">' + facts.join('') + '</div>';
439
464
  paintStatus();
@@ -566,7 +591,7 @@
566
591
  (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
567
592
  var av = mine ? '' : '<button class="av' + (canSwitch(m.from) ? ' go' : '') + '" data-r="' + esc(m.from) + '" title="' + esc(nameOf(m.from)) + '"' + (canSwitch(m.from) ? '' : ' disabled') + '>' + esc(m.fromLabel || roleLabel(m.from)) + '</button>';
568
593
  return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
569
- (m.ping ? ' data-ping="1"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
594
+ (m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
570
595
  '<div class="mcol"><div class="mwho">' + av + who + '</div><div class="mb">' + m.html + '</div></div>' +
571
596
  flip +
572
597
  '</div>';
@@ -587,18 +612,24 @@
587
612
  var run = [];
588
613
  var flush = function (before) {
589
614
  if (!run.length) { return; }
590
- var seen = {}, n = 0;
615
+ // 群窗里几个 wizard 同时挂机, 它们的 ping 会交错成一串 —— 按 wizard 分开计数, 不相加。
616
+ var seen = {}, per = {}, order = [];
591
617
  run.forEach(function (r) {
592
618
  var t = r.getAttribute('data-turn') || r.getAttribute('data-id');
593
- if (!seen[t]) { seen[t] = 1; n++; }
619
+ var w = r.getAttribute('data-ping-who') || '';
620
+ if (seen[t]) { return; }
621
+ seen[t] = 1;
622
+ if (!per[w]) { per[w] = 0; order.push(w); }
623
+ per[w]++;
594
624
  });
625
+ var tally = order.map(function (w) { return (w ? '.' + w + ' ' : '') + '×' + per[w]; }).join(' · ');
595
626
  var t0 = Number(run[0].getAttribute('data-ts') || 0);
596
627
  var t1 = Number(run[run.length - 1].getAttribute('data-ts') || 0);
597
628
  var span = fmtHM(t0) + (fmtHM(t1) !== fmtHM(t0) ? ' – ' + fmtHM(t1) : '');
598
629
  var d = document.createElement('details');
599
630
  d.className = 'ping-fold';
600
631
  d.open = PING_OPEN;
601
- d.innerHTML = '<summary class="ping-sum"><span class="pb">🏓 ×' + n + '</span>' +
632
+ d.innerHTML = '<summary class="ping-sum"><span class="pb">🏓 ' + esc(tally) + '</span>' +
602
633
  '<span class="ps">保温 ping</span><span class="pt">' + esc(span) + '</span></summary>' +
603
634
  '<div class="ping-body"></div>';
604
635
  root.insertBefore(d, before);
@@ -624,7 +655,8 @@
624
655
  return null;
625
656
  };
626
657
 
627
- var loadMsgs = function (limit) {
658
+ // land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
659
+ var loadMsgs = function (limit, land) {
628
660
  var gen = S.gen;
629
661
  if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
630
662
  return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
@@ -642,6 +674,7 @@
642
674
  render(inner);
643
675
  foldPings(inner);
644
676
  expireRows();
677
+ if (land && land()) return;
645
678
  S.pinned = true; toBottom(true);
646
679
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
647
680
  setTimeout(function () { toBottom(); }, 60);
@@ -731,10 +764,11 @@
731
764
  };
732
765
 
733
766
  /** 视角或窗口变了: 摘要、正文、SSE 全部按新参数重来。 */
734
- var refresh = function () {
767
+ var refresh = function (land) {
735
768
  S.gen++;
736
769
  syncUrl();
737
- inner.innerHTML = '<div class="empty">加载中…</div>';
770
+ // 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
771
+ if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
738
772
  return api('api/role', viewParams()).then(function (d) {
739
773
  if (!d.ok) {
740
774
  document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
@@ -743,7 +777,7 @@
743
777
  applyRole(d);
744
778
  syncUrl();
745
779
  if (VIEW === 'world' || VIEW === 'plan') { W.sel = ROLE; loadWorld(); }
746
- return loadMsgs().then(connect);
780
+ return loadMsgs(undefined, land).then(connect);
747
781
  });
748
782
  };
749
783
 
@@ -773,14 +807,84 @@
773
807
  var withBack = keep && WITH ? from : '';
774
808
  ROLE = id; WITH = withBack; SESSION = '';
775
809
  CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
776
- inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping');
777
- if (same) flipWindow(); else refresh();
810
+ if (same) { flipWindow(); return; }
811
+ var snap = VIEW === 'msgs' && !calm() ? snapRows() : {};
812
+ var kept = VIEW === 'msgs' ? keepBodies() : {};
813
+ refresh(function () { adoptBodies(kept); return landFlip(snap); });
814
+ };
815
+
816
+ // ── 换视角的 FLIP: 按 msgid 记下每条气泡的位置与底色, 重包后从旧处滑到新处、旧色渐到新色 ──
817
+ // 位置动在 .mcol 上, 底色按行内第几颗气泡对上 —— 就地重包与整窗重拉两条路一样用。
818
+ // 只量视窗里的行 —— 屏外的动了也没人看, 长窗口里逐行 getComputedStyle 是真开销。
819
+ var BUB = '.bubble.mq, .say > .bubble';
820
+ var calm = function () { return window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; };
821
+ var visibleRows = function () {
822
+ var vp = thread.getBoundingClientRect();
823
+ return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).filter(function (r) {
824
+ var b = r.getBoundingClientRect();
825
+ return b.height && b.bottom > vp.top && b.top < vp.bottom;
826
+ });
827
+ };
828
+ var snapRows = function () {
829
+ return visibleRows().reduce(function (acc, r) {
830
+ var col = r.querySelector('.mcol');
831
+ acc[r.getAttribute('data-id')] = {
832
+ rect: col.getBoundingClientRect(),
833
+ bubs: [].slice.call(r.querySelectorAll(BUB)).map(function (b) { return getComputedStyle(b).backgroundColor; }),
834
+ };
835
+ return acc;
836
+ }, {});
837
+ };
838
+ var playFlip = function (snap) {
839
+ var ease = { duration: 340, easing: 'cubic-bezier(.2,.7,.2,1)' };
840
+ Object.keys(snap).forEach(function (id) {
841
+ var row = rowNode(id), was = snap[id];
842
+ var col = row && row.querySelector('.mcol');
843
+ if (!col || !col.animate) return;
844
+ var now = col.getBoundingClientRect();
845
+ var dx = was.rect.left - now.left, dy = was.rect.top - now.top;
846
+ if (dx || dy) col.animate([{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }], ease);
847
+ var bubs = row.querySelectorAll(BUB);
848
+ was.bubs.forEach(function (from, i) {
849
+ var b = bubs[i], to = b && getComputedStyle(b).backgroundColor;
850
+ if (to && to !== from) b.animate([{ backgroundColor: from }, { backgroundColor: to }], ease);
851
+ });
852
+ });
853
+ };
854
+ // 整窗重拉会把正文换成服务端的新片段 —— 过程框按「已有终句就收起」重新出生, 点开的工具
855
+ // 与取过的正文全丢, 看上去就是工具那一截在切换中突然没了。sig 没变的行把旧 .mb 整块搬回来,
856
+ // 与 flipWindow 同一个做法: 折叠 / 展开态、懒取的工具正文、已渲染的 markdown 一并保住。
857
+ var keepBodies = function () {
858
+ return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).reduce(function (acc, r) {
859
+ var mb = r.querySelector('.mb');
860
+ if (mb) acc[r.getAttribute('data-id')] = { mb: mb, sig: r.getAttribute('data-sig') };
861
+ return acc;
862
+ }, {});
863
+ };
864
+ var adoptBodies = function (kept) {
865
+ Object.keys(kept).forEach(function (id) {
866
+ var row = rowNode(id), k = kept[id];
867
+ var nb = row && row.getAttribute('data-sig') === k.sig && row.querySelector('.mb');
868
+ if (nb) nb.replaceWith(k.mb);
869
+ });
870
+ };
871
+ // 整窗重拉后: 滚动锚在第一条新旧都在的消息上, 其余相对它滑动; 一条都不在 (换到了别的会话) 才整列淡入。
872
+ var landFlip = function (snap) {
873
+ var anchor = Object.keys(snap).filter(function (id) {
874
+ var r = rowNode(id); return r && r.getBoundingClientRect().height;
875
+ })[0];
876
+ if (!anchor) { inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping'); return false; }
877
+ thread.scrollTop += rowNode(anchor).querySelector('.mcol').getBoundingClientRect().top - snap[anchor].rect.top;
878
+ S.pinned = atBottom();
879
+ playFlip(snap);
880
+ return true;
778
881
  };
779
882
 
780
883
  /** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去; 侧栏交给 SSE 的首个 role 事件。 */
781
884
  var flipWindow = function () {
782
885
  S.gen++;
783
886
  syncUrl();
887
+ var snap = calm() ? {} : snapRows();
784
888
  unfoldPings(inner);
785
889
  [].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
786
890
  var m = S.frags[row.getAttribute('data-id')];
@@ -794,6 +898,7 @@
794
898
  });
795
899
  foldPings(inner);
796
900
  expireRows();
901
+ playFlip(snap);
797
902
  connect();
798
903
  var gen = S.gen;
799
904
  api('api/msgs', viewParams()).then(function (d) {