@co0ontty/wand 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,7 +59,6 @@
59
59
  (function() {
60
60
  var configPath = "${escapeHtml(configPath)}";
61
61
  var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
62
- var CHAT_AUTO_FOLLOW_STORAGE_KEY = "wand-chat-auto-follow";
63
62
 
64
63
  var state = {
65
64
  selectedId: (function() {
@@ -183,16 +182,13 @@
183
182
  quickCommitGenerating: false,
184
183
  quickCommitError: "",
185
184
  quickCommitForm: { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false },
186
- chatAutoFollow: (function() {
187
- try {
188
- var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
189
- return saved === null ? true : saved === "true";
190
- } catch (e) {
191
- return true;
192
- }
193
- })(),
194
- showChatJumpToBottom: false,
195
- chatScrollThreshold: 200,
185
+ // Telegram 风格的"贴底"状态:true = 用户当前贴在底部,新消息会自然出现;
186
+ // false = 用户向上滚了,未读会累积到气泡里,不会自动滚他们的视图。
187
+ chatStickToBottom: true,
188
+ chatUnreadCount: 0,
189
+ // state.currentMessages 中第一条未读消息的 index,-1 表示没有未读。
190
+ chatUnreadStartIndex: -1,
191
+ chatScrollThreshold: 120,
196
192
  chatIsProgrammaticScroll: false,
197
193
  chatScrollElement: null,
198
194
  chatScrollHandler: null,
@@ -406,14 +402,6 @@
406
402
  }
407
403
  }
408
404
 
409
- function persistChatAutoFollow() {
410
- try {
411
- localStorage.setItem(CHAT_AUTO_FOLLOW_STORAGE_KEY, state.chatAutoFollow ? "true" : "false");
412
- } catch (e) {
413
- // Ignore localStorage errors
414
- }
415
- }
416
-
417
405
  function getChatScrollElement() {
418
406
  var chatOutput = document.getElementById("chat-output");
419
407
  if (!chatOutput) {
@@ -429,36 +417,106 @@
429
417
  return null;
430
418
  }
431
419
 
420
+ // column-reverse: scrollTop=0 是视觉底部,越往上看 scrollTop 绝对值越大。
421
+ // 部分浏览器历史上在 column-reverse 里给负 scrollTop,所以用绝对值更稳。
432
422
  function isChatNearBottom(chatMsgs) {
433
423
  var el = chatMsgs || getChatScrollElement();
434
424
  if (!el) return true;
435
- return el.scrollTop < state.chatScrollThreshold;
425
+ return Math.abs(el.scrollTop) < state.chatScrollThreshold;
436
426
  }
437
427
 
438
- function updateChatFollowToggleButton() {
439
- var button = document.getElementById("chat-follow-toggle");
440
- if (!button) return;
441
- var enabled = !!state.chatAutoFollow;
442
- button.classList.toggle("active", enabled);
443
- button.setAttribute("aria-pressed", enabled ? "true" : "false");
444
- button.setAttribute("title", enabled ? "追踪底部:开启(点击暂停)" : "追踪底部:已暂停(点击开启)");
445
- button.setAttribute("aria-label", enabled ? "追踪底部:开启" : "追踪底部:已暂停");
446
- button.innerHTML = enabled
447
- ? '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 2.5l4.5 4.5 4.5-4.5"/><path d="M3.5 8.5l4.5 4.5 4.5-4.5"/></svg>'
448
- : '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.5 3v10"/><path d="M10.5 3v10"/></svg>';
428
+ // 没有手动 toggle 了——是否贴底完全由用户的滚动行为决定。
429
+ // 这个函数只用来在某些场景(点未读气泡)下显式把状态扳回 true。
430
+ function setChatStickToBottom(enabled) {
431
+ state.chatStickToBottom = !!enabled;
432
+ if (state.chatStickToBottom) clearChatUnread({ removeDivider: true });
433
+ updateChatUnreadBubble();
449
434
  }
450
435
 
451
- function updateChatJumpToBottomButton() {
452
- var button = document.getElementById("chat-jump-bottom");
436
+ function clearChatUnread(options) {
437
+ options = options || {};
438
+ var hadUnread = state.chatUnreadCount > 0 || state.chatUnreadStartIndex >= 0;
439
+ state.chatUnreadCount = 0;
440
+ state.chatUnreadStartIndex = -1;
441
+ if (options.removeDivider !== false) {
442
+ var chatMsgs = getChatScrollElement();
443
+ if (chatMsgs) {
444
+ var divider = chatMsgs.querySelector(".chat-unread-divider");
445
+ if (divider && divider.parentNode) divider.parentNode.removeChild(divider);
446
+ }
447
+ }
448
+ if (hadUnread) updateChatUnreadBubble();
449
+ }
450
+
451
+ // 在 chatMessages 容器里把"未读分割线"放到正确位置——visually 在
452
+ // 最后一条已读和第一条未读中间。column-reverse 下 DOM[0] 是最新(视觉底部),
453
+ // 所以分割线在 DOM 里应该插到"第一条已读消息"之前。
454
+ function refreshChatUnreadDivider(chatMessages) {
455
+ if (!chatMessages) chatMessages = getChatScrollElement();
456
+ if (!chatMessages) return;
457
+ var existing = chatMessages.querySelector(".chat-unread-divider");
458
+ if (state.chatUnreadStartIndex < 0 || state.chatUnreadCount <= 0) {
459
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
460
+ return;
461
+ }
462
+ var startIdx = state.chatUnreadStartIndex;
463
+ // 找到 DOM 里第一条 originalIndex < startIdx 的消息——它紧邻分割线下方(DOM 顺序),
464
+ // 也就是视觉上紧贴在分割线"上方"(column-reverse)。
465
+ var nodes = chatMessages.querySelectorAll(".chat-message");
466
+ var boundary = null;
467
+ for (var i = 0; i < nodes.length; i++) {
468
+ var idxAttr = nodes[i].getAttribute("data-msg-index");
469
+ if (idxAttr === null) continue;
470
+ var idx = parseInt(idxAttr, 10);
471
+ if (!isNaN(idx) && idx < startIdx) { boundary = nodes[i]; break; }
472
+ }
473
+ // 没找到 boundary:未读消息覆盖了整个可见窗口——把分割线挂到末尾即可。
474
+ var label = state.chatUnreadCount + " 条新消息";
475
+ if (!existing) {
476
+ existing = document.createElement("div");
477
+ existing.className = "chat-unread-divider";
478
+ existing.setAttribute("role", "separator");
479
+ existing.innerHTML = '<span class="chat-unread-divider-line"></span>'
480
+ + '<span class="chat-unread-divider-label"></span>'
481
+ + '<span class="chat-unread-divider-line"></span>';
482
+ }
483
+ existing.querySelector(".chat-unread-divider-label").textContent = label;
484
+ if (boundary) {
485
+ if (existing.nextSibling !== boundary || existing.parentNode !== chatMessages) {
486
+ chatMessages.insertBefore(existing, boundary);
487
+ }
488
+ } else {
489
+ if (existing.parentNode !== chatMessages || existing.nextSibling !== null) {
490
+ chatMessages.appendChild(existing);
491
+ }
492
+ }
493
+ }
494
+
495
+ function updateChatUnreadBubble() {
496
+ var bubble = document.getElementById("chat-unread-bubble");
497
+ if (!bubble) return;
453
498
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
454
- var shouldShow = !!selectedSession
455
- && state.currentView === "chat"
456
- && !state.chatAutoFollow
457
- && !isChatNearBottom();
458
- state.showChatJumpToBottom = shouldShow;
459
- if (button) {
460
- button.classList.toggle("visible", shouldShow);
499
+ var notAtBottom = !isChatNearBottom();
500
+ // 显示条件:有选中会话 + chat 视图 + 用户已经滚开了底部。
501
+ // 不强制要求有未读——用户主动滚上去时也给一个"回到底部"的入口。
502
+ var shouldShow = !!selectedSession && state.currentView === "chat" && notAtBottom;
503
+ bubble.classList.toggle("visible", shouldShow);
504
+ bubble.classList.toggle("has-unread", state.chatUnreadCount > 0);
505
+ var countEl = bubble.querySelector(".chat-unread-bubble-count");
506
+ if (countEl) {
507
+ if (state.chatUnreadCount > 0) {
508
+ countEl.textContent = state.chatUnreadCount > 99 ? "99+" : String(state.chatUnreadCount);
509
+ countEl.classList.add("visible");
510
+ } else {
511
+ countEl.textContent = "";
512
+ countEl.classList.remove("visible");
513
+ }
461
514
  }
515
+ var label = state.chatUnreadCount > 0
516
+ ? (state.chatUnreadCount + " 条新消息,点击查看")
517
+ : "回到最新消息";
518
+ bubble.setAttribute("aria-label", label);
519
+ bubble.setAttribute("title", label);
462
520
  var chatContainer = document.getElementById("chat-output");
463
521
  if (chatContainer) chatContainer.classList.toggle("has-jump-btn", shouldShow);
464
522
  }
@@ -467,38 +525,26 @@
467
525
  var chatMsgs = getChatScrollElement();
468
526
  if (!chatMsgs || !chatMsgs.isConnected) return;
469
527
  state.chatIsProgrammaticScroll = true;
528
+ var done = function() {
529
+ state.chatIsProgrammaticScroll = false;
530
+ state.chatStickToBottom = true;
531
+ clearChatUnread({ removeDivider: true });
532
+ updateChatUnreadBubble();
533
+ };
470
534
  if (smooth && typeof chatMsgs.scrollTo === "function") {
471
535
  chatMsgs.scrollTo({ top: 0, behavior: "smooth" });
472
- setTimeout(function() {
473
- state.chatIsProgrammaticScroll = false;
474
- updateChatJumpToBottomButton();
475
- }, 220);
536
+ setTimeout(done, 260);
476
537
  return;
477
538
  }
478
539
  chatMsgs.scrollTop = 0;
479
- requestAnimationFrame(function() {
480
- state.chatIsProgrammaticScroll = false;
481
- updateChatJumpToBottomButton();
482
- });
483
- }
484
-
485
- function setChatAutoFollow(enabled, options) {
486
- options = options || {};
487
- state.chatAutoFollow = !!enabled;
488
- persistChatAutoFollow();
489
- updateChatFollowToggleButton();
490
- if (state.chatAutoFollow && options.scrollNow !== false) {
491
- scrollChatToBottom(!!options.smooth);
492
- } else {
493
- updateChatJumpToBottomButton();
494
- }
540
+ requestAnimationFrame(done);
495
541
  }
496
542
 
497
543
  function bindChatScrollListener() {
498
544
  var chatMsgs = getChatScrollElement();
499
545
  if (!chatMsgs || !chatMsgs.isConnected) return;
500
546
  if (state.chatScrollElement === chatMsgs && state.chatScrollHandler) {
501
- updateChatJumpToBottomButton();
547
+ updateChatUnreadBubble();
502
548
  return;
503
549
  }
504
550
  if (state.chatScrollElement && state.chatScrollHandler) {
@@ -507,22 +553,24 @@
507
553
  state.chatScrollElement = chatMsgs;
508
554
  state.chatScrollHandler = function() {
509
555
  if (!chatMsgs.isConnected) return;
556
+ // 程序触发的滚动(点了气泡)不算"用户翻页"——别把状态弄乱。
510
557
  if (state.chatIsProgrammaticScroll) {
511
- updateChatJumpToBottomButton();
558
+ updateChatUnreadBubble();
512
559
  return;
513
560
  }
514
- if (!isChatNearBottom(chatMsgs)) {
515
- if (state.chatAutoFollow) {
516
- setChatAutoFollow(false, { scrollNow: false });
517
- } else {
518
- updateChatJumpToBottomButton();
519
- }
520
- return;
561
+ var atBottom = isChatNearBottom(chatMsgs);
562
+ if (atBottom) {
563
+ // 用户自己滚到底了——清未读、贴回底部、撤下气泡。
564
+ state.chatStickToBottom = true;
565
+ clearChatUnread({ removeDivider: true });
566
+ } else {
567
+ // 用户主动往上翻——脱离贴底状态。新消息只会累积到气泡,不滚视图。
568
+ state.chatStickToBottom = false;
521
569
  }
522
- updateChatJumpToBottomButton();
570
+ updateChatUnreadBubble();
523
571
  };
524
572
  chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
525
- updateChatJumpToBottomButton();
573
+ updateChatUnreadBubble();
526
574
  }
527
575
 
528
576
  /** Load older messages by expanding the visible window */
@@ -865,8 +913,11 @@
865
913
  }
866
914
  state.chatScrollElement = null;
867
915
  state.chatScrollHandler = null;
868
- state.showChatJumpToBottom = false;
869
916
  state.chatIsProgrammaticScroll = false;
917
+ // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
918
+ state.chatStickToBottom = true;
919
+ state.chatUnreadCount = 0;
920
+ state.chatUnreadStartIndex = -1;
870
921
  }
871
922
 
872
923
  function getEffectiveCwd() {
@@ -1388,10 +1439,10 @@
1388
1439
  '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部" aria-label="回到底部"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></button>' +
1389
1440
  '</div>' +
1390
1441
  '<div id="chat-output" class="chat-container hidden">' +
1391
- '<div class="chat-overlay-controls">' +
1392
- '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" aria-label="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启(点击暂停)' : '追踪底部:已暂停(点击开启)') + '">' + (state.chatAutoFollow ? '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 2.5l4.5 4.5 4.5-4.5"/><path d="M3.5 8.5l4.5 4.5 4.5-4.5"/></svg>' : '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.5 3v10"/><path d="M10.5 3v10"/></svg>') + '</button>' +
1393
- '</div>' +
1394
- '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底" aria-label="回到底部"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></button>' +
1442
+ '<button id="chat-unread-bubble" class="chat-unread-bubble" type="button" title="回到最新消息" aria-label="回到最新消息">' +
1443
+ '<span class="chat-unread-bubble-icon"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></span>' +
1444
+ '<span class="chat-unread-bubble-count" aria-hidden="true"></span>' +
1445
+ '</button>' +
1395
1446
  '</div>' +
1396
1447
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
1397
1448
  '<div class="blank-chat-inner">' +
@@ -1423,15 +1474,22 @@
1423
1474
  '<div class="composer-top-row">' +
1424
1475
  '<div id="todo-progress" class="todo-progress hidden">' +
1425
1476
  '<div class="todo-progress-header" id="todo-progress-toggle">' +
1426
- '<span class="todo-progress-spinner" aria-hidden="true"></span>' +
1427
- '<span class="todo-progress-message" id="todo-progress-message"></span>' +
1428
- '<span class="todo-progress-status" id="todo-progress-status">执行中</span>' +
1429
- '<svg class="todo-progress-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>' +
1430
- '</div>' +
1431
- '<div class="todo-progress-body hidden" id="todo-progress-body">' +
1432
- '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1477
+ '<div class="todo-progress-left">' +
1478
+ '<span class="todo-progress-ring" id="todo-progress-ring" aria-hidden="true" style="--progress:0">' +
1479
+ '<svg width="16" height="16" viewBox="0 0 36 36">' +
1480
+ '<circle class="todo-ring-track" cx="18" cy="18" r="15.5" fill="none" stroke-width="4"/>' +
1481
+ '<circle class="todo-ring-fill" cx="18" cy="18" r="15.5" fill="none" stroke-width="4" stroke-linecap="round"/>' +
1482
+ '</svg>' +
1483
+ '</span>' +
1484
+ '<span class="todo-progress-counter" id="todo-progress-counter"></span>' +
1485
+ '<span class="todo-progress-task" id="todo-progress-task"></span>' +
1486
+ '</div>' +
1487
+ '<svg class="todo-progress-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>' +
1433
1488
  '</div>' +
1434
1489
  '</div>' +
1490
+ '<div class="todo-progress-body hidden" id="todo-progress-body">' +
1491
+ '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1492
+ '</div>' +
1435
1493
  '</div>' +
1436
1494
  '<div class="input-composer">' +
1437
1495
  '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
@@ -5080,17 +5138,10 @@
5080
5138
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
5081
5139
  maybeScrollTerminalToBottom("force");
5082
5140
  });
5083
- var chatFollowToggle = document.getElementById("chat-follow-toggle");
5084
- if (chatFollowToggle) chatFollowToggle.addEventListener("click", function() {
5085
- if (state.chatAutoFollow) {
5086
- setChatAutoFollow(false, { scrollNow: false });
5087
- } else {
5088
- setChatAutoFollow(true, { scrollNow: true, smooth: false });
5089
- }
5090
- });
5091
- var chatJumpBottomBtn = document.getElementById("chat-jump-bottom");
5092
- if (chatJumpBottomBtn) chatJumpBottomBtn.addEventListener("click", function() {
5093
- setChatAutoFollow(true, { scrollNow: true, smooth: true });
5141
+ // 未读气泡:点一下就贴回最新消息,顺手清掉未读分割线和计数。
5142
+ var chatUnreadBubble = document.getElementById("chat-unread-bubble");
5143
+ if (chatUnreadBubble) chatUnreadBubble.addEventListener("click", function() {
5144
+ scrollChatToBottom(true);
5094
5145
  });
5095
5146
  var fileRefresh = document.getElementById("file-explorer-refresh");
5096
5147
  if (fileRefresh) fileRefresh.addEventListener("click", function() { refreshFileExplorer(); });
@@ -7118,8 +7169,7 @@
7118
7169
  ensureChatMessagesContainer(chatContainer);
7119
7170
  }
7120
7171
  bindChatScrollListener();
7121
- updateChatFollowToggleButton();
7122
- updateChatJumpToBottomButton();
7172
+ updateChatUnreadBubble();
7123
7173
  updateInteractiveControls();
7124
7174
  }
7125
7175
 
@@ -7588,6 +7638,14 @@
7588
7638
  // Reset todo progress bar
7589
7639
  var todoEl = document.getElementById("todo-progress");
7590
7640
  if (todoEl) todoEl.classList.add("hidden");
7641
+ // 同时清掉上一会话残留的 "回复中 N.Ns" 状态条以及它的计时器/glow。
7642
+ // 不清就会出现:切到新建的空会话,底部仍显示前一会话的 todolist + 回复中。
7643
+ var staleStatusBar = document.querySelector(".structured-status-bar");
7644
+ if (staleStatusBar) staleStatusBar.remove();
7645
+ if (_statusBarTimerId) { clearInterval(_statusBarTimerId); _statusBarTimerId = null; }
7646
+ _statusBarStartTime = 0;
7647
+ var staleComposer = document.querySelector(".input-composer");
7648
+ if (staleComposer) staleComposer.classList.remove("in-flight");
7591
7649
  var session = foundSession;
7592
7650
  state.preferredCommand = getPreferredTool();
7593
7651
  state.chatMode = getSafeModeForTool("claude", session && session.mode ? session.mode : state.chatMode);
@@ -13768,8 +13826,7 @@
13768
13826
  if (!chatMessages) return null;
13769
13827
  chatMessages.innerHTML = html;
13770
13828
  bindChatScrollListener();
13771
- updateChatFollowToggleButton();
13772
- updateChatJumpToBottomButton();
13829
+ updateChatUnreadBubble();
13773
13830
  return chatMessages;
13774
13831
  }
13775
13832
 
@@ -13795,13 +13852,18 @@
13795
13852
  state.lastRenderedEmpty = "empty";
13796
13853
  state.lastRenderedMsgCount = 0;
13797
13854
  }
13855
+ // 空会话进入空状态前,把上一会话残留的状态条 / todo 进度条清掉。
13856
+ // 这里是 selectSession 之外的兜底:WS init 等异步路径也会落到这条空分支。
13857
+ renderStructuredStatusBar(null, selectedSession);
13858
+ updateTodoProgress([]);
13798
13859
  return;
13799
13860
  }
13800
13861
 
13801
13862
  // Lazy loading: only render the most recent chatRenderedCount messages.
13802
- // Auto-expand when new messages arrive during active streaming to avoid hiding them.
13863
+ // 新消息进来时永远展开渲染窗口,避免用户正在看的旧消息被挤进"加载更早"里——
13864
+ // Telegram 风格下我们不主动挪用户的视线,最稳妥的办法就是别让他看的那条消失。
13803
13865
  var totalMsgCount = allMessages.length;
13804
- if (totalMsgCount > state.chatRenderedCount && state.chatAutoFollow) {
13866
+ if (totalMsgCount > state.chatRenderedCount) {
13805
13867
  state.chatRenderedCount = totalMsgCount;
13806
13868
  }
13807
13869
  var visibleOffset = Math.max(0, totalMsgCount - state.chatRenderedCount);
@@ -13857,6 +13919,13 @@
13857
13919
  var chatMessages = ensureChatMessagesContainer(chatOutput);
13858
13920
  if (!chatMessages) return;
13859
13921
 
13922
+ // 在动 DOM 之前先看用户是不是贴在底部——这决定后面我们要不要让视图
13923
+ // "继续粘在底部"。column-reverse 下 scrollTop 接近 0 = 视觉底部。
13924
+ // 同时把 state.chatStickToBottom 同步到当前真实状态,避免长时间不滚动后
13925
+ // 的状态漂移(比如新会话 init 的瞬间)。
13926
+ var renderWasAtBottom = isChatNearBottom(chatMessages);
13927
+ if (renderWasAtBottom) state.chatStickToBottom = true;
13928
+
13860
13929
  var existingCount = chatMessages.querySelectorAll(".chat-message").length;
13861
13930
  // Full render when: forced, no existing messages, or message count decreased/changed
13862
13931
  var needsFullRender = forceRender || existingCount === 0 || msgCount !== existingCount;
@@ -13906,16 +13975,29 @@
13906
13975
  }
13907
13976
 
13908
13977
  chatMessages.innerHTML = html;
13978
+ // 给每条消息打 data-msg-index(用 state.currentMessages 的全局索引),
13979
+ // 后面 refreshChatUnreadDivider 用它找未读分割线的位置。
13980
+ (function() {
13981
+ var msgEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
13982
+ // column-reverse: DOM[0] = 最新(最高 originalIndex)
13983
+ var totalVisible = msgEls.length;
13984
+ for (var idx = 0; idx < totalVisible; idx++) {
13985
+ msgEls[idx].setAttribute("data-msg-index", String(visibleOffset + totalVisible - 1 - idx));
13986
+ }
13987
+ })();
13909
13988
  // 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
13910
13989
  // 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
13911
13990
  // 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
13912
13991
  // prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
13913
13992
  // 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
13914
- // 注意:仅在"换会话/初次渲染"时强制重置;同一会话内的全量重渲染
13915
- // (prevMsgCount > 0,比如 forceFullRender 或消息数减少)继续走
13916
- // smartScrollToBottom,尊重用户的 chatAutoFollow 选择。
13917
13993
  if (prevMsgCount === 0) {
13918
13994
  chatMessages.scrollTop = 0;
13995
+ state.chatStickToBottom = true;
13996
+ clearChatUnread({ removeDivider: true });
13997
+ } else if (renderWasAtBottom) {
13998
+ // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
13999
+ // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
14000
+ chatMessages.scrollTop = 0;
13919
14001
  }
13920
14002
  attachAllCopyHandlers(chatMessages);
13921
14003
  bindChatScrollListener();
@@ -13940,9 +14022,13 @@
13940
14022
  }
13941
14023
  }
13942
14024
  }
13943
- // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
14025
+ // 不主动 smartScrollToBottom——同一会话的全量重渲染要么是
14026
+ // streaming fallback(页面位置应保持),要么是 msgCount 减少(极少见,
14027
+ // 走 prevMsgCount===0 那条分支已经处理)。让浏览器自带的 scroll
14028
+ // anchoring 接手,避免在用户阅读时把视图拽走。
13944
14029
  requestAnimationFrame(function() {
13945
- smartScrollToBottom(chatMessages);
14030
+ refreshChatUnreadDivider(chatMessages);
14031
+ updateChatUnreadBubble();
13946
14032
  observeLoadMoreSentinel();
13947
14033
  });
13948
14034
  }
@@ -14017,6 +14103,11 @@
14017
14103
  newMessages.reverse();
14018
14104
  var fragment = document.createDocumentFragment();
14019
14105
  var insertedEls = [];
14106
+ // 记录每条新消息的 originalIndex,方便后面打标签 / 计算未读起点。
14107
+ var insertedOrigIdx = [];
14108
+ // 第一条新消息(数组里 index 最小的,时间上最早的那条)对应的全局索引——
14109
+ // 用作未读起点。
14110
+ var firstNewOrigIdx = visibleOffset + existingCount;
14020
14111
  for (var i = 0; i < newMessages.length; i++) {
14021
14112
  var div = document.createElement("div");
14022
14113
  var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
@@ -14024,7 +14115,9 @@
14024
14115
  var el = div.firstElementChild;
14025
14116
  if (el) {
14026
14117
  el.classList.add("animate-in");
14118
+ el.setAttribute("data-msg-index", String(nmOrigIdx));
14027
14119
  insertedEls.push(el);
14120
+ insertedOrigIdx.push(nmOrigIdx);
14028
14121
  fragment.appendChild(el);
14029
14122
  }
14030
14123
  }
@@ -14034,10 +14127,31 @@
14034
14127
  applyPersistedExpandState(chatMessages);
14035
14128
  // Collapse all existing cards; new cards (with animate-in) stay expanded
14036
14129
  collapseOldToolCards(chatMessages, insertedEls);
14037
- // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
14038
- requestAnimationFrame(function() {
14039
- smartScrollToBottom(chatMessages);
14040
- });
14130
+ // Telegram 行为:
14131
+ // - 用户原本就贴在底部 → 维持贴底(column-reverse 通常会自动留在底部,
14132
+ // 但浏览器的 scroll anchoring 在某些边界场景会把 scrollTop 调成非 0;
14133
+ // 这里显式拉回 0 做兜底,不用动画,不会让用户感觉"被甩")。
14134
+ // - 用户已经滚上去 → 一根毛都不动他的视图,只把未读累到气泡里。
14135
+ if (renderWasAtBottom) {
14136
+ requestAnimationFrame(function() {
14137
+ if (chatMessages.isConnected && Math.abs(chatMessages.scrollTop) > 1) {
14138
+ state.chatIsProgrammaticScroll = true;
14139
+ chatMessages.scrollTop = 0;
14140
+ requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
14141
+ }
14142
+ // 视为已读 —— 用户当前就在底部看着,这些新消息直接进入"已读"。
14143
+ clearChatUnread({ removeDivider: true });
14144
+ updateChatUnreadBubble();
14145
+ });
14146
+ } else {
14147
+ // 累计未读。如果之前没有未读,就用这一批的最早一条做分割线起点。
14148
+ if (state.chatUnreadStartIndex < 0) {
14149
+ state.chatUnreadStartIndex = firstNewOrigIdx;
14150
+ }
14151
+ state.chatUnreadCount += insertedEls.length;
14152
+ refreshChatUnreadDivider(chatMessages);
14153
+ updateChatUnreadBubble();
14154
+ }
14041
14155
  } else if (msgCount === existingCount && outputHash !== prevHash) {
14042
14156
  // Same message count but content changed (streaming update).
14043
14157
  // Optimization: only re-render the newest N messages (column-reverse: first children)
@@ -14072,8 +14186,18 @@
14072
14186
  if (replacedAny) {
14073
14187
  bindChatScrollListener();
14074
14188
  applyPersistedExpandState(chatMessages);
14189
+ // Streaming 更新只是改最新一条的内容,不改条数。column-reverse 下
14190
+ // 浏览器的 scroll anchoring 会自动保持视觉位置;用户贴底时新内容
14191
+ // 自然出现在底部,用户上滚时视图也不受打扰——不需要再 smartScroll。
14075
14192
  requestAnimationFrame(function() {
14076
- smartScrollToBottom(chatMessages);
14193
+ // 兜底:用户贴底时如果浏览器把 scrollTop 调成非零,拉回来。
14194
+ if (renderWasAtBottom && chatMessages.isConnected && Math.abs(chatMessages.scrollTop) > 1) {
14195
+ state.chatIsProgrammaticScroll = true;
14196
+ chatMessages.scrollTop = 0;
14197
+ requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
14198
+ }
14199
+ refreshChatUnreadDivider(chatMessages);
14200
+ updateChatUnreadBubble();
14077
14201
  });
14078
14202
  var newestMsgEl = chatMessages.querySelector(".chat-message");
14079
14203
  var allCards = chatMessages.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
@@ -14102,19 +14226,11 @@
14102
14226
  updateTodoProgress(allMessages);
14103
14227
  }
14104
14228
 
14105
- // Smart scroll: only auto-scroll if user is near bottom
14106
- // column-reverse: scrollTop near 0 = visual bottom (newest messages)
14107
- function smartScrollToBottom(container) {
14108
- if (!state.chatAutoFollow) {
14109
- updateChatJumpToBottomButton();
14110
- return;
14111
- }
14112
- var chatMsgs = (container && container.classList && container.classList.contains("chat-messages"))
14113
- ? container
14114
- : getChatScrollElement();
14115
- if (!chatMsgs || !chatMsgs.isConnected) return;
14116
- scrollChatToBottom(false);
14117
- }
14229
+ // 注:旧版的 smartScrollToBottom / chatAutoFollow / chat-follow-toggle 都已经
14230
+ // 拆掉,改成 Telegram 风格:贴底状态完全由用户的滚动行为驱动,未读靠
14231
+ // chat-unread-bubble 气泡提示,不再主动滚动用户的视图。
14232
+ // 相关入口:scrollChatToBottom(用户点气泡时强制贴底)、
14233
+ // refreshChatUnreadDivider(分割线渲染)、updateChatUnreadBubble(气泡 UI)。
14118
14234
 
14119
14235
  // --- Todo progress bar ---
14120
14236
  var todoExpanded = false;
@@ -14132,10 +14248,10 @@
14132
14248
  if (prog && body) {
14133
14249
  if (todoExpanded) {
14134
14250
  prog.classList.add("expanded");
14135
- body.classList.remove("hidden");
14251
+ body.classList.add("expanded");
14136
14252
  } else {
14137
14253
  prog.classList.remove("expanded");
14138
- body.classList.add("hidden");
14254
+ body.classList.remove("expanded");
14139
14255
  }
14140
14256
  }
14141
14257
  });
@@ -14157,14 +14273,17 @@
14157
14273
  }
14158
14274
 
14159
14275
  var container = document.getElementById("todo-progress");
14276
+ var bodyEl = document.getElementById("todo-progress-body");
14160
14277
  if (!container) return;
14161
14278
 
14162
14279
  if (!todos || todos.length === 0) {
14163
14280
  container.classList.add("hidden");
14281
+ if (bodyEl) bodyEl.classList.add("hidden");
14164
14282
  return;
14165
14283
  }
14166
14284
 
14167
14285
  container.classList.remove("hidden");
14286
+ if (bodyEl) bodyEl.classList.remove("hidden");
14168
14287
 
14169
14288
  var completed = 0;
14170
14289
  var inProgress = 0;
@@ -14186,6 +14305,7 @@
14186
14305
  if (allDone) {
14187
14306
  // Hide todo when all tasks are completed
14188
14307
  container.classList.add("hidden");
14308
+ if (bodyEl) bodyEl.classList.add("hidden");
14189
14309
  return;
14190
14310
  } else {
14191
14311
  container.classList.remove("all-done");
@@ -14197,6 +14317,14 @@
14197
14317
  var task = document.getElementById("todo-progress-task");
14198
14318
  if (task) task.textContent = activeTask;
14199
14319
 
14320
+ // Drive the circular progress ring with the honest "completed / total" fraction
14321
+ // (counter text shows the 1-indexed current step, ring shows actual done ratio).
14322
+ var ring = document.getElementById("todo-progress-ring");
14323
+ if (ring) {
14324
+ var ratio = todos.length > 0 ? completed / todos.length : 0;
14325
+ ring.style.setProperty("--progress", ratio.toFixed(3));
14326
+ }
14327
+
14200
14328
  // Render expanded list
14201
14329
  var list = document.getElementById("todo-progress-list");
14202
14330
  if (list) {