@co0ontty/wand 1.23.0 → 1.25.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.
- package/dist/server.js +93 -3
- package/dist/web-ui/content/scripts.js +612 -155
- package/dist/web-ui/content/styles.css +593 -187
- package/package.json +1 -1
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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(
|
|
473
|
-
state.chatIsProgrammaticScroll = false;
|
|
474
|
-
updateChatJumpToBottomButton();
|
|
475
|
-
}, 220);
|
|
536
|
+
setTimeout(done, 260);
|
|
476
537
|
return;
|
|
477
538
|
}
|
|
478
539
|
chatMsgs.scrollTop = 0;
|
|
479
|
-
requestAnimationFrame(
|
|
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
|
-
|
|
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
|
-
|
|
558
|
+
updateChatUnreadBubble();
|
|
512
559
|
return;
|
|
513
560
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
570
|
+
updateChatUnreadBubble();
|
|
523
571
|
};
|
|
524
572
|
chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
|
|
525
|
-
|
|
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() {
|
|
@@ -1360,19 +1411,38 @@
|
|
|
1360
1411
|
// File side panel
|
|
1361
1412
|
'<div id="file-side-panel" class="file-side-panel' + (state.filePanelOpen ? " open" : "") + '">' +
|
|
1362
1413
|
'<div class="file-side-panel-header">' +
|
|
1363
|
-
'<
|
|
1364
|
-
|
|
1414
|
+
'<div class="file-side-panel-title-group">' +
|
|
1415
|
+
'<span class="file-side-panel-icon">' + wandFileIcon("folder-open", { size: 16 }) + '</span>' +
|
|
1416
|
+
'<span class="file-side-panel-title">文件</span>' +
|
|
1417
|
+
'</div>' +
|
|
1418
|
+
'<div class="file-side-panel-header-actions">' +
|
|
1419
|
+
'<button class="file-side-panel-iconbtn file-explorer-toggle-hidden' +
|
|
1420
|
+
(state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' +
|
|
1421
|
+
(state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' +
|
|
1422
|
+
(state.fileExplorerShowHidden ? "true" : "false") + '" aria-label="切换显示隐藏文件">' +
|
|
1423
|
+
wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 }) +
|
|
1424
|
+
'</button>' +
|
|
1425
|
+
'<button class="file-side-panel-iconbtn" id="file-explorer-refresh" type="button" title="刷新" aria-label="刷新文件列表">' +
|
|
1426
|
+
wandFileIcon("refresh", { size: 15 }) +
|
|
1427
|
+
'</button>' +
|
|
1428
|
+
'<button id="file-side-panel-close" class="file-side-panel-iconbtn close" type="button" aria-label="关闭文件面板" title="关闭">' +
|
|
1429
|
+
wandFileIcon("x", { size: 16 }) +
|
|
1430
|
+
'</button>' +
|
|
1431
|
+
'</div>' +
|
|
1365
1432
|
'</div>' +
|
|
1366
1433
|
'<div class="file-side-panel-body">' +
|
|
1367
1434
|
'<div class="file-explorer-header">' +
|
|
1368
|
-
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录"
|
|
1435
|
+
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录">' +
|
|
1436
|
+
wandFileIcon("arrow-up", { size: 15 }) +
|
|
1437
|
+
'</button>' +
|
|
1369
1438
|
'<input type="text" class="file-explorer-path" id="file-explorer-cwd" value="' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '" title="' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '" placeholder="输入路径并回车..." spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" aria-label="当前路径,可直接修改后回车" />' +
|
|
1370
|
-
'<button class="file-explorer-toggle-hidden' + (state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' + (state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' + (state.fileExplorerShowHidden ? "true" : "false") + '">' + (state.fileExplorerShowHidden ? "👁" : "👁🗨") + '</button>' +
|
|
1371
|
-
'<button class="file-explorer-refresh" id="file-explorer-refresh" title="刷新" aria-label="刷新文件列表">↻</button>' +
|
|
1372
1439
|
'</div>' +
|
|
1373
1440
|
'<div class="file-search-box">' +
|
|
1374
|
-
'<
|
|
1375
|
-
'<
|
|
1441
|
+
'<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
|
|
1442
|
+
'<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" />' +
|
|
1443
|
+
'<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
|
|
1444
|
+
wandFileIcon("x", { size: 13 }) +
|
|
1445
|
+
'</button>' +
|
|
1376
1446
|
'</div>' +
|
|
1377
1447
|
'<div class="file-explorer" id="file-explorer">' + renderFileExplorer(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</div>' +
|
|
1378
1448
|
'</div>' +
|
|
@@ -1388,10 +1458,10 @@
|
|
|
1388
1458
|
'<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
1459
|
'</div>' +
|
|
1390
1460
|
'<div id="chat-output" class="chat-container hidden">' +
|
|
1391
|
-
'<
|
|
1392
|
-
'<
|
|
1393
|
-
|
|
1394
|
-
'
|
|
1461
|
+
'<button id="chat-unread-bubble" class="chat-unread-bubble" type="button" title="回到最新消息" aria-label="回到最新消息">' +
|
|
1462
|
+
'<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>' +
|
|
1463
|
+
'<span class="chat-unread-bubble-count" aria-hidden="true"></span>' +
|
|
1464
|
+
'</button>' +
|
|
1395
1465
|
'</div>' +
|
|
1396
1466
|
'<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
|
|
1397
1467
|
'<div class="blank-chat-inner">' +
|
|
@@ -1450,18 +1520,20 @@
|
|
|
1450
1520
|
'</svg>' +
|
|
1451
1521
|
'<span class="prompt-optimize-spinner" aria-hidden="true"></span>' +
|
|
1452
1522
|
'</button>' +
|
|
1453
|
-
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1523
|
+
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" enterkeyhint="send">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1454
1524
|
'<div id="attachment-preview" class="attachment-preview hidden"></div>' +
|
|
1455
1525
|
'<div class="input-composer-bar">' +
|
|
1456
1526
|
'<div class="input-composer-left">' +
|
|
1457
1527
|
'<button id="attach-btn" class="btn-circle btn-circle-attach" type="button" title="附加文件">' +
|
|
1458
1528
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>' +
|
|
1459
1529
|
'</button>' +
|
|
1460
|
-
|
|
1461
|
-
|
|
1530
|
+
// tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
|
|
1531
|
+
// 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
|
|
1532
|
+
'<input type="file" id="file-upload-input" multiple tabindex="-1" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0);pointer-events:none">' +
|
|
1533
|
+
'<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
|
|
1462
1534
|
renderModeOptions(preferredTool, composerMode) +
|
|
1463
1535
|
'</select>' +
|
|
1464
|
-
'<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1536
|
+
'<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1465
1537
|
renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
|
|
1466
1538
|
'</select>' +
|
|
1467
1539
|
'<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
|
|
@@ -2941,6 +3013,49 @@
|
|
|
2941
3013
|
}
|
|
2942
3014
|
}
|
|
2943
3015
|
|
|
3016
|
+
// ── Inline SVG icon library for file UI ──
|
|
3017
|
+
// The previous design used unicode/emoji glyphs (⬆ ↻ 👁 ✕ 📋 ✏️ ⬇ ↩ A−) for
|
|
3018
|
+
// toolbar/header buttons. Those render inconsistently across OSes and don't
|
|
3019
|
+
// visually convey their action. These SVG icons are stroke-based, follow
|
|
3020
|
+
// currentColor, and stay crisp at any zoom.
|
|
3021
|
+
var WAND_FILE_ICONS = {
|
|
3022
|
+
"chevron-left": '<path d="M15 18l-6-6 6-6"/>',
|
|
3023
|
+
"arrow-up": '<path d="M12 19V5"/><path d="M5 12l7-7 7 7"/>',
|
|
3024
|
+
"refresh": '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/>',
|
|
3025
|
+
"eye": '<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/>',
|
|
3026
|
+
"eye-off": '<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a19.86 19.86 0 0 1 4.22-5.18"/><path d="M1 1l22 22"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 7 11 7a19.83 19.83 0 0 1-3.36 4.27"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>',
|
|
3027
|
+
"x": '<path d="M18 6L6 18"/><path d="M6 6l12 12"/>',
|
|
3028
|
+
"search": '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
|
3029
|
+
"copy": '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
3030
|
+
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/><path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
|
3031
|
+
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
|
|
3032
|
+
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
3033
|
+
"save": '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
|
|
3034
|
+
"rotate-ccw": '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>',
|
|
3035
|
+
"wrap-text": '<path d="M3 6h18"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><path d="M16 16l-2 2 2 2"/><path d="M3 18h6"/>',
|
|
3036
|
+
"type": '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
|
|
3037
|
+
"minus": '<path d="M5 12h14"/>',
|
|
3038
|
+
"plus": '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
|
3039
|
+
"send-to-input": '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
|
3040
|
+
"terminal": '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
|
3041
|
+
"folder-open": '<path d="M6 14l1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2"/>',
|
|
3042
|
+
"info": '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
|
3043
|
+
};
|
|
3044
|
+
|
|
3045
|
+
// Render a stroke-based 16x16 SVG icon by name. Extra classes get appended
|
|
3046
|
+
// to the outer svg, so callers can target specific icons in CSS.
|
|
3047
|
+
function wandFileIcon(name, opts) {
|
|
3048
|
+
opts = opts || {};
|
|
3049
|
+
var body = WAND_FILE_ICONS[name] || "";
|
|
3050
|
+
var size = opts.size || 16;
|
|
3051
|
+
var extraClass = opts.className ? " " + opts.className : "";
|
|
3052
|
+
return '<svg class="wand-icon wand-icon-' + name + extraClass +
|
|
3053
|
+
'" width="' + size + '" height="' + size +
|
|
3054
|
+
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' +
|
|
3055
|
+
' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
3056
|
+
body + '</svg>';
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2944
3059
|
function renderFileExplorer(cwd) {
|
|
2945
3060
|
var root = cwd || getConfigCwd();
|
|
2946
3061
|
if (!root) {
|
|
@@ -3260,7 +3375,7 @@
|
|
|
3260
3375
|
if (btn) {
|
|
3261
3376
|
btn.classList.toggle("active", state.fileExplorerShowHidden);
|
|
3262
3377
|
btn.setAttribute("aria-pressed", state.fileExplorerShowHidden ? "true" : "false");
|
|
3263
|
-
btn.
|
|
3378
|
+
btn.innerHTML = wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 });
|
|
3264
3379
|
btn.title = state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件";
|
|
3265
3380
|
}
|
|
3266
3381
|
refreshFileExplorer();
|
|
@@ -3403,11 +3518,15 @@
|
|
|
3403
3518
|
'<div class="file-preview-header">' +
|
|
3404
3519
|
'<div class="file-preview-title">' +
|
|
3405
3520
|
'<span class="file-preview-icon">📄</span>' +
|
|
3406
|
-
'<
|
|
3521
|
+
'<div class="file-preview-name-block">' +
|
|
3522
|
+
'<div class="file-preview-name-row">' +
|
|
3523
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3524
|
+
'</div>' +
|
|
3525
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3526
|
+
'</div>' +
|
|
3407
3527
|
'</div>' +
|
|
3408
|
-
'<div class="file-preview-path" title=""></div>' +
|
|
3409
3528
|
'<div class="file-preview-toolbar"></div>' +
|
|
3410
|
-
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭"
|
|
3529
|
+
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭">' + wandFileIcon("x", { size: 18 }) + '</button>' +
|
|
3411
3530
|
'</div>' +
|
|
3412
3531
|
'<div class="file-preview-body">' +
|
|
3413
3532
|
'<div class="file-preview-loading">加载预览…</div>' +
|
|
@@ -3417,6 +3536,25 @@
|
|
|
3417
3536
|
|
|
3418
3537
|
var closeBtn = overlay.querySelector(".file-preview-close");
|
|
3419
3538
|
var closeModal = function() {
|
|
3539
|
+
// Guard: warn before discarding unsaved edits.
|
|
3540
|
+
if (_activeFilePreview && _activeFilePreview.dirty) {
|
|
3541
|
+
if (typeof openWandDialog === "function") {
|
|
3542
|
+
openWandDialog({
|
|
3543
|
+
type: "warning",
|
|
3544
|
+
title: "放弃未保存的修改?",
|
|
3545
|
+
message: "当前文件有未保存的改动,关闭后会丢失。",
|
|
3546
|
+
buttons: [
|
|
3547
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
3548
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
3549
|
+
],
|
|
3550
|
+
cancelValue: false,
|
|
3551
|
+
}).then(function(go) { if (go) doClose(); });
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
doClose();
|
|
3556
|
+
};
|
|
3557
|
+
var doClose = function() {
|
|
3420
3558
|
overlay.remove();
|
|
3421
3559
|
document.removeEventListener("keydown", keyHandler);
|
|
3422
3560
|
_activeFilePreview = null;
|
|
@@ -3426,21 +3564,49 @@
|
|
|
3426
3564
|
if (e.target === overlay) closeModal();
|
|
3427
3565
|
});
|
|
3428
3566
|
var keyHandler = function(e) {
|
|
3429
|
-
|
|
3567
|
+
// Ctrl/Cmd+S to save in edit mode.
|
|
3568
|
+
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
|
|
3569
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3570
|
+
e.preventDefault();
|
|
3571
|
+
saveFileEdit();
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
if (e.key === "Escape") {
|
|
3576
|
+
// Inside edit mode, Esc exits edit instead of closing the modal.
|
|
3577
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3578
|
+
e.preventDefault();
|
|
3579
|
+
exitFileEdit();
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
closeModal();
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3430
3585
|
if (!_activeFilePreview) return;
|
|
3586
|
+
// Don't intercept arrow keys while typing.
|
|
3431
3587
|
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
|
|
3588
|
+
if (_activeFilePreview.editing) return;
|
|
3432
3589
|
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePreviewSibling(-1); }
|
|
3433
3590
|
else if (e.key === "ArrowRight") { e.preventDefault(); navigatePreviewSibling(1); }
|
|
3434
3591
|
};
|
|
3435
3592
|
document.addEventListener("keydown", keyHandler);
|
|
3436
3593
|
|
|
3437
|
-
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null };
|
|
3594
|
+
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null, editing: false, dirty: false };
|
|
3438
3595
|
} else {
|
|
3439
3596
|
_activeFilePreview.path = filePath;
|
|
3597
|
+
_activeFilePreview.editing = false;
|
|
3598
|
+
_activeFilePreview.dirty = false;
|
|
3440
3599
|
// Reset header / body for the new file.
|
|
3441
3600
|
var titleEl = overlay.querySelector(".file-preview-title");
|
|
3442
3601
|
if (titleEl) {
|
|
3443
|
-
titleEl.innerHTML =
|
|
3602
|
+
titleEl.innerHTML =
|
|
3603
|
+
'<span class="file-preview-icon">📄</span>' +
|
|
3604
|
+
'<div class="file-preview-name-block">' +
|
|
3605
|
+
'<div class="file-preview-name-row">' +
|
|
3606
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3607
|
+
'</div>' +
|
|
3608
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3609
|
+
'</div>';
|
|
3444
3610
|
}
|
|
3445
3611
|
var toolbarEl = overlay.querySelector(".file-preview-toolbar");
|
|
3446
3612
|
if (toolbarEl) toolbarEl.innerHTML = "";
|
|
@@ -3630,16 +3796,31 @@
|
|
|
3630
3796
|
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3631
3797
|
if (!bar) return;
|
|
3632
3798
|
bar.innerHTML = "";
|
|
3799
|
+
bar.classList.remove("editing");
|
|
3800
|
+
|
|
3801
|
+
// ── Edit mode renders its own dedicated toolbar (save / revert / cancel). ──
|
|
3802
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3803
|
+
bar.classList.add("editing");
|
|
3804
|
+
renderEditToolbar(overlay, data);
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3633
3808
|
var buttons = [];
|
|
3634
3809
|
|
|
3810
|
+
if (data.kind === "text") {
|
|
3811
|
+
buttons.push({ label: "编辑文件 (E)", icon: wandFileIcon("edit"), primary: true, action: function() {
|
|
3812
|
+
enterFileEdit();
|
|
3813
|
+
}});
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3635
3816
|
// Common actions across all kinds
|
|
3636
|
-
buttons.push({ label: "复制路径", icon: "
|
|
3817
|
+
buttons.push({ label: "复制路径", icon: wandFileIcon("clipboard"), action: function() {
|
|
3637
3818
|
copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
|
|
3638
3819
|
}});
|
|
3639
|
-
buttons.push({ label: "粘贴到输入框", icon: "
|
|
3820
|
+
buttons.push({ label: "粘贴到输入框", icon: wandFileIcon("send-to-input"), action: function() {
|
|
3640
3821
|
if (appendToComposer(data.path)) showToastIfPossible("已粘贴到输入框");
|
|
3641
3822
|
}});
|
|
3642
|
-
buttons.push({ label: "下载", icon: "
|
|
3823
|
+
buttons.push({ label: "下载", icon: wandFileIcon("download"), action: function() {
|
|
3643
3824
|
var a = document.createElement("a");
|
|
3644
3825
|
a.href = "/api/file-raw?download=1&path=" + encodeURIComponent(data.path);
|
|
3645
3826
|
a.download = data.name || "";
|
|
@@ -3649,10 +3830,10 @@
|
|
|
3649
3830
|
}});
|
|
3650
3831
|
|
|
3651
3832
|
if (data.kind === "text") {
|
|
3652
|
-
buttons.push({ label: "
|
|
3833
|
+
buttons.push({ label: "复制全部内容", icon: wandFileIcon("copy"), action: function() {
|
|
3653
3834
|
copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
|
|
3654
3835
|
}});
|
|
3655
|
-
buttons.push({ label: "
|
|
3836
|
+
buttons.push({ label: "切换自动换行", icon: wandFileIcon("wrap-text"), toggleClass: "toolbar-active",
|
|
3656
3837
|
getInitial: function() {
|
|
3657
3838
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3658
3839
|
return pre && pre.classList.contains("wrap");
|
|
@@ -3664,26 +3845,235 @@
|
|
|
3664
3845
|
btn.classList.toggle("toolbar-active", pre.classList.contains("wrap"));
|
|
3665
3846
|
}
|
|
3666
3847
|
});
|
|
3667
|
-
|
|
3668
|
-
buttons.push({
|
|
3848
|
+
// Font-size adjustments — render as a single grouped chip with two halves.
|
|
3849
|
+
buttons.push({ kind: "group", className: "toolbar-group-fontsize",
|
|
3850
|
+
children: [
|
|
3851
|
+
{ label: "缩小字号", icon: wandFileIcon("minus"), action: function() { adjustPreviewFontSize(overlay, -1); }},
|
|
3852
|
+
{ kind: "label", icon: wandFileIcon("type"), label: "字号" },
|
|
3853
|
+
{ label: "放大字号", icon: wandFileIcon("plus"), action: function() { adjustPreviewFontSize(overlay, +1); }},
|
|
3854
|
+
],
|
|
3855
|
+
});
|
|
3669
3856
|
}
|
|
3670
3857
|
|
|
3858
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
// Render a flat list of toolbar buttons (with optional grouped chips).
|
|
3862
|
+
function renderToolbarButtons(bar, buttons, overlay) {
|
|
3671
3863
|
buttons.forEach(function(b) {
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3864
|
+
if (b.kind === "group") {
|
|
3865
|
+
var group = document.createElement("div");
|
|
3866
|
+
group.className = "file-preview-toolbar-group" + (b.className ? " " + b.className : "");
|
|
3867
|
+
b.children.forEach(function(child) {
|
|
3868
|
+
if (child.kind === "label") {
|
|
3869
|
+
var lab = document.createElement("span");
|
|
3870
|
+
lab.className = "file-preview-toolbar-grouplabel";
|
|
3871
|
+
lab.title = child.label || "";
|
|
3872
|
+
lab.innerHTML = child.icon || "";
|
|
3873
|
+
group.appendChild(lab);
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
group.appendChild(buildToolbarButton(child));
|
|
3877
|
+
});
|
|
3878
|
+
bar.appendChild(group);
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
bar.appendChild(buildToolbarButton(b));
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
function buildToolbarButton(b) {
|
|
3886
|
+
var btn = document.createElement("button");
|
|
3887
|
+
btn.type = "button";
|
|
3888
|
+
btn.className = "file-preview-toolbar-btn";
|
|
3889
|
+
if (b.primary) btn.classList.add("primary");
|
|
3890
|
+
if (b.danger) btn.classList.add("danger");
|
|
3891
|
+
btn.title = b.label;
|
|
3892
|
+
btn.setAttribute("aria-label", b.label);
|
|
3893
|
+
btn.innerHTML = '<span class="toolbar-icon">' + (b.icon || "") + '</span>' +
|
|
3894
|
+
(b.text ? '<span class="toolbar-text">' + escapeHtml(b.text) + '</span>' : '');
|
|
3895
|
+
if (b.getInitial && b.getInitial()) btn.classList.add("toolbar-active");
|
|
3896
|
+
btn.addEventListener("click", function(ev) {
|
|
3897
|
+
ev.stopPropagation();
|
|
3898
|
+
if (typeof b.action === "function") b.action(btn);
|
|
3899
|
+
});
|
|
3900
|
+
return btn;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
// ── Edit mode ──
|
|
3904
|
+
function renderEditToolbar(overlay, data) {
|
|
3905
|
+
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3906
|
+
if (!bar) return;
|
|
3907
|
+
bar.innerHTML = "";
|
|
3908
|
+
var saving = _activeFilePreview && _activeFilePreview.saving;
|
|
3909
|
+
var buttons = [
|
|
3910
|
+
{ label: "保存 (Ctrl+S)", icon: wandFileIcon("save"), text: "保存", primary: true,
|
|
3911
|
+
action: function() { saveFileEdit(); } },
|
|
3912
|
+
{ label: "撤销改动", icon: wandFileIcon("rotate-ccw"),
|
|
3913
|
+
action: function() { revertFileEdit(); } },
|
|
3914
|
+
{ label: "退出编辑 (Esc)", icon: wandFileIcon("x"),
|
|
3915
|
+
action: function() { exitFileEdit(); } },
|
|
3916
|
+
];
|
|
3917
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3918
|
+
if (saving) {
|
|
3919
|
+
bar.querySelectorAll(".file-preview-toolbar-btn").forEach(function(b) { b.disabled = true; });
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
function enterFileEdit() {
|
|
3924
|
+
if (!_activeFilePreview || !_activeFilePreview.data) return;
|
|
3925
|
+
var data = _activeFilePreview.data;
|
|
3926
|
+
if (data.kind !== "text") return;
|
|
3927
|
+
_activeFilePreview.editing = true;
|
|
3928
|
+
_activeFilePreview.dirty = false;
|
|
3929
|
+
_activeFilePreview.originalContent = data.content || "";
|
|
3930
|
+
var overlay = _activeFilePreview.overlay;
|
|
3931
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3932
|
+
if (!body) return;
|
|
3933
|
+
body.classList.add("editing");
|
|
3934
|
+
body.innerHTML =
|
|
3935
|
+
'<div class="code-editor-wrapper">' +
|
|
3936
|
+
'<textarea class="code-editor-textarea" spellcheck="false" autocomplete="off"' +
|
|
3937
|
+
' autocorrect="off" autocapitalize="off" wrap="off"></textarea>' +
|
|
3938
|
+
'</div>';
|
|
3939
|
+
var ta = body.querySelector(".code-editor-textarea");
|
|
3940
|
+
if (ta) {
|
|
3941
|
+
ta.value = data.content || "";
|
|
3942
|
+
ta.addEventListener("input", function() {
|
|
3943
|
+
var dirty = ta.value !== (_activeFilePreview.originalContent || "");
|
|
3944
|
+
if (dirty !== _activeFilePreview.dirty) {
|
|
3945
|
+
_activeFilePreview.dirty = dirty;
|
|
3946
|
+
updateDirtyBadge();
|
|
3947
|
+
}
|
|
3948
|
+
});
|
|
3949
|
+
// Tab key inserts spaces (2-space indent) instead of moving focus.
|
|
3950
|
+
ta.addEventListener("keydown", function(e) {
|
|
3951
|
+
if (e.key === "Tab") {
|
|
3952
|
+
e.preventDefault();
|
|
3953
|
+
var start = ta.selectionStart, end = ta.selectionEnd;
|
|
3954
|
+
var indent = " ";
|
|
3955
|
+
ta.value = ta.value.slice(0, start) + indent + ta.value.slice(end);
|
|
3956
|
+
ta.selectionStart = ta.selectionEnd = start + indent.length;
|
|
3957
|
+
ta.dispatchEvent(new Event("input"));
|
|
3958
|
+
}
|
|
3682
3959
|
});
|
|
3683
|
-
|
|
3960
|
+
// Focus and place caret at start so user sees the top of the file.
|
|
3961
|
+
setTimeout(function() {
|
|
3962
|
+
ta.focus();
|
|
3963
|
+
ta.setSelectionRange(0, 0);
|
|
3964
|
+
ta.scrollTop = 0;
|
|
3965
|
+
}, 30);
|
|
3966
|
+
}
|
|
3967
|
+
renderPreviewToolbar(overlay, data);
|
|
3968
|
+
updateDirtyBadge();
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function exitFileEdit() {
|
|
3972
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
3973
|
+
var doExit = function() {
|
|
3974
|
+
_activeFilePreview.editing = false;
|
|
3975
|
+
_activeFilePreview.dirty = false;
|
|
3976
|
+
var overlay = _activeFilePreview.overlay;
|
|
3977
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3978
|
+
if (body) body.classList.remove("editing");
|
|
3979
|
+
// Re-render preview from latest data.
|
|
3980
|
+
renderPreviewContent(overlay, _activeFilePreview.data);
|
|
3981
|
+
updateDirtyBadge();
|
|
3982
|
+
};
|
|
3983
|
+
if (_activeFilePreview.dirty && typeof openWandDialog === "function") {
|
|
3984
|
+
openWandDialog({
|
|
3985
|
+
type: "warning",
|
|
3986
|
+
title: "放弃未保存的修改?",
|
|
3987
|
+
message: "当前文件有未保存的改动,退出编辑后会丢失。",
|
|
3988
|
+
buttons: [
|
|
3989
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
3990
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
3991
|
+
],
|
|
3992
|
+
cancelValue: false,
|
|
3993
|
+
}).then(function(go) { if (go) doExit(); });
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3996
|
+
doExit();
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
function revertFileEdit() {
|
|
4000
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4001
|
+
var overlay = _activeFilePreview.overlay;
|
|
4002
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4003
|
+
if (!ta) return;
|
|
4004
|
+
ta.value = _activeFilePreview.originalContent || "";
|
|
4005
|
+
_activeFilePreview.dirty = false;
|
|
4006
|
+
updateDirtyBadge();
|
|
4007
|
+
ta.focus();
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
function saveFileEdit() {
|
|
4011
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4012
|
+
if (_activeFilePreview.saving) return;
|
|
4013
|
+
var overlay = _activeFilePreview.overlay;
|
|
4014
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4015
|
+
if (!ta) return;
|
|
4016
|
+
var newContent = ta.value;
|
|
4017
|
+
if (newContent === (_activeFilePreview.originalContent || "")) {
|
|
4018
|
+
showToastIfPossible("没有改动");
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
_activeFilePreview.saving = true;
|
|
4022
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4023
|
+
fetch("/api/file-write", {
|
|
4024
|
+
method: "POST",
|
|
4025
|
+
credentials: "same-origin",
|
|
4026
|
+
headers: { "Content-Type": "application/json" },
|
|
4027
|
+
body: JSON.stringify({ path: _activeFilePreview.path, content: newContent }),
|
|
4028
|
+
}).then(function(res) {
|
|
4029
|
+
return res.json().then(function(json) { return { ok: res.ok, status: res.status, data: json }; });
|
|
4030
|
+
}).then(function(result) {
|
|
4031
|
+
_activeFilePreview.saving = false;
|
|
4032
|
+
if (!result.ok || (result.data && result.data.error)) {
|
|
4033
|
+
var msg = (result.data && result.data.error) || ("保存失败 (" + result.status + ")");
|
|
4034
|
+
showToastIfPossible(msg);
|
|
4035
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4036
|
+
return;
|
|
4037
|
+
}
|
|
4038
|
+
// Sync local cache so revert points at the new baseline.
|
|
4039
|
+
_activeFilePreview.data.content = newContent;
|
|
4040
|
+
_activeFilePreview.data.size = (result.data && result.data.size) || newContent.length;
|
|
4041
|
+
_activeFilePreview.originalContent = newContent;
|
|
4042
|
+
_activeFilePreview.dirty = false;
|
|
4043
|
+
showToastIfPossible("已保存");
|
|
4044
|
+
updateDirtyBadge();
|
|
4045
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4046
|
+
// Quietly refresh the file tree so size/git-status update.
|
|
4047
|
+
if (typeof refreshFileExplorer === "function") {
|
|
4048
|
+
try { refreshFileExplorer(); } catch (e) {}
|
|
4049
|
+
}
|
|
4050
|
+
}).catch(function(err) {
|
|
4051
|
+
_activeFilePreview.saving = false;
|
|
4052
|
+
showToastIfPossible("保存失败:" + (err && err.message ? err.message : "网络错误"));
|
|
4053
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
3684
4054
|
});
|
|
3685
4055
|
}
|
|
3686
4056
|
|
|
4057
|
+
function updateDirtyBadge() {
|
|
4058
|
+
if (!_activeFilePreview) return;
|
|
4059
|
+
var overlay = _activeFilePreview.overlay;
|
|
4060
|
+
if (!overlay) return;
|
|
4061
|
+
var row = overlay.querySelector(".file-preview-name-row");
|
|
4062
|
+
if (!row) return;
|
|
4063
|
+
var existing = row.querySelector(".file-preview-dirty");
|
|
4064
|
+
if (_activeFilePreview.dirty) {
|
|
4065
|
+
if (!existing) {
|
|
4066
|
+
var dot = document.createElement("span");
|
|
4067
|
+
dot.className = "file-preview-dirty";
|
|
4068
|
+
dot.title = "有未保存的修改";
|
|
4069
|
+
dot.textContent = "● 未保存";
|
|
4070
|
+
row.appendChild(dot);
|
|
4071
|
+
}
|
|
4072
|
+
} else if (existing) {
|
|
4073
|
+
existing.remove();
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
3687
4077
|
function adjustPreviewFontSize(overlay, delta) {
|
|
3688
4078
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3689
4079
|
var nums = overlay.querySelector(".code-preview-lines");
|
|
@@ -5087,17 +5477,10 @@
|
|
|
5087
5477
|
if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
|
|
5088
5478
|
maybeScrollTerminalToBottom("force");
|
|
5089
5479
|
});
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
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 });
|
|
5480
|
+
// 未读气泡:点一下就贴回最新消息,顺手清掉未读分割线和计数。
|
|
5481
|
+
var chatUnreadBubble = document.getElementById("chat-unread-bubble");
|
|
5482
|
+
if (chatUnreadBubble) chatUnreadBubble.addEventListener("click", function() {
|
|
5483
|
+
scrollChatToBottom(true);
|
|
5101
5484
|
});
|
|
5102
5485
|
var fileRefresh = document.getElementById("file-explorer-refresh");
|
|
5103
5486
|
if (fileRefresh) fileRefresh.addEventListener("click", function() { refreshFileExplorer(); });
|
|
@@ -7125,8 +7508,7 @@
|
|
|
7125
7508
|
ensureChatMessagesContainer(chatContainer);
|
|
7126
7509
|
}
|
|
7127
7510
|
bindChatScrollListener();
|
|
7128
|
-
|
|
7129
|
-
updateChatJumpToBottomButton();
|
|
7511
|
+
updateChatUnreadBubble();
|
|
7130
7512
|
updateInteractiveControls();
|
|
7131
7513
|
}
|
|
7132
7514
|
|
|
@@ -12465,7 +12847,27 @@
|
|
|
12465
12847
|
// Keyboard just closed — force terminal refit and scroll to bottom
|
|
12466
12848
|
// after a delay so the keyboard dismiss animation and layout settle.
|
|
12467
12849
|
if (keyboardOpen && !isKeyboardOpen) {
|
|
12850
|
+
// iOS Safari quirk: 用户按系统 Done / 下滑收起键盘 / 应用切换回来时,
|
|
12851
|
+
// 经常不会触发 textarea 的 blur 事件,导致 handleInputBoxBlur 里的
|
|
12852
|
+
// window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
|
|
12853
|
+
// 偏移位置,input-panel 看起来"没回到底"。
|
|
12854
|
+
// 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
|
|
12855
|
+
// 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
|
|
12856
|
+
// 100dvh、input-panel 重新贴屏幕底部。
|
|
12857
|
+
var rootEl = document.documentElement;
|
|
12858
|
+
rootEl.style.removeProperty('--app-viewport-height');
|
|
12859
|
+
window.scrollTo(0, 0);
|
|
12860
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12861
|
+
rootEl.scrollTop = 0;
|
|
12862
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12468
12863
|
setTimeout(function() {
|
|
12864
|
+
// 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
|
|
12865
|
+
// 防止 iOS 在动画过程中又把 scrollTop 推上去。
|
|
12866
|
+
window.scrollTo(0, 0);
|
|
12867
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12868
|
+
rootEl.scrollTop = 0;
|
|
12869
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12870
|
+
syncAppViewportHeight();
|
|
12469
12871
|
ensureTerminalFit("keyboard-close", { forceReplay: true });
|
|
12470
12872
|
maybeScrollTerminalToBottom("force");
|
|
12471
12873
|
}, 200);
|
|
@@ -13783,8 +14185,7 @@
|
|
|
13783
14185
|
if (!chatMessages) return null;
|
|
13784
14186
|
chatMessages.innerHTML = html;
|
|
13785
14187
|
bindChatScrollListener();
|
|
13786
|
-
|
|
13787
|
-
updateChatJumpToBottomButton();
|
|
14188
|
+
updateChatUnreadBubble();
|
|
13788
14189
|
return chatMessages;
|
|
13789
14190
|
}
|
|
13790
14191
|
|
|
@@ -13818,9 +14219,10 @@
|
|
|
13818
14219
|
}
|
|
13819
14220
|
|
|
13820
14221
|
// Lazy loading: only render the most recent chatRenderedCount messages.
|
|
13821
|
-
//
|
|
14222
|
+
// 新消息进来时永远展开渲染窗口,避免用户正在看的旧消息被挤进"加载更早"里——
|
|
14223
|
+
// Telegram 风格下我们不主动挪用户的视线,最稳妥的办法就是别让他看的那条消失。
|
|
13822
14224
|
var totalMsgCount = allMessages.length;
|
|
13823
|
-
if (totalMsgCount > state.chatRenderedCount
|
|
14225
|
+
if (totalMsgCount > state.chatRenderedCount) {
|
|
13824
14226
|
state.chatRenderedCount = totalMsgCount;
|
|
13825
14227
|
}
|
|
13826
14228
|
var visibleOffset = Math.max(0, totalMsgCount - state.chatRenderedCount);
|
|
@@ -13876,6 +14278,13 @@
|
|
|
13876
14278
|
var chatMessages = ensureChatMessagesContainer(chatOutput);
|
|
13877
14279
|
if (!chatMessages) return;
|
|
13878
14280
|
|
|
14281
|
+
// 在动 DOM 之前先看用户是不是贴在底部——这决定后面我们要不要让视图
|
|
14282
|
+
// "继续粘在底部"。column-reverse 下 scrollTop 接近 0 = 视觉底部。
|
|
14283
|
+
// 同时把 state.chatStickToBottom 同步到当前真实状态,避免长时间不滚动后
|
|
14284
|
+
// 的状态漂移(比如新会话 init 的瞬间)。
|
|
14285
|
+
var renderWasAtBottom = isChatNearBottom(chatMessages);
|
|
14286
|
+
if (renderWasAtBottom) state.chatStickToBottom = true;
|
|
14287
|
+
|
|
13879
14288
|
var existingCount = chatMessages.querySelectorAll(".chat-message").length;
|
|
13880
14289
|
// Full render when: forced, no existing messages, or message count decreased/changed
|
|
13881
14290
|
var needsFullRender = forceRender || existingCount === 0 || msgCount !== existingCount;
|
|
@@ -13925,16 +14334,29 @@
|
|
|
13925
14334
|
}
|
|
13926
14335
|
|
|
13927
14336
|
chatMessages.innerHTML = html;
|
|
14337
|
+
// 给每条消息打 data-msg-index(用 state.currentMessages 的全局索引),
|
|
14338
|
+
// 后面 refreshChatUnreadDivider 用它找未读分割线的位置。
|
|
14339
|
+
(function() {
|
|
14340
|
+
var msgEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
|
|
14341
|
+
// column-reverse: DOM[0] = 最新(最高 originalIndex)
|
|
14342
|
+
var totalVisible = msgEls.length;
|
|
14343
|
+
for (var idx = 0; idx < totalVisible; idx++) {
|
|
14344
|
+
msgEls[idx].setAttribute("data-msg-index", String(visibleOffset + totalVisible - 1 - idx));
|
|
14345
|
+
}
|
|
14346
|
+
})();
|
|
13928
14347
|
// 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
|
|
13929
14348
|
// 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
|
|
13930
14349
|
// 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
|
|
13931
14350
|
// prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
|
|
13932
14351
|
// 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
|
|
13933
|
-
// 注意:仅在"换会话/初次渲染"时强制重置;同一会话内的全量重渲染
|
|
13934
|
-
// (prevMsgCount > 0,比如 forceFullRender 或消息数减少)继续走
|
|
13935
|
-
// smartScrollToBottom,尊重用户的 chatAutoFollow 选择。
|
|
13936
14352
|
if (prevMsgCount === 0) {
|
|
13937
14353
|
chatMessages.scrollTop = 0;
|
|
14354
|
+
state.chatStickToBottom = true;
|
|
14355
|
+
clearChatUnread({ removeDivider: true });
|
|
14356
|
+
} else if (renderWasAtBottom) {
|
|
14357
|
+
// 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
|
|
14358
|
+
// 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
|
|
14359
|
+
chatMessages.scrollTop = 0;
|
|
13938
14360
|
}
|
|
13939
14361
|
attachAllCopyHandlers(chatMessages);
|
|
13940
14362
|
bindChatScrollListener();
|
|
@@ -13959,9 +14381,13 @@
|
|
|
13959
14381
|
}
|
|
13960
14382
|
}
|
|
13961
14383
|
}
|
|
13962
|
-
//
|
|
14384
|
+
// 不主动 smartScrollToBottom——同一会话的全量重渲染要么是
|
|
14385
|
+
// streaming fallback(页面位置应保持),要么是 msgCount 减少(极少见,
|
|
14386
|
+
// 走 prevMsgCount===0 那条分支已经处理)。让浏览器自带的 scroll
|
|
14387
|
+
// anchoring 接手,避免在用户阅读时把视图拽走。
|
|
13963
14388
|
requestAnimationFrame(function() {
|
|
13964
|
-
|
|
14389
|
+
refreshChatUnreadDivider(chatMessages);
|
|
14390
|
+
updateChatUnreadBubble();
|
|
13965
14391
|
observeLoadMoreSentinel();
|
|
13966
14392
|
});
|
|
13967
14393
|
}
|
|
@@ -14036,6 +14462,11 @@
|
|
|
14036
14462
|
newMessages.reverse();
|
|
14037
14463
|
var fragment = document.createDocumentFragment();
|
|
14038
14464
|
var insertedEls = [];
|
|
14465
|
+
// 记录每条新消息的 originalIndex,方便后面打标签 / 计算未读起点。
|
|
14466
|
+
var insertedOrigIdx = [];
|
|
14467
|
+
// 第一条新消息(数组里 index 最小的,时间上最早的那条)对应的全局索引——
|
|
14468
|
+
// 用作未读起点。
|
|
14469
|
+
var firstNewOrigIdx = visibleOffset + existingCount;
|
|
14039
14470
|
for (var i = 0; i < newMessages.length; i++) {
|
|
14040
14471
|
var div = document.createElement("div");
|
|
14041
14472
|
var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
|
|
@@ -14043,7 +14474,9 @@
|
|
|
14043
14474
|
var el = div.firstElementChild;
|
|
14044
14475
|
if (el) {
|
|
14045
14476
|
el.classList.add("animate-in");
|
|
14477
|
+
el.setAttribute("data-msg-index", String(nmOrigIdx));
|
|
14046
14478
|
insertedEls.push(el);
|
|
14479
|
+
insertedOrigIdx.push(nmOrigIdx);
|
|
14047
14480
|
fragment.appendChild(el);
|
|
14048
14481
|
}
|
|
14049
14482
|
}
|
|
@@ -14053,10 +14486,31 @@
|
|
|
14053
14486
|
applyPersistedExpandState(chatMessages);
|
|
14054
14487
|
// Collapse all existing cards; new cards (with animate-in) stay expanded
|
|
14055
14488
|
collapseOldToolCards(chatMessages, insertedEls);
|
|
14056
|
-
//
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14489
|
+
// Telegram 行为:
|
|
14490
|
+
// - 用户原本就贴在底部 → 维持贴底(column-reverse 通常会自动留在底部,
|
|
14491
|
+
// 但浏览器的 scroll anchoring 在某些边界场景会把 scrollTop 调成非 0;
|
|
14492
|
+
// 这里显式拉回 0 做兜底,不用动画,不会让用户感觉"被甩")。
|
|
14493
|
+
// - 用户已经滚上去 → 一根毛都不动他的视图,只把未读累到气泡里。
|
|
14494
|
+
if (renderWasAtBottom) {
|
|
14495
|
+
requestAnimationFrame(function() {
|
|
14496
|
+
if (chatMessages.isConnected && Math.abs(chatMessages.scrollTop) > 1) {
|
|
14497
|
+
state.chatIsProgrammaticScroll = true;
|
|
14498
|
+
chatMessages.scrollTop = 0;
|
|
14499
|
+
requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
|
|
14500
|
+
}
|
|
14501
|
+
// 视为已读 —— 用户当前就在底部看着,这些新消息直接进入"已读"。
|
|
14502
|
+
clearChatUnread({ removeDivider: true });
|
|
14503
|
+
updateChatUnreadBubble();
|
|
14504
|
+
});
|
|
14505
|
+
} else {
|
|
14506
|
+
// 累计未读。如果之前没有未读,就用这一批的最早一条做分割线起点。
|
|
14507
|
+
if (state.chatUnreadStartIndex < 0) {
|
|
14508
|
+
state.chatUnreadStartIndex = firstNewOrigIdx;
|
|
14509
|
+
}
|
|
14510
|
+
state.chatUnreadCount += insertedEls.length;
|
|
14511
|
+
refreshChatUnreadDivider(chatMessages);
|
|
14512
|
+
updateChatUnreadBubble();
|
|
14513
|
+
}
|
|
14060
14514
|
} else if (msgCount === existingCount && outputHash !== prevHash) {
|
|
14061
14515
|
// Same message count but content changed (streaming update).
|
|
14062
14516
|
// Optimization: only re-render the newest N messages (column-reverse: first children)
|
|
@@ -14091,8 +14545,18 @@
|
|
|
14091
14545
|
if (replacedAny) {
|
|
14092
14546
|
bindChatScrollListener();
|
|
14093
14547
|
applyPersistedExpandState(chatMessages);
|
|
14548
|
+
// Streaming 更新只是改最新一条的内容,不改条数。column-reverse 下
|
|
14549
|
+
// 浏览器的 scroll anchoring 会自动保持视觉位置;用户贴底时新内容
|
|
14550
|
+
// 自然出现在底部,用户上滚时视图也不受打扰——不需要再 smartScroll。
|
|
14094
14551
|
requestAnimationFrame(function() {
|
|
14095
|
-
|
|
14552
|
+
// 兜底:用户贴底时如果浏览器把 scrollTop 调成非零,拉回来。
|
|
14553
|
+
if (renderWasAtBottom && chatMessages.isConnected && Math.abs(chatMessages.scrollTop) > 1) {
|
|
14554
|
+
state.chatIsProgrammaticScroll = true;
|
|
14555
|
+
chatMessages.scrollTop = 0;
|
|
14556
|
+
requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
|
|
14557
|
+
}
|
|
14558
|
+
refreshChatUnreadDivider(chatMessages);
|
|
14559
|
+
updateChatUnreadBubble();
|
|
14096
14560
|
});
|
|
14097
14561
|
var newestMsgEl = chatMessages.querySelector(".chat-message");
|
|
14098
14562
|
var allCards = chatMessages.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
|
|
@@ -14121,19 +14585,11 @@
|
|
|
14121
14585
|
updateTodoProgress(allMessages);
|
|
14122
14586
|
}
|
|
14123
14587
|
|
|
14124
|
-
//
|
|
14125
|
-
//
|
|
14126
|
-
|
|
14127
|
-
|
|
14128
|
-
|
|
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
|
-
}
|
|
14588
|
+
// 注:旧版的 smartScrollToBottom / chatAutoFollow / chat-follow-toggle 都已经
|
|
14589
|
+
// 拆掉,改成 Telegram 风格:贴底状态完全由用户的滚动行为驱动,未读靠
|
|
14590
|
+
// chat-unread-bubble 气泡提示,不再主动滚动用户的视图。
|
|
14591
|
+
// 相关入口:scrollChatToBottom(用户点气泡时强制贴底)、
|
|
14592
|
+
// refreshChatUnreadDivider(分割线渲染)、updateChatUnreadBubble(气泡 UI)。
|
|
14137
14593
|
|
|
14138
14594
|
// --- Todo progress bar ---
|
|
14139
14595
|
var todoExpanded = false;
|
|
@@ -14220,11 +14676,12 @@
|
|
|
14220
14676
|
var task = document.getElementById("todo-progress-task");
|
|
14221
14677
|
if (task) task.textContent = activeTask;
|
|
14222
14678
|
|
|
14223
|
-
//
|
|
14224
|
-
//
|
|
14679
|
+
// Keep the ring aligned with the counter text: both reflect the 1-indexed
|
|
14680
|
+
// "current step" (completed + 1, capped at total). Otherwise the ring lags
|
|
14681
|
+
// one step behind whenever a task is in_progress (e.g. text "6/6" but ring 5/6).
|
|
14225
14682
|
var ring = document.getElementById("todo-progress-ring");
|
|
14226
14683
|
if (ring) {
|
|
14227
|
-
var ratio = todos.length > 0 ?
|
|
14684
|
+
var ratio = todos.length > 0 ? currentStep / todos.length : 0;
|
|
14228
14685
|
ring.style.setProperty("--progress", ratio.toFixed(3));
|
|
14229
14686
|
}
|
|
14230
14687
|
|