@co0ontty/wand 1.23.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;
426
+ }
427
+
428
+ // 没有手动 toggle 了——是否贴底完全由用户的滚动行为决定。
429
+ // 这个函数只用来在某些场景(点未读气泡)下显式把状态扳回 true。
430
+ function setChatStickToBottom(enabled) {
431
+ state.chatStickToBottom = !!enabled;
432
+ if (state.chatStickToBottom) clearChatUnread({ removeDivider: true });
433
+ updateChatUnreadBubble();
436
434
  }
437
435
 
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>';
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
449
  }
450
450
 
451
- function updateChatJumpToBottomButton() {
452
- var button = document.getElementById("chat-jump-bottom");
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">' +
@@ -5087,17 +5138,10 @@
5087
5138
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
5088
5139
  maybeScrollTerminalToBottom("force");
5089
5140
  });
5090
- var chatFollowToggle = document.getElementById("chat-follow-toggle");
5091
- if (chatFollowToggle) chatFollowToggle.addEventListener("click", function() {
5092
- if (state.chatAutoFollow) {
5093
- setChatAutoFollow(false, { scrollNow: false });
5094
- } else {
5095
- setChatAutoFollow(true, { scrollNow: true, smooth: false });
5096
- }
5097
- });
5098
- var chatJumpBottomBtn = document.getElementById("chat-jump-bottom");
5099
- if (chatJumpBottomBtn) chatJumpBottomBtn.addEventListener("click", function() {
5100
- 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);
5101
5145
  });
5102
5146
  var fileRefresh = document.getElementById("file-explorer-refresh");
5103
5147
  if (fileRefresh) fileRefresh.addEventListener("click", function() { refreshFileExplorer(); });
@@ -7125,8 +7169,7 @@
7125
7169
  ensureChatMessagesContainer(chatContainer);
7126
7170
  }
7127
7171
  bindChatScrollListener();
7128
- updateChatFollowToggleButton();
7129
- updateChatJumpToBottomButton();
7172
+ updateChatUnreadBubble();
7130
7173
  updateInteractiveControls();
7131
7174
  }
7132
7175
 
@@ -13783,8 +13826,7 @@
13783
13826
  if (!chatMessages) return null;
13784
13827
  chatMessages.innerHTML = html;
13785
13828
  bindChatScrollListener();
13786
- updateChatFollowToggleButton();
13787
- updateChatJumpToBottomButton();
13829
+ updateChatUnreadBubble();
13788
13830
  return chatMessages;
13789
13831
  }
13790
13832
 
@@ -13818,9 +13860,10 @@
13818
13860
  }
13819
13861
 
13820
13862
  // Lazy loading: only render the most recent chatRenderedCount messages.
13821
- // Auto-expand when new messages arrive during active streaming to avoid hiding them.
13863
+ // 新消息进来时永远展开渲染窗口,避免用户正在看的旧消息被挤进"加载更早"里——
13864
+ // Telegram 风格下我们不主动挪用户的视线,最稳妥的办法就是别让他看的那条消失。
13822
13865
  var totalMsgCount = allMessages.length;
13823
- if (totalMsgCount > state.chatRenderedCount && state.chatAutoFollow) {
13866
+ if (totalMsgCount > state.chatRenderedCount) {
13824
13867
  state.chatRenderedCount = totalMsgCount;
13825
13868
  }
13826
13869
  var visibleOffset = Math.max(0, totalMsgCount - state.chatRenderedCount);
@@ -13876,6 +13919,13 @@
13876
13919
  var chatMessages = ensureChatMessagesContainer(chatOutput);
13877
13920
  if (!chatMessages) return;
13878
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
+
13879
13929
  var existingCount = chatMessages.querySelectorAll(".chat-message").length;
13880
13930
  // Full render when: forced, no existing messages, or message count decreased/changed
13881
13931
  var needsFullRender = forceRender || existingCount === 0 || msgCount !== existingCount;
@@ -13925,16 +13975,29 @@
13925
13975
  }
13926
13976
 
13927
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
+ })();
13928
13988
  // 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
13929
13989
  // 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
13930
13990
  // 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
13931
13991
  // prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
13932
13992
  // 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
13933
- // 注意:仅在"换会话/初次渲染"时强制重置;同一会话内的全量重渲染
13934
- // (prevMsgCount > 0,比如 forceFullRender 或消息数减少)继续走
13935
- // smartScrollToBottom,尊重用户的 chatAutoFollow 选择。
13936
13993
  if (prevMsgCount === 0) {
13937
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;
13938
14001
  }
13939
14002
  attachAllCopyHandlers(chatMessages);
13940
14003
  bindChatScrollListener();
@@ -13959,9 +14022,13 @@
13959
14022
  }
13960
14023
  }
13961
14024
  }
13962
- // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
14025
+ // 不主动 smartScrollToBottom——同一会话的全量重渲染要么是
14026
+ // streaming fallback(页面位置应保持),要么是 msgCount 减少(极少见,
14027
+ // 走 prevMsgCount===0 那条分支已经处理)。让浏览器自带的 scroll
14028
+ // anchoring 接手,避免在用户阅读时把视图拽走。
13963
14029
  requestAnimationFrame(function() {
13964
- smartScrollToBottom(chatMessages);
14030
+ refreshChatUnreadDivider(chatMessages);
14031
+ updateChatUnreadBubble();
13965
14032
  observeLoadMoreSentinel();
13966
14033
  });
13967
14034
  }
@@ -14036,6 +14103,11 @@
14036
14103
  newMessages.reverse();
14037
14104
  var fragment = document.createDocumentFragment();
14038
14105
  var insertedEls = [];
14106
+ // 记录每条新消息的 originalIndex,方便后面打标签 / 计算未读起点。
14107
+ var insertedOrigIdx = [];
14108
+ // 第一条新消息(数组里 index 最小的,时间上最早的那条)对应的全局索引——
14109
+ // 用作未读起点。
14110
+ var firstNewOrigIdx = visibleOffset + existingCount;
14039
14111
  for (var i = 0; i < newMessages.length; i++) {
14040
14112
  var div = document.createElement("div");
14041
14113
  var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
@@ -14043,7 +14115,9 @@
14043
14115
  var el = div.firstElementChild;
14044
14116
  if (el) {
14045
14117
  el.classList.add("animate-in");
14118
+ el.setAttribute("data-msg-index", String(nmOrigIdx));
14046
14119
  insertedEls.push(el);
14120
+ insertedOrigIdx.push(nmOrigIdx);
14047
14121
  fragment.appendChild(el);
14048
14122
  }
14049
14123
  }
@@ -14053,10 +14127,31 @@
14053
14127
  applyPersistedExpandState(chatMessages);
14054
14128
  // Collapse all existing cards; new cards (with animate-in) stay expanded
14055
14129
  collapseOldToolCards(chatMessages, insertedEls);
14056
- // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
14057
- requestAnimationFrame(function() {
14058
- smartScrollToBottom(chatMessages);
14059
- });
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
+ }
14060
14155
  } else if (msgCount === existingCount && outputHash !== prevHash) {
14061
14156
  // Same message count but content changed (streaming update).
14062
14157
  // Optimization: only re-render the newest N messages (column-reverse: first children)
@@ -14091,8 +14186,18 @@
14091
14186
  if (replacedAny) {
14092
14187
  bindChatScrollListener();
14093
14188
  applyPersistedExpandState(chatMessages);
14189
+ // Streaming 更新只是改最新一条的内容,不改条数。column-reverse 下
14190
+ // 浏览器的 scroll anchoring 会自动保持视觉位置;用户贴底时新内容
14191
+ // 自然出现在底部,用户上滚时视图也不受打扰——不需要再 smartScroll。
14094
14192
  requestAnimationFrame(function() {
14095
- 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();
14096
14201
  });
14097
14202
  var newestMsgEl = chatMessages.querySelector(".chat-message");
14098
14203
  var allCards = chatMessages.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
@@ -14121,19 +14226,11 @@
14121
14226
  updateTodoProgress(allMessages);
14122
14227
  }
14123
14228
 
14124
- // Smart scroll: only auto-scroll if user is near bottom
14125
- // column-reverse: scrollTop near 0 = visual bottom (newest messages)
14126
- function smartScrollToBottom(container) {
14127
- if (!state.chatAutoFollow) {
14128
- updateChatJumpToBottomButton();
14129
- return;
14130
- }
14131
- var chatMsgs = (container && container.classList && container.classList.contains("chat-messages"))
14132
- ? container
14133
- : getChatScrollElement();
14134
- if (!chatMsgs || !chatMsgs.isConnected) return;
14135
- scrollChatToBottom(false);
14136
- }
14229
+ // 注:旧版的 smartScrollToBottom / chatAutoFollow / chat-follow-toggle 都已经
14230
+ // 拆掉,改成 Telegram 风格:贴底状态完全由用户的滚动行为驱动,未读靠
14231
+ // chat-unread-bubble 气泡提示,不再主动滚动用户的视图。
14232
+ // 相关入口:scrollChatToBottom(用户点气泡时强制贴底)、
14233
+ // refreshChatUnreadDivider(分割线渲染)、updateChatUnreadBubble(气泡 UI)。
14137
14234
 
14138
14235
  // --- Todo progress bar ---
14139
14236
  var todoExpanded = false;
@@ -3268,159 +3268,190 @@
3268
3268
  background: linear-gradient(180deg, rgba(255, 252, 248, 0.5) 0%, transparent 100%);
3269
3269
  }
3270
3270
 
3271
- .chat-overlay-controls {
3271
+ /* ===== Telegram 风格的"回到最新"胶囊 =====
3272
+ 规则:
3273
+ - 用户贴在底部时 → 胶囊隐藏,新消息自然出现。
3274
+ - 用户滚上去时 → 胶囊浮现在 chat-container 右下角,正好压在输入框上方边缘。
3275
+ - 没有未读时 → 紧凑圆形小胶囊,只显示一个回到底部的箭头。
3276
+ - 有未读时 → 胶囊横向展开成"↓ N 条新消息",行内显示数字。
3277
+ - 点胶囊 → 平滑滚回最新消息,未读分割线消失。 */
3278
+ .chat-unread-bubble {
3272
3279
  position: absolute;
3273
- top: 10px;
3274
- right: 10px;
3275
- display: inline-flex;
3276
- align-items: center;
3277
- z-index: 12;
3278
- pointer-events: auto;
3279
- opacity: 0;
3280
- transform: translateY(-4px);
3281
- transition: opacity 0.22s ease, transform 0.26s var(--ease-out-expo);
3282
- }
3283
-
3284
- .chat-container:hover .chat-overlay-controls,
3285
- .chat-overlay-controls:focus-within {
3286
- opacity: 1;
3287
- transform: translateY(0);
3288
- }
3289
-
3290
- .chat-follow-toggle {
3280
+ right: 14px;
3281
+ bottom: 10px;
3291
3282
  display: inline-flex;
3292
3283
  align-items: center;
3293
3284
  justify-content: center;
3294
- min-width: unset;
3295
- padding: 6px 11px;
3296
- color: var(--text-tertiary);
3297
- background: rgba(255, 250, 242, 0.7);
3298
- backdrop-filter: blur(12px);
3299
- -webkit-backdrop-filter: blur(12px);
3300
- border: 1px solid rgba(125, 91, 57, 0.1);
3285
+ gap: 6px;
3286
+ height: 32px;
3287
+ min-width: 32px;
3288
+ padding: 0 10px;
3289
+ box-sizing: border-box;
3290
+ border: 1px solid rgba(255, 255, 255, 0.72);
3301
3291
  border-radius: 999px;
3302
- box-shadow: 0 1px 4px rgba(89, 58, 32, 0.06);
3303
- cursor: pointer;
3304
- transition: all 0.2s ease;
3305
- }
3306
-
3307
- .chat-follow-toggle svg {
3308
- flex-shrink: 0;
3309
- opacity: 0.7;
3310
- transition: opacity 0.18s ease, transform 0.2s var(--ease-out-expo);
3311
- }
3312
-
3313
- .chat-follow-toggle.active {
3314
- color: var(--accent);
3315
- background: rgba(255, 250, 242, 0.88);
3316
- border-color: rgba(197, 101, 61, 0.18);
3317
- box-shadow:
3318
- 0 1px 4px rgba(89, 58, 32, 0.08),
3319
- 0 0 0 1px rgba(197, 101, 61, 0.06);
3320
- }
3321
-
3322
- .chat-follow-toggle.active svg {
3323
- opacity: 0.85;
3324
- stroke: var(--accent);
3325
- }
3326
-
3327
- .chat-follow-toggle:hover {
3328
- color: var(--text-primary);
3329
3292
  background: rgba(255, 252, 248, 0.92);
3330
- border-color: rgba(125, 91, 57, 0.16);
3331
- box-shadow: 0 2px 8px rgba(89, 58, 32, 0.1);
3332
- }
3333
-
3334
- .chat-follow-toggle:hover svg {
3335
- opacity: 0.8;
3336
- }
3337
-
3338
- .chat-follow-toggle.active:hover {
3339
- color: var(--accent-hover);
3340
- background: rgba(255, 248, 240, 0.94);
3341
- border-color: rgba(197, 101, 61, 0.24);
3342
- box-shadow: 0 2px 8px rgba(184, 92, 55, 0.12);
3343
- }
3344
-
3345
- .chat-follow-toggle.active:hover svg {
3346
- stroke: var(--accent-hover);
3347
- transform: translateY(1px);
3348
- }
3349
-
3350
- .chat-follow-toggle:active {
3351
- transform: scale(0.94);
3352
- transition-duration: 0.06s;
3353
- }
3354
-
3355
- .chat-jump-bottom {
3356
- position: absolute;
3357
- right: 14px;
3358
- bottom: 16px;
3359
- display: inline-flex;
3360
- align-items: center;
3361
- justify-content: center;
3362
- width: 34px;
3363
- height: 34px;
3364
- padding: 0;
3365
- border: 1px solid rgba(255, 255, 255, 0.65);
3366
- border-radius: 50%;
3367
- background: var(--accent);
3368
- color: #fff;
3293
+ color: var(--accent);
3294
+ font-size: 0.78rem;
3295
+ font-weight: 600;
3296
+ line-height: 1;
3297
+ letter-spacing: 0.01em;
3298
+ white-space: nowrap;
3299
+ backdrop-filter: blur(14px);
3300
+ -webkit-backdrop-filter: blur(14px);
3369
3301
  box-shadow:
3370
- 0 1px 2px rgba(89, 58, 32, 0.18),
3371
- 0 8px 22px rgba(184, 92, 55, 0.28),
3372
- inset 0 1px 0 rgba(255, 255, 255, 0.25);
3302
+ 0 1px 3px rgba(89, 58, 32, 0.12),
3303
+ 0 10px 24px rgba(184, 92, 55, 0.18),
3304
+ inset 0 1px 0 rgba(255, 255, 255, 0.6);
3373
3305
  cursor: pointer;
3374
3306
  z-index: 13;
3375
3307
  opacity: 0;
3376
- transform: translateY(10px) scale(0.86);
3308
+ transform: translateY(8px) scale(0.9);
3377
3309
  pointer-events: none;
3378
3310
  transition:
3379
3311
  opacity 0.26s var(--ease-out-expo),
3380
3312
  transform 0.32s var(--ease-spring),
3381
3313
  background 0.18s ease,
3382
3314
  border-color 0.18s ease,
3383
- box-shadow 0.2s ease;
3315
+ box-shadow 0.2s ease,
3316
+ color 0.18s ease,
3317
+ padding 0.22s var(--ease-out-expo);
3384
3318
  }
3385
3319
 
3386
- .chat-jump-bottom.visible {
3320
+ .chat-unread-bubble.visible {
3387
3321
  opacity: 1;
3388
3322
  transform: translateY(0) scale(1);
3389
3323
  pointer-events: auto;
3390
3324
  }
3391
3325
 
3392
- .chat-jump-bottom svg {
3326
+ /* 有未读时升级成"高亮色"——胶囊变成主题色实心,更显眼。 */
3327
+ .chat-unread-bubble.has-unread {
3328
+ background: var(--accent);
3329
+ color: #fff;
3330
+ border-color: rgba(255, 255, 255, 0.78);
3331
+ padding: 0 12px 0 10px;
3332
+ box-shadow:
3333
+ 0 1px 3px rgba(89, 58, 32, 0.18),
3334
+ 0 12px 28px rgba(184, 92, 55, 0.32),
3335
+ inset 0 1px 0 rgba(255, 255, 255, 0.28);
3336
+ }
3337
+
3338
+ .chat-unread-bubble-icon {
3339
+ display: inline-flex;
3340
+ align-items: center;
3341
+ justify-content: center;
3342
+ flex-shrink: 0;
3393
3343
  transition: transform 0.2s var(--ease-out-expo);
3394
3344
  }
3395
3345
 
3396
- .chat-jump-bottom:hover {
3346
+ .chat-unread-bubble:hover {
3347
+ background: rgba(255, 250, 242, 1);
3348
+ box-shadow:
3349
+ 0 2px 4px rgba(89, 58, 32, 0.16),
3350
+ 0 14px 30px rgba(184, 92, 55, 0.24),
3351
+ inset 0 1px 0 rgba(255, 255, 255, 0.7);
3352
+ }
3353
+
3354
+ .chat-unread-bubble.has-unread:hover {
3397
3355
  background: var(--accent-hover);
3398
- border-color: rgba(255, 255, 255, 0.85);
3399
3356
  box-shadow:
3400
- 0 2px 4px rgba(89, 58, 32, 0.20),
3401
- 0 12px 28px rgba(184, 92, 55, 0.38),
3357
+ 0 2px 5px rgba(89, 58, 32, 0.22),
3358
+ 0 14px 32px rgba(184, 92, 55, 0.42),
3402
3359
  inset 0 1px 0 rgba(255, 255, 255, 0.32);
3403
3360
  }
3404
3361
 
3405
- .chat-jump-bottom:hover svg {
3362
+ .chat-unread-bubble:hover .chat-unread-bubble-icon {
3406
3363
  transform: translateY(1.5px);
3407
3364
  }
3408
3365
 
3409
- .chat-jump-bottom:active {
3410
- transform: translateY(0) scale(0.92);
3411
- background: var(--accent-active);
3366
+ .chat-unread-bubble:active {
3367
+ transform: translateY(0) scale(0.94);
3412
3368
  transition-duration: 0.08s;
3413
3369
  }
3414
3370
 
3415
- .chat-jump-bottom:focus-visible {
3371
+ .chat-unread-bubble:focus-visible {
3416
3372
  outline: none;
3417
3373
  box-shadow:
3418
3374
  0 2px 4px rgba(89, 58, 32, 0.20),
3419
- 0 12px 28px rgba(184, 92, 55, 0.38),
3375
+ 0 12px 28px rgba(184, 92, 55, 0.32),
3420
3376
  inset 0 1px 0 rgba(255, 255, 255, 0.32),
3421
3377
  0 0 0 3px rgba(197, 101, 61, 0.32);
3422
3378
  }
3423
3379
 
3380
+ /* 未读计数:胶囊里行内显示,"↓ 3 条新消息"那种。
3381
+ 没有未读时 (.visible 不挂) 直接占 0 宽度,胶囊退化成圆形。 */
3382
+ .chat-unread-bubble-count {
3383
+ display: none;
3384
+ align-items: center;
3385
+ font-size: 0.78rem;
3386
+ font-weight: 600;
3387
+ line-height: 1;
3388
+ letter-spacing: 0.01em;
3389
+ pointer-events: none;
3390
+ }
3391
+
3392
+ .chat-unread-bubble-count.visible {
3393
+ display: inline-flex;
3394
+ animation: chatUnreadCountPop 0.22s var(--ease-out-back, cubic-bezier(0.34, 1.56, 0.64, 1));
3395
+ }
3396
+
3397
+ .chat-unread-bubble-count::after {
3398
+ content: " 条新消息";
3399
+ margin-left: 2px;
3400
+ font-weight: 500;
3401
+ opacity: 0.95;
3402
+ }
3403
+
3404
+ @keyframes chatUnreadCountPop {
3405
+ from { transform: scale(0.6); opacity: 0; }
3406
+ to { transform: scale(1); opacity: 1; }
3407
+ }
3408
+
3409
+ /* ===== 未读消息分隔线 =====
3410
+ 在第一条未读消息的"上方"渲染一条带文字的横线。
3411
+ column-reverse 下:分隔线在 DOM 里挂在第一条已读消息的前面(DOM 顺序:
3412
+ 新→旧),视觉上自然落在未读和已读之间。 */
3413
+ .chat-unread-divider {
3414
+ display: flex;
3415
+ align-items: center;
3416
+ gap: 12px;
3417
+ margin: 6px 4px 2px;
3418
+ padding: 0 2px;
3419
+ font-size: 0.72rem;
3420
+ font-weight: 500;
3421
+ color: var(--accent);
3422
+ letter-spacing: 0.04em;
3423
+ user-select: none;
3424
+ pointer-events: none;
3425
+ opacity: 0;
3426
+ animation: chatUnreadDividerIn 0.32s var(--ease-out-expo) forwards;
3427
+ }
3428
+
3429
+ .chat-unread-divider-line {
3430
+ flex: 1;
3431
+ height: 1px;
3432
+ background: linear-gradient(
3433
+ to right,
3434
+ rgba(197, 101, 61, 0.05),
3435
+ rgba(197, 101, 61, 0.32),
3436
+ rgba(197, 101, 61, 0.05)
3437
+ );
3438
+ }
3439
+
3440
+ .chat-unread-divider-label {
3441
+ flex-shrink: 0;
3442
+ padding: 3px 10px;
3443
+ background: rgba(255, 248, 240, 0.95);
3444
+ border: 1px solid rgba(197, 101, 61, 0.18);
3445
+ border-radius: 999px;
3446
+ box-shadow: 0 1px 3px rgba(184, 92, 55, 0.1);
3447
+ white-space: nowrap;
3448
+ }
3449
+
3450
+ @keyframes chatUnreadDividerIn {
3451
+ from { opacity: 0; transform: translateY(-2px); }
3452
+ to { opacity: 1; transform: translateY(0); }
3453
+ }
3454
+
3424
3455
  .chat-container.active { display: flex; }
3425
3456
 
3426
3457
  .chat-container::after {
@@ -8505,8 +8536,7 @@
8505
8536
  }
8506
8537
 
8507
8538
  /* 回到底部按钮 - 紧凑 */
8508
- .terminal-jump-bottom,
8509
- .chat-jump-bottom {
8539
+ .terminal-jump-bottom {
8510
8540
  width: 32px;
8511
8541
  height: 32px;
8512
8542
  padding: 0;
@@ -8514,6 +8544,23 @@
8514
8544
  bottom: 12px;
8515
8545
  }
8516
8546
 
8547
+ /* 移动端的未读胶囊 —— 略矮一点,贴紧 chat-container 的右下角,
8548
+ 离输入框边缘更近、避开虚拟键盘那一坨。 */
8549
+ .chat-unread-bubble {
8550
+ height: 30px;
8551
+ min-width: 30px;
8552
+ right: 10px;
8553
+ bottom: 8px;
8554
+ padding: 0 9px;
8555
+ font-size: 0.72rem;
8556
+ }
8557
+ .chat-unread-bubble.has-unread {
8558
+ padding: 0 11px 0 9px;
8559
+ }
8560
+ .chat-unread-bubble-count {
8561
+ font-size: 0.72rem;
8562
+ }
8563
+
8517
8564
  /* 小键盘 FAB */
8518
8565
  .mini-keyboard-fab {
8519
8566
  width: 40px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {