diff --git a/docs/contracts/generated/chat-event-graph.json b/docs/contracts/generated/chat-event-graph.json
index 24c31af70..78e05c68e 100644
--- a/docs/contracts/generated/chat-event-graph.json
+++ b/docs/contracts/generated/chat-event-graph.json
@@ -326,9 +326,12 @@
"modules/ui-system/appearance-studio.js",
"modules/ui-system/ask-nova-modal.js",
"modules/ui-system/avatar-picker.js",
+ "modules/ui-system/chat-back-to-bottom.js",
+ "modules/ui-system/chat-composer-inset.js",
"modules/ui-system/component-manifest.js",
"modules/ui-system/component-showcase.js",
"modules/ui-system/contribution-registry.js",
+ "modules/ui-system/conversation-turn-navigator.js",
"modules/ui-system/interactive-chat-app.js",
"modules/ui-system/lifecycle-inspector.js",
"modules/ui-system/lifecycle-scope.js",
@@ -3034,6 +3037,11 @@
}
],
"consumers": [
+ {
+ "file": "renderer.js",
+ "line": 662,
+ "kind": "custom-event-listener"
+ },
{
"file": "modules/event-listeners.js",
"line": 586,
@@ -3061,6 +3069,12 @@
}
],
"evidence": [
+ {
+ "file": "renderer.js",
+ "line": 662,
+ "kind": "custom-event-listener",
+ "match": "addEventListener('global-settings-updated'"
+ },
{
"file": "modules/event-listeners.js",
"line": 586,
@@ -4509,7 +4523,7 @@
"producers": [
{
"file": "modules/ui-helpers.js",
- "line": 599,
+ "line": 639,
"kind": "custom-event-dispatch"
}
],
@@ -4567,7 +4581,7 @@
},
{
"file": "modules/ui-helpers.js",
- "line": 599,
+ "line": 639,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('modal-ready'"
},
@@ -4584,12 +4598,12 @@
"producers": [
{
"file": "modules/ui-helpers.js",
- "line": 608,
+ "line": 648,
"kind": "custom-event-dispatch"
},
{
"file": "modules/ui-helpers.js",
- "line": 630,
+ "line": 670,
"kind": "custom-event-dispatch"
}
],
@@ -4613,13 +4627,13 @@
"evidence": [
{
"file": "modules/ui-helpers.js",
- "line": 608,
+ "line": 648,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('modal-visibility-changed'"
},
{
"file": "modules/ui-helpers.js",
- "line": 630,
+ "line": 670,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('modal-visibility-changed'"
},
@@ -5124,7 +5138,7 @@
"consumers": [
{
"file": "renderer.js",
- "line": 602,
+ "line": 606,
"kind": "preload-subscription"
},
{
@@ -5136,7 +5150,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 602,
+ "line": 606,
"kind": "preload-subscription",
"match": "onStreamingEventConsumer("
},
@@ -5914,14 +5928,14 @@
"consumers": [
{
"file": "modules/ui-helpers.js",
- "line": 347,
+ "line": 359,
"kind": "custom-event-listener"
}
],
"evidence": [
{
"file": "modules/ui-helpers.js",
- "line": 347,
+ "line": 359,
"kind": "custom-event-listener",
"match": "addEventListener('pointercancel'"
}
@@ -7476,14 +7490,14 @@
"consumers": [
{
"file": "modules/ui-helpers.js",
- "line": 335,
+ "line": 347,
"kind": "custom-event-listener"
}
],
"evidence": [
{
"file": "modules/ui-helpers.js",
- "line": 335,
+ "line": 347,
"kind": "custom-event-listener",
"match": "addEventListener('touchcancel'"
}
@@ -7635,7 +7649,7 @@
"producers": [
{
"file": "renderer.js",
- "line": 261,
+ "line": 265,
"kind": "custom-event-dispatch"
}
],
@@ -7649,7 +7663,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 261,
+ "line": 265,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('vcp-appearance-studio-configure'"
},
@@ -7666,7 +7680,7 @@
"producers": [
{
"file": "renderer.js",
- "line": 251,
+ "line": 255,
"kind": "custom-event-dispatch"
}
],
@@ -7680,7 +7694,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 251,
+ "line": 255,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('vcp-appearance-studio-dispose'"
},
@@ -7716,7 +7730,7 @@
"producers": [
{
"file": "renderer.js",
- "line": 571,
+ "line": 575,
"kind": "custom-event-dispatch"
}
],
@@ -7724,7 +7738,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 571,
+ "line": 575,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('vcp-chat-stream-terminal'"
}
@@ -8014,7 +8028,7 @@
"producers": [
{
"file": "renderer.js",
- "line": 802,
+ "line": 837,
"kind": "custom-event-dispatch"
}
],
@@ -8028,7 +8042,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 802,
+ "line": 837,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('vcp-main-chat-commands-configure'"
},
@@ -8045,7 +8059,7 @@
"producers": [
{
"file": "renderer.js",
- "line": 981,
+ "line": 1016,
"kind": "custom-event-dispatch"
}
],
@@ -8053,7 +8067,7 @@
"evidence": [
{
"file": "renderer.js",
- "line": 981,
+ "line": 1016,
"kind": "custom-event-dispatch",
"match": "dispatchEvent(new CustomEvent('vcp-renderer-ready'"
}
diff --git a/main.html b/main.html
index 6f6d9bbd4..a8aeed3ef 100755
--- a/main.html
+++ b/main.html
@@ -37,6 +37,9 @@
+
+
+
diff --git a/modules/searchManager.js b/modules/searchManager.js
index c4a91ae4e..851ae530e 100644
--- a/modules/searchManager.js
+++ b/modules/searchManager.js
@@ -576,6 +576,8 @@ const searchManager = {
const messageEl = document.querySelector(`.message-item[data-message-id='${message.id}']`);
if (messageEl) {
+ // 切话题会重新打开粘底跟随;不先释放,长话题里平滑滚动途中消息增高,会被拽回底部。
+ this.uiHelper.releaseChatScrollFollow?.();
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageEl.classList.add('message-highlight');
setTimeout(() => {
diff --git a/modules/settings/schema/appearance-settings.js b/modules/settings/schema/appearance-settings.js
index 4091ffd12..af8db6581 100644
--- a/modules/settings/schema/appearance-settings.js
+++ b/modules/settings/schema/appearance-settings.js
@@ -225,6 +225,13 @@ export const appearanceSettingsSection = section('appearance-settings', '界面
when: ['chatPresentationModeBubble', 'enableUserChatBubbleUi'],
save: { present: true },
}),
+ switchField('enableTurnNavigator', {
+ label: '显示提问导航条',
+ hint: '聊天区左侧每条提问一根短横条,悬停预览、点击跳转。聊天区宽度不足 864px 时自动隐藏。',
+ hintInsideWrapper: true,
+ checked: true,
+ save: { present: true },
+ }),
numberCells('chatBubbleWideWidth', {
label: '宽屏模式自定义宽度(%)',
hint: '可设置范围为 50% - 98%。标准模式沿用系统默认宽度,这里只调整气泡模式的宽屏布局。',
diff --git a/modules/ui-helpers.js b/modules/ui-helpers.js
index a239d1001..d1c97ecd7 100644
--- a/modules/ui-helpers.js
+++ b/modules/ui-helpers.js
@@ -13,6 +13,7 @@
const textareaResizeStates = new WeakMap();
const chatScrollStates = new WeakMap();
const CHAT_BOTTOM_THRESHOLD_PX = 50;
+ const CHAT_FOLLOW_CHANGE_EVENT = 'vcp-chat-follow-change';
const REGEX_CACHE_MAX_ENTRIES = 512;
const regexCompileCache = new Map();
const filePreviewIconMarkup = `
@@ -272,6 +273,17 @@
return getDistanceFromChatBottom(container) <= threshold;
}
+ // 跟随状态每次真正翻转都在滚动容器上派发 CHAT_FOLLOW_CHANGE_EVENT;
+ // 「回到底部」按钮这类界面只订阅事件,不直接读写内部状态。
+ function setChatFollowBottom(container, state, followBottom) {
+ if (state.followBottom === followBottom) return;
+ state.followBottom = followBottom;
+ const EventCtor = container.ownerDocument?.defaultView?.CustomEvent;
+ if (typeof EventCtor === 'function') {
+ container.dispatchEvent(new EventCtor(CHAT_FOLLOW_CHANGE_EVENT, { detail: { followBottom } }));
+ }
+ }
+
function getChatScrollState(container) {
let state = chatScrollStates.get(container);
if (state) return state;
@@ -313,7 +325,7 @@
if (!container.isConnected || state.generation !== expectedGeneration) return;
const nearBottom = isChatNearBottom(container);
// 用户主动回到底部时重新授权持续跟随;离开底部则保持关闭。
- state.followBottom = nearBottom;
+ setChatFollowBottom(container, state, nearBottom);
state.userScrollActive = false;
});
};
@@ -324,7 +336,7 @@
// 向上滚轮是明确的解锁意图。保持 userScrollActive,直到后续
// 向下滚动结算;这样该滚轮默认行为产生的近底部 scroll 事件
// 不会在同一轮事件中立刻把 followBottom 改回 true。
- state.followBottom = false;
+ setChatFollowBottom(container, state, false);
} else {
settleUserScrollIntent();
}
@@ -356,7 +368,7 @@
// 更早到达的向上滚轮解锁意图。重新开启统一交给向下滚轮、
// touchend 或滚动条 pointerup 的代际保护结算。
if (!isChatNearBottom(container)) {
- state.followBottom = false;
+ setChatFollowBottom(container, state, false);
}
return;
}
@@ -365,7 +377,7 @@
// 只有真正到达底部(而非落入 50px 追踪阈值)才重新开启跟随。
// 这同时防止小幅向上滚轮产生的 scroll 事件发生近底部竞态。
if (getDistanceFromChatBottom(container) <= 1) {
- state.followBottom = true;
+ setChatFollowBottom(container, state, true);
}
}, { passive: true });
@@ -425,7 +437,7 @@
if (!container) return;
const state = getChatScrollState(container);
state.generation += 1;
- state.followBottom = true;
+ setChatFollowBottom(container, state, true);
state.programmatic = false;
state.userScrollActive = false;
state.requestedGeneration = null;
@@ -439,6 +451,34 @@
}
};
+ /**
+ * Releases bottom-follow before a programmatic jump (search result, turn
+ * navigator). The jump itself is not user input, so follow would stay on and
+ * content-visibility messages that grow while the jump passes them would let
+ * the ResizeObserver pull the view back to the bottom. Reaching the bottom
+ * again re-enables follow as usual.
+ */
+ uiHelperFunctions.releaseChatScrollFollow = function() {
+ const container = getChatScrollContainer();
+ if (!container) return;
+ const state = getChatScrollState(container);
+ state.generation += 1;
+ state.programmatic = false;
+ state.userScrollActive = false;
+ state.requestedGeneration = null;
+ if (state.frameId) {
+ cancelAnimationFrame(state.frameId);
+ state.frameId = 0;
+ }
+ if (state.layoutFrameId) {
+ cancelAnimationFrame(state.layoutFrameId);
+ state.layoutFrameId = 0;
+ }
+ setChatFollowBottom(container, state, false);
+ };
+
+ uiHelperFunctions.CHAT_FOLLOW_CHANGE_EVENT = CHAT_FOLLOW_CHANGE_EVENT;
+
/**
* Scrolls the chat Surface to the bottom when bottom-follow is active.
* Calls in one paint frame are normally coalesced. `force` bypasses the
@@ -465,7 +505,7 @@
return false;
}
- state.followBottom = true;
+ setChatFollowBottom(container, state, true);
state.requestedGeneration = expectedGeneration ?? state.generation;
const commitScroll = () => {
@@ -487,7 +527,7 @@
// 之间完成解码并再次撑高内容;只要用户没有产生新滚动意图,
// ResizeObserver 会继续补偿到新的底部。
if (container.isConnected && isChatNearBottom(container)) {
- state.followBottom = true;
+ setChatFollowBottom(container, state, true);
}
});
};
diff --git a/modules/ui-system/chat-back-to-bottom.js b/modules/ui-system/chat-back-to-bottom.js
new file mode 100644
index 000000000..f990bb92a
--- /dev/null
+++ b/modules/ui-system/chat-back-to-bottom.js
@@ -0,0 +1,109 @@
+/**
+ * modules/ui-system/chat-back-to-bottom.js
+ * 主聊天的「回到底部」圆钮:离开底部(粘底跟随关闭)时浮在输入区上沿正中,点一下回到最新消息并重新跟随。
+ *
+ * 显隐规则和位置照 ZCode 的 ConversationBackToBottomButton
+ * (https://github.com/zai-org/ZCode ,Apache-2.0,packages/ui/src/v4/ConversationTimeline.tsx):
+ * 不在跟随、且有消息时显示;挂在输入区上方 8px、水平居中。
+ * 跟随状态只由 ui-helpers.js 持有:这里订阅它在滚动容器上派发的 vcp-chat-follow-change,
+ * 点击时走 resetChatScrollFollow + scrollToBottom,不自己判断几何位置。
+ */
+
+'use strict';
+
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const DEFAULT_FOLLOW_CHANGE_EVENT = 'vcp-chat-follow-change';
+
+function createArrowDownIcon(doc) {
+ // lucide arrow-down
+ const svg = doc.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('width', '16');
+ svg.setAttribute('height', '16');
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '2');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.setAttribute('aria-hidden', 'true');
+ for (const d of ['M12 5v14', 'm19 12-7 7-7-7']) {
+ const path = doc.createElementNS(SVG_NS, 'path');
+ path.setAttribute('d', d);
+ svg.appendChild(path);
+ }
+ return svg;
+}
+
+export function createChatBackToBottom({
+ document: doc = document,
+ uiHelper = null,
+ scroller = null,
+ messagesRoot = null,
+ dock = null,
+ label = '回到底部'
+} = {}) {
+ const cleanups = [];
+ let button = null;
+ let messages = messagesRoot;
+ let following = true;
+
+ function sync() {
+ if (!button) return;
+ button.hidden = following || !messages.querySelector(':scope > .message-item');
+ }
+
+ function mount() {
+ if (button) return button;
+ scroller = scroller || doc.querySelector('.chat-messages-container');
+ messages = messages || doc.getElementById('chatMessages');
+ dock = dock || doc.querySelector('.chat-input-area');
+ if (!uiHelper || !scroller || !messages || !dock) return null;
+
+ button = doc.createElement('button');
+ button.type = 'button';
+ button.className = 'vcp-back-to-bottom';
+ button.title = label;
+ button.setAttribute('aria-label', label);
+ button.hidden = true;
+ button.appendChild(createArrowDownIcon(doc));
+ const onClick = () => {
+ uiHelper.resetChatScrollFollow?.();
+ uiHelper.scrollToBottom?.({ force: true });
+ };
+ button.addEventListener('click', onClick);
+ cleanups.push(() => button.removeEventListener('click', onClick));
+ dock.prepend(button);
+
+ const eventName = uiHelper.CHAT_FOLLOW_CHANGE_EVENT || DEFAULT_FOLLOW_CHANGE_EVENT;
+ const onFollowChange = (event) => {
+ following = event.detail?.followBottom !== false;
+ sync();
+ };
+ scroller.addEventListener(eventName, onFollowChange);
+ cleanups.push(() => scroller.removeEventListener(eventName, onFollowChange));
+
+ // 换话题 / 清空时消息被整体替换,只看直接子节点增删即可
+ const MutationObserverCtor = doc.defaultView?.MutationObserver;
+ if (typeof MutationObserverCtor === 'function') {
+ const observer = new MutationObserverCtor(sync);
+ observer.observe(messages, { childList: true });
+ cleanups.push(() => observer.disconnect());
+ }
+
+ following = uiHelper.captureChatScrollFollow?.().followBottom !== false;
+ sync();
+ return button;
+ }
+
+ function dispose() {
+ while (cleanups.length) cleanups.pop()();
+ button?.remove();
+ button = null;
+ }
+
+ return {
+ mount,
+ dispose,
+ get element() { return button; }
+ };
+}
diff --git a/modules/ui-system/chat-composer-inset.js b/modules/ui-system/chat-composer-inset.js
new file mode 100644
index 000000000..61eb8225e
--- /dev/null
+++ b/modules/ui-system/chat-composer-inset.js
@@ -0,0 +1,75 @@
+/**
+ * modules/ui-system/chat-composer-inset.js
+ * 让主聊天的滚动区一直延伸到窗口底部,输入区浮在它的底部之上(滚动条也随之到底)。
+ *
+ * 做法照 ZCode 的 composer dock(https://github.com/zai-org/ZCode ,Apache-2.0,
+ * packages/ui/src/v4/ConversationTimeline.tsx):消息层在输入区所在的底部一段完全透明,
+ * 其上淡出一小段,滚动条不受遮罩影响。这里不改 DOM 结构,只把输入区的实时高度写成
+ * 上的 CSS 变量,由 styles/ui-system/chat-composer-inset.css 叠放和留白;
+ * 脚本没有挂载时布局与原来完全一样。
+ */
+
+'use strict';
+
+const OVERLAY_CLASS = 'vcp-chat-composer-overlay';
+const INSET_VAR = '--vcp-chat-composer-inset';
+const SCROLLBAR_VAR = '--vcp-chat-scrollbar-size';
+
+export function createChatComposerInset({
+ document: doc = document,
+ uiHelper = null,
+ root = null,
+ scroller = null,
+ dock = null
+} = {}) {
+ const cleanups = [];
+ let mounted = false;
+ let lastInset = -1;
+ let lastScrollbar = -1;
+
+ function sync() {
+ const inset = Math.ceil(dock.getBoundingClientRect().height);
+ const scrollbar = Math.max(0, scroller.offsetWidth - scroller.clientWidth);
+ if (scrollbar !== lastScrollbar) {
+ lastScrollbar = scrollbar;
+ root.style.setProperty(SCROLLBAR_VAR, `${scrollbar}px`);
+ }
+ if (inset === lastInset) return;
+ // 输入框变高时留白跟着变,原本贴底的视图要继续贴底,否则最后一条会被盖住
+ const following = uiHelper?.captureChatScrollFollow?.().followBottom === true;
+ lastInset = inset;
+ root.style.setProperty(INSET_VAR, `${inset}px`);
+ if (following) uiHelper.scrollToBottom?.({ force: true, immediate: true });
+ }
+
+ function mount() {
+ if (mounted) return true;
+ root = root || doc.querySelector('main.main-content');
+ scroller = scroller || root?.querySelector(':scope > .chat-messages-container');
+ dock = dock || root?.querySelector(':scope > .chat-input-area');
+ const ResizeObserverCtor = doc.defaultView?.ResizeObserver;
+ if (!root || !scroller || !dock || typeof ResizeObserverCtor !== 'function') return false;
+
+ mounted = true;
+ root.classList.add(OVERLAY_CLASS);
+ sync();
+ const observer = new ResizeObserverCtor(sync);
+ observer.observe(dock);
+ observer.observe(scroller);
+ cleanups.push(() => observer.disconnect());
+ return true;
+ }
+
+ function dispose() {
+ while (cleanups.length) cleanups.pop()();
+ if (!mounted) return;
+ mounted = false;
+ root.classList.remove(OVERLAY_CLASS);
+ root.style.removeProperty(INSET_VAR);
+ root.style.removeProperty(SCROLLBAR_VAR);
+ lastInset = -1;
+ lastScrollbar = -1;
+ }
+
+ return { mount, dispose };
+}
diff --git a/modules/ui-system/conversation-turn-navigator.js b/modules/ui-system/conversation-turn-navigator.js
new file mode 100644
index 000000000..6001f648e
--- /dev/null
+++ b/modules/ui-system/conversation-turn-navigator.js
@@ -0,0 +1,648 @@
+/**
+ * modules/ui-system/conversation-turn-navigator.js
+ * 主聊天左侧的「按提问快速定位」导轨:每条用户提问一根短横条,悬停的那根像山峰一样放大,
+ * 悬停卡片里是这条提问和助手回答的开头,点击滚动到对应位置(近处平滑滚动,远处直接到位)。
+ *
+ * 结构、交互和数值对照 ZCode 的 ConversationTurnNavigator / conversationTurnNavigatorHelpers
+ * (https://github.com/zai-org/ZCode ,Apache-2.0,packages/ui/src/v4),
+ * 由 React + Tailwind 改写为原生 DOM + styles/ui-system/turn-navigator.css。
+ * ZCode 的数据来自虚拟列表的 render unit;这里直接观察主聊天的消息 DOM:
+ * 每个 .message-item.user 是一个导航项,它到下一条用户消息之间的助手消息提供回答摘要。
+ */
+
+'use strict';
+
+
+export const TURN_NAVIGATOR_MIN_WIDTH_PX = 864; // 同 ZCode:聊天列不够宽就隐藏,不挤占消息
+const DEFAULT_MAX_PREVIEW_CHARS = 220;
+const DEFAULT_MAX_PREVIEW_PARAGRAPHS = 2;
+const HOVER_OPEN_DELAY_MS = 120;
+const HOVER_CLOSE_DELAY_MS = 80;
+const REFRESH_DEBOUNCE_MS = 150;
+const JUMP_TOP_MARGIN_PX = 12;
+const JUMP_RELEASE_FOLLOW_MIN_PX = 80;
+const JUMP_SMOOTH_MAX_VIEWPORTS = 1.5; // 更远的跳转直接到位:平滑滚过上万像素的长回答会逐条渲染、卡顿,目标也会过期
+const JUMP_HOLD_MS = 1200; // 落地后这么久内目标被挤走就拉回来
+const JUMP_SMOOTH_STILL_FRAMES = 3; // 平滑滚动连续这么多帧没动才算结束(第一帧它可能还没起步)
+const USER_FALLBACK_PREVIEW = '(没有文字内容)';
+const ASSISTANT_EMPTY_PREVIEW = '还没有回答';
+const ASSISTANT_RUNNING_PREVIEW = '正在回答…';
+
+// ------------------------------------------------------------------ pure helpers
+
+function normalizePreviewParagraphs(text, maxParagraphs) {
+ return String(text || '')
+ .trim()
+ .split(/\n\s*\n/u)
+ .map(paragraph => paragraph.replace(/\s+/gu, ' ').trim())
+ .filter(Boolean)
+ .slice(0, Math.max(1, maxParagraphs));
+}
+
+function truncatePreview(text, maxChars) {
+ const limit = Math.max(8, maxChars);
+ if (text.length <= limit) return text;
+ return `${text.slice(0, limit - 3).trimEnd()}...`;
+}
+
+export function buildPreviewText(texts, fallback, {
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS,
+ maxPreviewParagraphs = DEFAULT_MAX_PREVIEW_PARAGRAPHS
+} = {}) {
+ const paragraphs = normalizePreviewParagraphs(texts.join('\n\n'), maxPreviewParagraphs);
+ if (paragraphs.length === 0) return fallback;
+ return truncatePreview(paragraphs.join('\n'), maxPreviewChars);
+}
+
+/**
+ * entries: [{ id, userText, assistantTexts: string[], running }],按出现顺序。
+ * 没有用户提问就没有导航项;助手摘要取该提问到下一条提问之间的文字。
+ */
+export function buildTurnNavigatorItems(entries, options = {}) {
+ return (entries || []).map((entry, index) => buildTurnNavigatorItem(entry, index, options));
+}
+
+export function buildTurnNavigatorItem(entry, index, options = {}) {
+ const assistantTexts = (entry.assistantTexts || []).filter(text => String(text || '').trim());
+ let assistantPreview;
+ let assistantPreviewKind;
+ if (assistantTexts.length > 0) {
+ assistantPreview = buildPreviewText(assistantTexts, ASSISTANT_EMPTY_PREVIEW, options);
+ assistantPreviewKind = 'text';
+ } else if (entry.running) {
+ assistantPreview = ASSISTANT_RUNNING_PREVIEW;
+ assistantPreviewKind = 'running';
+ } else {
+ assistantPreview = ASSISTANT_EMPTY_PREVIEW;
+ assistantPreviewKind = 'empty';
+ }
+ return {
+ key: String(entry.id ?? index),
+ index,
+ userPreview: buildPreviewText([entry.userText || ''], USER_FALLBACK_PREVIEW, options),
+ assistantPreview,
+ assistantPreviewKind,
+ isRunning: Boolean(entry.running)
+ };
+}
+
+function finiteNonNegative(value) {
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
+}
+
+/**
+ * positions: [{ key, start, end }](相对滚动容器内容的像素)。
+ * 视口里可见的提问中取离视口顶部最近的;一个都看不见就取视口上方最近的一条,再不行取下方第一条。
+ */
+export function resolveActiveTurnKey({ positions, scrollOffsetPx, viewportHeightPx }) {
+ if (!positions?.length) return undefined;
+ const viewportStart = finiteNonNegative(scrollOffsetPx);
+ const viewportEnd = viewportStart + Math.max(1, finiteNonNegative(viewportHeightPx));
+ const normalized = positions
+ .map(position => {
+ const start = finiteNonNegative(position.start);
+ return { key: position.key, start, end: Math.max(start, finiteNonNegative(position.end)) };
+ })
+ .sort((left, right) => left.start - right.start);
+
+ const visible = normalized.filter(position => position.end >= viewportStart && position.start <= viewportEnd);
+ if (visible.length > 0) {
+ return visible.reduce((nearest, candidate) =>
+ Math.abs(candidate.start - viewportStart) < Math.abs(nearest.start - viewportStart) ? candidate : nearest).key;
+ }
+ const above = normalized.filter(position => position.start <= viewportStart);
+ return above.length ? above[above.length - 1].key : normalized.find(position => position.start > viewportStart)?.key;
+}
+
+/**
+ * resolveActiveTurnKey 的长话题版本:提问在 DOM 里按顺序排列、互不重叠,起点单调递增,
+ * 所以二分查找视口顶部附近的两条就够了,measure(index) 只会被调用 O(log n) 次(上千条提问时每帧不必量遍全部)。
+ * 返回导航项下标,没有提问时为 -1。
+ */
+export function resolveActiveTurnIndex({ count, measure, scrollOffsetPx, viewportHeightPx }) {
+ if (!(count > 0)) return -1;
+ const viewportStart = finiteNonNegative(scrollOffsetPx);
+ const viewportEnd = viewportStart + Math.max(1, finiteNonNegative(viewportHeightPx));
+ const read = (index) => {
+ const position = measure(index) || {};
+ const start = finiteNonNegative(position.start);
+ return { start, end: Math.max(start, finiteNonNegative(position.end)) };
+ };
+ let low = 0;
+ let high = count; // 找第一条起点在视口顶部之下的提问
+ while (low < high) {
+ const middle = (low + high) >> 1;
+ if (read(middle).start > viewportStart) high = middle;
+ else low = middle + 1;
+ }
+ const above = low - 1;
+ const aboveVisible = above >= 0 && read(above).end >= viewportStart;
+ const belowVisible = low < count && read(low).start <= viewportEnd;
+ if (aboveVisible && belowVisible) {
+ return read(low).start - viewportStart < viewportStart - read(above).start ? low : above;
+ }
+ if (aboveVisible) return above;
+ if (belowVisible) return low;
+ return above >= 0 ? above : low;
+}
+
+/** 悬停/聚焦的那根放大成「山峰」,左右邻居依次递减。 */
+export function resolveBarVisualState({ itemIndex, visualFocusItemIndex }) {
+ if (visualFocusItemIndex === undefined) return { colorTone: 'muted', opacity: 0.58, scaleX: 1, tone: 'idle' };
+ const distance = Math.abs(itemIndex - visualFocusItemIndex);
+ // 横条平时 8px 宽(ZCode 是 12px);放大后的长度仍同 ZCode:约 31 / 20 / 15px
+ if (distance === 0) return { colorTone: 'focus', opacity: 1, scaleX: 3.9, tone: 'peak' };
+ if (distance === 1) return { colorTone: 'muted', opacity: 0.86, scaleX: 2.55, tone: 'near' };
+ if (distance === 2) return { colorTone: 'muted', opacity: 0.72, scaleX: 1.875, tone: 'mid' };
+ return { colorTone: 'muted', opacity: 0.58, scaleX: 1, tone: 'idle' };
+}
+
+// ------------------------------------------------------------------ DOM reading
+
+function readMessageText(messageItem) {
+ const content = messageItem.querySelector('.md-content');
+ if (!content) return '';
+ const clone = content.cloneNode(true);
+ clone.querySelectorAll('.thinking-indicator, style, script, .vcp-tool-use-bubble, details').forEach(node => node.remove());
+ return clone.textContent || '';
+}
+
+/**
+ * 把消息列表读成 buildTurnNavigatorItems 需要的 entries。
+ * textCache(WeakMap)+ dirty(Set)让没有变化的消息不必每次都克隆一遍 DOM:
+ * 只有被标记为 dirty 的消息才会重新读取文字。
+ */
+export function collectTurnEntries(messagesRoot, { textCache = null, dirty = null } = {}) {
+ const textOf = (element) => {
+ if (!textCache) return readMessageText(element);
+ if (!dirty?.has(element) && textCache.has(element)) return textCache.get(element);
+ const text = readMessageText(element);
+ textCache.set(element, text);
+ dirty?.delete(element);
+ return text;
+ };
+ const entries = [];
+ let current = null;
+ for (const child of Array.from(messagesRoot?.children || [])) {
+ if (!child.classList?.contains('message-item')) continue;
+ if (child.classList.contains('user')) {
+ current = {
+ id: child.dataset.messageId || `idx-${entries.length}`,
+ element: child,
+ userText: textOf(child),
+ assistantTexts: [],
+ running: false
+ };
+ entries.push(current);
+ } else if (child.classList.contains('assistant') && current) {
+ current.assistantTexts.push(textOf(child));
+ if (child.classList.contains('streaming')) current.running = true;
+ }
+ }
+ return entries;
+}
+
+// ------------------------------------------------------------------ controller
+
+export function createConversationTurnNavigator({
+ document: doc = document,
+ messagesRoot = null,
+ scrollRoot = null,
+ minWidthPx = TURN_NAVIGATOR_MIN_WIDTH_PX,
+ releaseFollow = null // 主聊天传 uiHelperFunctions.releaseChatScrollFollow
+} = {}) {
+ const win = doc.defaultView;
+ const cleanups = [];
+ let disposed = false;
+ let mounted = false;
+ let nav = null;
+ let railScroll = null;
+ let railInner = null;
+ let card = null;
+ let cardUser = null;
+ let cardAssistant = null;
+ let messages = messagesRoot;
+ let scroller = scrollRoot;
+ let entries = [];
+ let items = [];
+ let signature = '';
+ let buttons = [];
+ let buttonsByKey = new Map();
+ let itemCache = new Map(); // 导航项 key → { entry, item }:文字没变就不重算摘要
+ const appliedVisuals = new WeakMap();
+ let activeKey;
+ let interactionIndex;
+ let cardIndex = -1;
+ let openTimer = null;
+ let closeTimer = null;
+ let refreshTimer = null;
+ let jumpHold = null;
+ let frame = null;
+ let reducedMotion = false;
+ const textCache = new WeakMap();
+ const dirty = new Set();
+
+ const h = (tag, className) => {
+ const node = doc.createElement(tag);
+ if (className) node.className = className;
+ return node;
+ };
+
+ function activeIndex() {
+ return items.findIndex(item => item.key === activeKey);
+ }
+
+ // 上千根时只改真正变了的那几根,悬停移动不必每次重写全部样式
+ function applyVisuals() {
+ const active = activeIndex();
+ buttons.forEach((button, index) => {
+ const item = items[index];
+ const state = resolveBarVisualState({ itemIndex: index, visualFocusItemIndex: interactionIndex });
+ const scrollActive = interactionIndex === undefined && index === active;
+ const applied = `${state.tone}|${scrollActive}|${index === active}|${item.isRunning}`;
+ if (appliedVisuals.get(button) === applied) return;
+ appliedVisuals.set(button, applied);
+ const bar = button.firstChild;
+ bar.style.opacity = String(scrollActive ? 0.9 : item.isRunning ? Math.max(state.opacity, 0.72) : state.opacity);
+ bar.style.transform = `scaleX(${state.scaleX})`;
+ bar.classList.toggle('is-strong', state.colorTone === 'focus' || scrollActive);
+ button.dataset.visualTone = state.tone;
+ button.dataset.visualScale = String(state.scaleX);
+ button.dataset.active = index === active ? 'true' : 'false';
+ button.dataset.running = item.isRunning ? 'true' : 'false';
+ if (index === active) button.setAttribute('aria-current', 'location');
+ else button.removeAttribute('aria-current');
+ });
+ }
+
+ function setInteraction(index) {
+ interactionIndex = index;
+ applyVisuals();
+ }
+
+ // -------------------------------------------------------------- hover card
+
+ function showCard(index) {
+ const item = items[index];
+ const button = buttons[index];
+ if (!item || !button) return;
+ cardIndex = index;
+ cardUser.textContent = item.userPreview;
+ cardAssistant.textContent = item.assistantPreview;
+ cardAssistant.classList.toggle('is-muted', item.assistantPreviewKind !== 'text');
+ const navRect = nav.getBoundingClientRect();
+ const buttonRect = button.getBoundingClientRect();
+ card.style.top = `${Math.round(buttonRect.top - navRect.top + buttonRect.height / 2)}px`;
+ card.hidden = false;
+ card.dataset.key = item.key;
+ }
+
+ function hideCard() {
+ cardIndex = -1;
+ card.hidden = true;
+ }
+
+ function scheduleCard(index) {
+ win.clearTimeout(closeTimer);
+ closeTimer = null;
+ if (!card.hidden) { // 已经开着:直接换成这一条,不再等待
+ win.clearTimeout(openTimer);
+ showCard(index);
+ return;
+ }
+ win.clearTimeout(openTimer);
+ openTimer = win.setTimeout(() => showCard(index), HOVER_OPEN_DELAY_MS);
+ }
+
+ function scheduleHideCard() {
+ win.clearTimeout(openTimer);
+ openTimer = null;
+ win.clearTimeout(closeTimer);
+ closeTimer = win.setTimeout(hideCard, HOVER_CLOSE_DELAY_MS);
+ }
+
+ // -------------------------------------------------------------- rendering
+
+ function createButton(key) {
+ const button = h('button', 'vcp-turn-nav-item');
+ button.type = 'button';
+ button.dataset.turnKey = key;
+ button.appendChild(h('span', 'vcp-turn-nav-bar'));
+ // 加载更早的消息时下标会整体后移,所以事件触发时再读当前下标
+ const indexOf = () => Number(button.dataset.itemIndex);
+ button.addEventListener('pointerenter', () => { setInteraction(indexOf()); scheduleCard(indexOf()); });
+ button.addEventListener('pointerleave', () => { setInteraction(undefined); scheduleHideCard(); });
+ button.addEventListener('focus', () => { setInteraction(indexOf()); scheduleCard(indexOf()); });
+ button.addEventListener('blur', () => { setInteraction(undefined); scheduleHideCard(); });
+ button.addEventListener('click', () => jumpTo(indexOf(), reducedMotion ? 'auto' : 'smooth'));
+ return button;
+ }
+
+ // 按 key 复用已有的横条,只增删变化的部分:长话题分批加载时不必每批重建上千个按钮
+ function syncButtons() {
+ const nextByKey = new Map();
+ const setSize = String(items.length);
+ buttons = items.map((item, index) => {
+ const button = buttonsByKey.get(item.key) || createButton(item.key);
+ nextByKey.set(item.key, button);
+ if (button.dataset.itemIndex !== String(index)) {
+ button.dataset.itemIndex = String(index);
+ button.setAttribute('aria-label', `跳转到第 ${index + 1} 条提问`);
+ button.setAttribute('aria-posinset', String(index + 1));
+ }
+ if (button.getAttribute('aria-setsize') !== setSize) button.setAttribute('aria-setsize', setSize);
+ return button;
+ });
+ buttonsByKey.forEach((button, key) => { if (!nextByKey.has(key)) button.remove(); });
+ buttonsByKey = nextByKey;
+ let cursor = railInner.firstChild;
+ for (const button of buttons) {
+ if (button === cursor) cursor = cursor.nextSibling;
+ else railInner.insertBefore(button, cursor);
+ }
+ nav.dataset.itemCount = setSize;
+ }
+
+ function buildItems(nextEntries) {
+ const nextCache = new Map();
+ const built = nextEntries.map((entry, index) => {
+ const key = String(entry.id ?? index);
+ const cached = itemCache.get(key)?.entry;
+ const same = cached
+ && cached.userText === entry.userText
+ && cached.running === entry.running
+ && cached.assistantTexts.length === entry.assistantTexts.length
+ && cached.assistantTexts.every((text, i) => text === entry.assistantTexts[i]);
+ const item = same ? { ...itemCache.get(key).item, index } : buildTurnNavigatorItem(entry, index);
+ nextCache.set(key, { entry, item });
+ return item;
+ });
+ itemCache = nextCache;
+ return built;
+ }
+
+ function revealActiveBar() {
+ const index = activeIndex();
+ const button = buttons[index];
+ if (!button || !railScroll.clientHeight) return;
+ const top = button.offsetTop;
+ const bottom = top + button.offsetHeight;
+ if (top < railScroll.scrollTop) railScroll.scrollTop = top;
+ else if (bottom > railScroll.scrollTop + railScroll.clientHeight) railScroll.scrollTop = bottom - railScroll.clientHeight;
+ }
+
+ // 输入区叠放在滚动区底部时(chat-composer-inset.js),底部留白那段被输入区挡住,不算可视区
+ function visibleHeight() {
+ const inset = parseFloat(win.getComputedStyle(scroller).paddingBottom) || 0;
+ return Math.max(0, scroller.clientHeight - inset);
+ }
+
+ function measureOverlay() {
+ if (!nav || !scroller) return;
+ nav.style.top = `${scroller.offsetTop}px`;
+ nav.style.height = `${visibleHeight()}px`;
+ const wide = scroller.clientWidth >= minWidthPx;
+ nav.classList.toggle('is-wide', wide);
+ if (!wide) hideCard();
+ }
+
+ function updateActive() {
+ frame = null;
+ if (disposed || !scroller) return;
+ const scrollerTop = scroller.getBoundingClientRect().top;
+ const scrollTop = scroller.scrollTop;
+ const measured = new Map();
+ const index = resolveActiveTurnIndex({
+ count: entries.length,
+ measure: (i) => {
+ if (!measured.has(i)) {
+ const rect = entries[i].element.getBoundingClientRect();
+ const start = rect.top - scrollerTop + scrollTop;
+ measured.set(i, { start, end: start + rect.height });
+ }
+ return measured.get(i);
+ },
+ scrollOffsetPx: scrollTop,
+ viewportHeightPx: visibleHeight()
+ });
+ const next = items[index]?.key;
+ if (next === activeKey) return;
+ activeKey = next;
+ applyVisuals();
+ revealActiveBar();
+ }
+
+ function scheduleActiveUpdate() {
+ if (frame !== null || disposed) return;
+ frame = win.requestAnimationFrame(updateActive);
+ }
+
+ function refresh() {
+ refreshTimer = null;
+ if (disposed || !mounted) return;
+ const cardKey = items[cardIndex]?.key;
+ const interactionKey = items[interactionIndex]?.key;
+ entries = collectTurnEntries(messages, { textCache, dirty });
+ items = buildItems(entries);
+ const nextSignature = items.map(item => item.key).join('|');
+ const show = items.length >= 2;
+ nav.hidden = !show;
+ if (!show) {
+ signature = '';
+ buttons = [];
+ buttonsByKey = new Map();
+ railInner.textContent = '';
+ interactionIndex = undefined;
+ hideCard();
+ return;
+ }
+ if (nextSignature !== signature) {
+ signature = nextSignature;
+ syncButtons();
+ if (!items.some(item => item.key === activeKey)) activeKey = undefined;
+ if (interactionKey !== undefined) {
+ const index = items.findIndex(item => item.key === interactionKey);
+ interactionIndex = index >= 0 ? index : undefined;
+ }
+ }
+ // 流式回答时悬停卡片里的摘要跟着更新;前面插入了更早的消息时按 key 找回原来那条
+ const nextCardIndex = cardKey === undefined ? -1 : items.findIndex(item => item.key === cardKey);
+ if (nextCardIndex >= 0) showCard(nextCardIndex);
+ else if (cardIndex >= 0) hideCard();
+ measureOverlay();
+ applyVisuals();
+ updateActive();
+ }
+
+ function markDirty(records) {
+ for (const record of records) {
+ const node = record.target?.nodeType === 1 ? record.target : record.target?.parentElement;
+ const item = node?.closest?.('.message-item');
+ if (item) dirty.add(item);
+ }
+ }
+
+ function onMutations(records) {
+ markDirty(records);
+ scheduleRefresh();
+ }
+
+ function scheduleRefresh() {
+ if (disposed || refreshTimer !== null) return;
+ refreshTimer = win.setTimeout(refresh, REFRESH_DEBOUNCE_MS);
+ }
+
+ function jumpTargetTop(entry) {
+ const scrollerTop = scroller.getBoundingClientRect().top;
+ return Math.max(0, entry.element.getBoundingClientRect().top - scrollerTop + scroller.scrollTop - JUMP_TOP_MARGIN_PX);
+ }
+
+ function stopJumpHold() {
+ if (!jumpHold) return;
+ win.cancelAnimationFrame(jumpHold.frame);
+ jumpHold = null;
+ }
+
+ // 把正在守位的提问拉回目标位置;越过动画以后才做,避免打断平滑滚动
+ function holdJumpTarget() {
+ const hold = jumpHold;
+ if (!hold || hold.smoothing) return;
+ if (disposed || !hold.entry.element.isConnected) { stopJumpHold(); return; }
+ // 夹到可滚动范围内,否则贴底时每帧都在写一个无效值、守位永远不结束
+ const want = Math.min(jumpTargetTop(hold.entry), Math.max(0, scroller.scrollHeight - scroller.clientHeight));
+ if (Math.abs(want - scroller.scrollTop) < 1) return;
+ scroller.scrollTop = want;
+ hold.until = win.performance.now() + JUMP_HOLD_MS; // 还在变就继续守
+ }
+
+ /**
+ * 长话题里(content-visibility: auto)屏幕外的消息进入视口才渲染出真实高度,一次算出的目标位置会过期;
+ * 聊天区的「粘底跟随」也会把跳转拽回底部(见 ui-helpers.js 的 releaseChatScrollFollow)。
+ * 所以:远处直接跳到位、近处才平滑滚动(ZCode 的 scrollToQuery 也是这样分的);
+ * 落地后消息列表每次变高都在绘制前把这条提问拉回原位(目标上方紧挨着的长回答渲染出来会把它往下挤,
+ * 浏览器的滚动锚定管不到),直到稳定一段时间或用户自己滚动。
+ */
+ function jumpTo(index, behavior) {
+ const entry = entries[index];
+ if (!entry?.element || !scroller) return;
+ stopJumpHold();
+ const top = jumpTargetTop(entry);
+ const maxTop = scroller.scrollHeight - scroller.clientHeight;
+ const released = maxTop - top > JUMP_RELEASE_FOLLOW_MIN_PX;
+ if (released) releaseFollow?.();
+ const smooth = behavior === 'smooth'
+ && Math.abs(top - scroller.scrollTop) <= scroller.clientHeight * JUMP_SMOOTH_MAX_VIEWPORTS;
+ scroller.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
+ if (!released) return; // 落在底部附近的交给粘底跟随,两边不能抢
+
+ // until 从第一帧算起:跳过去后渲染长回答的那一帧本身可能就超过一秒
+ const hold = { entry, frame: null, smoothing: smooth, still: 0, lastTop: scroller.scrollTop, until: null };
+ const tick = () => {
+ if (jumpHold !== hold) return;
+ const now = win.performance.now();
+ if (hold.until === null) hold.until = now + JUMP_HOLD_MS;
+ if (now > hold.until) { jumpHold = null; return; }
+ if (hold.smoothing) {
+ // 平滑动画还在走就不打断,停下来再开始守位
+ const current = scroller.scrollTop;
+ hold.still = current === hold.lastTop ? hold.still + 1 : 0;
+ hold.lastTop = current;
+ if (hold.still >= JUMP_SMOOTH_STILL_FRAMES) hold.smoothing = false;
+ hold.until = now + JUMP_HOLD_MS;
+ }
+ holdJumpTarget();
+ if (jumpHold === hold) hold.frame = win.requestAnimationFrame(tick);
+ };
+ jumpHold = hold;
+ hold.frame = win.requestAnimationFrame(tick);
+ }
+
+ // -------------------------------------------------------------- lifecycle
+
+ function on(target, type, handler, options) {
+ target.addEventListener(type, handler, options);
+ cleanups.push(() => target.removeEventListener(type, handler, options));
+ }
+
+ function mount() {
+ if (mounted || disposed) return nav;
+ messages = messages || doc.getElementById('chatMessages');
+ scroller = scroller || messages?.closest('.chat-messages-container') || null;
+ const host = scroller?.parentElement;
+ if (!messages || !scroller || !host) return null;
+ if (win.getComputedStyle(host).position === 'static') host.style.position = 'relative';
+
+ nav = h('nav', 'vcp-turn-nav vcp-ui-scope'); // 挂在 下,自身带作用域类才能命中 ui-system 样式
+ nav.setAttribute('aria-label', '提问目录');
+ nav.hidden = true;
+ railScroll = h('div', 'vcp-turn-nav-scroll');
+ railInner = h('div', 'vcp-turn-nav-inner');
+ railScroll.appendChild(railInner);
+ card = h('div', 'vcp-turn-nav-card');
+ card.hidden = true;
+ card.setAttribute('role', 'tooltip');
+ cardUser = h('p', 'vcp-turn-nav-card-user');
+ cardAssistant = h('p', 'vcp-turn-nav-card-assistant');
+ card.append(cardUser, cardAssistant);
+ nav.append(railScroll, card);
+ host.appendChild(nav);
+ mounted = true;
+
+ on(railScroll, 'pointerleave', () => setInteraction(undefined));
+ on(railScroll, 'scroll', () => { setInteraction(undefined); hideCard(); });
+ on(scroller, 'scroll', scheduleActiveUpdate, { passive: true });
+ // 用户自己滚动、按键或点了别处(回到底部、发送)就不再守位
+ on(scroller, 'wheel', stopJumpHold, { passive: true });
+ on(scroller, 'touchstart', stopJumpHold, { passive: true });
+ on(doc, 'pointerdown', stopJumpHold, { capture: true, passive: true });
+ on(doc, 'keydown', stopJumpHold, { capture: true });
+ on(card, 'pointerenter', () => { win.clearTimeout(closeTimer); closeTimer = null; });
+ on(card, 'pointerleave', scheduleHideCard);
+
+ if (typeof win.MutationObserver === 'function') {
+ const observer = new win.MutationObserver(onMutations);
+ observer.observe(messages, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
+ cleanups.push(() => observer.disconnect());
+ }
+ if (typeof win.ResizeObserver === 'function') {
+ const resizeObserver = new win.ResizeObserver(() => { measureOverlay(); scheduleActiveUpdate(); });
+ resizeObserver.observe(scroller);
+ cleanups.push(() => resizeObserver.disconnect());
+ // ResizeObserver 在布局之后、绘制之前回调,在这里拉回不会先画出被挤走的那一帧
+ const contentObserver = new win.ResizeObserver(holdJumpTarget);
+ contentObserver.observe(messages);
+ cleanups.push(() => contentObserver.disconnect());
+ }
+ if (typeof win.matchMedia === 'function') {
+ const query = win.matchMedia('(prefers-reduced-motion: reduce)');
+ const update = () => { reducedMotion = query.matches; };
+ update();
+ query.addEventListener?.('change', update);
+ cleanups.push(() => query.removeEventListener?.('change', update));
+ }
+
+ refresh();
+ return nav;
+ }
+
+ function dispose() {
+ if (disposed) return;
+ disposed = true;
+ win.clearTimeout(openTimer);
+ win.clearTimeout(closeTimer);
+ win.clearTimeout(refreshTimer);
+ stopJumpHold();
+ if (frame !== null) win.cancelAnimationFrame(frame);
+ while (cleanups.length) cleanups.pop()();
+ nav?.remove();
+ nav = null;
+ }
+
+ return {
+ mount,
+ dispose,
+ refresh: () => { win.clearTimeout(refreshTimer); refresh(); },
+ jumpTo,
+ getItems: () => items.slice()
+ };
+}
diff --git a/modules/utils/appSettingsManager.js b/modules/utils/appSettingsManager.js
index c0d856ac4..03b1a1653 100644
--- a/modules/utils/appSettingsManager.js
+++ b/modules/utils/appSettingsManager.js
@@ -317,6 +317,7 @@ class SettingsManager extends EventEmitter {
chatToolFontCustom: '',
enableUserChatBubbleUi: true,
showUserMetaInChatBubbleUi: true,
+ enableTurnNavigator: true,
minChunkBufferSize: 1,
smoothStreamIntervalMs: 25,
assistantAgent: '',
diff --git a/renderer.js b/renderer.js
index d82afb506..0357c3e22 100755
--- a/renderer.js
+++ b/renderer.js
@@ -18,6 +18,9 @@ import { createMainChatThemeOwner } from './modules/renderer/mainChatThemeOwner.
import { createMainChatSettingsPresentationOwner } from './modules/renderer/mainChatSettingsPresentationOwner.js';
import { createMainChatAttachmentOwner } from './modules/renderer/mainChatAttachmentOwner.js';
import { createMainChatSendOwner } from './modules/renderer/mainChatSendOwner.js';
+import { createConversationTurnNavigator } from './modules/ui-system/conversation-turn-navigator.js';
+import { createChatBackToBottom } from './modules/ui-system/chat-back-to-bottom.js';
+import { createChatComposerInset } from './modules/ui-system/chat-composer-inset.js';
const streamManager = createStreamProjection();
const messageRenderer = createMessageRenderer({ streamManager });
@@ -75,6 +78,7 @@ const mainChatSettingsOwner = createMainChatSettingsOwner({ initial: {
chatToolFontCustom: '',
enableUserChatBubbleUi: true,
showUserMetaInChatBubbleUi: true,
+ enableTurnNavigator: true,
voiceMode: 'local',
voiceInputMode: 'windows_voice_typing',
voiceInputShortcut: 'F7',
@@ -637,6 +641,37 @@ mainChatSettingsPresentationOwner.configureStartup({
ownedRendererSubscriptions.add({ dispose: () => composerModelSelect.dispose?.() });
}
+ // 「显示提问导航条」设置:关掉时整个卸载(不留观察器),设置加载或保存后按新值挂上或卸下
+ let conversationTurnNavigator = null;
+ const syncConversationTurnNavigator = () => {
+ const enabled = getGlobalSettings().enableTurnNavigator !== false;
+ if (enabled === (conversationTurnNavigator !== null)) return;
+ if (!enabled) {
+ conversationTurnNavigator.dispose();
+ conversationTurnNavigator = null;
+ return;
+ }
+ conversationTurnNavigator = createConversationTurnNavigator({
+ document,
+ messagesRoot: chatMessagesDiv,
+ releaseFollow: () => uiHelperFunctions.releaseChatScrollFollow?.()
+ });
+ conversationTurnNavigator.mount();
+ };
+ syncConversationTurnNavigator();
+ window.addEventListener('global-settings-updated', syncConversationTurnNavigator);
+ ownedRendererSubscriptions.add({ dispose: () => {
+ window.removeEventListener('global-settings-updated', syncConversationTurnNavigator);
+ conversationTurnNavigator?.dispose();
+ conversationTurnNavigator = null;
+ } });
+ const chatBackToBottom = createChatBackToBottom({ document, uiHelper: uiHelperFunctions, messagesRoot: chatMessagesDiv });
+ chatBackToBottom.mount();
+ ownedRendererSubscriptions.add({ dispose: () => chatBackToBottom.dispose() });
+ const chatComposerInset = createChatComposerInset({ document, uiHelper: uiHelperFunctions });
+ chatComposerInset.mount();
+ ownedRendererSubscriptions.add({ dispose: () => chatComposerInset.dispose() });
+
const auxiliaryEventOwner = createMainChatAuxiliaryEventOwner({
subscriptions: {
loomShareText: chatAPI?.onLoomShareTextToInput,
diff --git a/scripts/check-design-system-boundary.mjs b/scripts/check-design-system-boundary.mjs
index 6d74dc94e..4a8f44bc3 100644
--- a/scripts/check-design-system-boundary.mjs
+++ b/scripts/check-design-system-boundary.mjs
@@ -196,6 +196,7 @@ const allowedSourceDifferences = new Set([
'scripts/check-theme-provenance.mjs',
'tests/message-regeneration-stream-animation.test.js',
'tests/topic-summary-model.test.mjs',
+ 'tests/ui-helper-chat-scroll-follow.test.js',
'tests/ui-helpers-settings-close.test.js',
'tests/vcpchat-installer-contract.test.mjs',
'scripts/check-ui-async-state-matrix.mjs',
@@ -417,6 +418,9 @@ const allowedSourceDifferencePatterns = [
/^modules\/ui-system\/vcp-icons(?:\.MIT)?\.(?:js|txt)$/,
/^styles\/(?:base|messageRenderer)\.css$/,
/^tests\/notification-menu-controller\.test\.js$/,
+ // Conversation navigation: turn navigator rail, back-to-bottom button, composer overlay.
+ /^modules\/ui-system\/(?:conversation-turn-navigator|chat-back-to-bottom|chat-composer-inset)\.js$/,
+ /^tests\/(?:conversation-turn-navigator|chat-back-to-bottom|chat-composer-inset)\.test\.mjs$/,
/^docs\/(?:global-settings-section-ownership|settings-ui-pr-scope-2026-08-31)\.md$/,
/^docs\/research\/settings-schema-render-plan\.md$/,
/^docs\/archive\/2026-08-chat-kernel-and-ui-roadmaps\//,
diff --git a/scripts/check-ui-system.mjs b/scripts/check-ui-system.mjs
index a70231a8e..91145dece 100644
--- a/scripts/check-ui-system.mjs
+++ b/scripts/check-ui-system.mjs
@@ -137,6 +137,8 @@ const inlineStyleCompatibilityAllowlist = new Set([
path.join(moduleDir, 'settings', 'identity-controls.js'), // ColorPair preview mirrors the canonical color value.
path.join(moduleDir, 'settings', 'render-visibility.js'), // Legacy custom typography row visibility is an owned projection.
path.join(moduleDir, 'typed-field-owners.js'), // Settings snapshot projection updates canonical dependent rows.
+ path.join(moduleDir, 'conversation-turn-navigator.js'), // Rail bars magnify by pointer distance; rail and card follow the measured chat scroller.
+ path.join(moduleDir, 'chat-composer-inset.js'), // Publishes the measured composer height and scrollbar width as CSS variables.
]);
for (const file of filesIn(moduleDir, '.js')) {
diff --git a/styles/ui-system/chat-back-to-bottom.css b/styles/ui-system/chat-back-to-bottom.css
new file mode 100644
index 000000000..5a9c34606
--- /dev/null
+++ b/styles/ui-system/chat-back-to-bottom.css
@@ -0,0 +1,37 @@
+/*
+ * 主聊天「回到底部」圆钮(modules/ui-system/chat-back-to-bottom.js)。
+ * 版式对照 ZCode 的 ConversationBackToBottomButton(https://github.com/zai-org/ZCode ,Apache-2.0):
+ * 28px 圆形描边按钮、16px 下箭头,挂在输入区上沿上方 8px、水平居中。
+ */
+
+html .vcp-back-to-bottom:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ z-index: 30;
+ width: 28px;
+ height: 28px;
+ margin: 0 0 8px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--vcp-ui-border);
+ border-radius: var(--vcp-ui-radius-full);
+ color: var(--vcp-ui-text-0);
+ background: var(--vcp-ui-bg-1);
+ box-shadow: var(--vcp-ui-shadow-sm);
+ transform: translateX(-50%);
+ cursor: pointer;
+ transition: background-color 150ms ease-out, border-color 150ms ease-out;
+}
+html .vcp-back-to-bottom[hidden]:where(.vcp-ui-scope, .vcp-ui-scope *) { display: none; }
+html .vcp-back-to-bottom:hover:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ border-color: var(--vcp-ui-border-strong);
+ background: color-mix(in srgb, var(--vcp-ui-bg-1) 88%, var(--vcp-ui-text-0));
+}
+html .vcp-back-to-bottom:focus-visible:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ outline: 2px solid var(--vcp-ui-focus-ring);
+ outline-offset: 2px;
+}
+html .vcp-back-to-bottom svg:where(.vcp-ui-scope, .vcp-ui-scope *) { width: 16px; height: 16px; flex: 0 0 auto; }
diff --git a/styles/ui-system/chat-composer-inset.css b/styles/ui-system/chat-composer-inset.css
new file mode 100644
index 000000000..4aa29d9a7
--- /dev/null
+++ b/styles/ui-system/chat-composer-inset.css
@@ -0,0 +1,40 @@
+/*
+ * 主聊天输入区叠放在滚动区底部之上(modules/ui-system/chat-composer-inset.js 挂上
+ * .vcp-chat-composer-overlay 后才生效)。
+ * 对照 ZCode composer dock(https://github.com/zai-org/ZCode ,Apache-2.0):
+ * 滚动区与滚动条一直到底;消息在输入区所在的一段透明、其上淡出,滚动条那一列不加遮罩。
+ */
+
+html .vcp-chat-composer-overlay > .chat-input-area:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+html .vcp-chat-composer-overlay > .chat-messages-container:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ --vcp-chat-composer-fade: 20px;
+ /* 贴底时最后一条消息停在淡出带之上 */
+ padding-bottom: calc(var(--vcp-chat-composer-inset, 0px) + var(--vcp-chat-composer-fade));
+ scroll-padding-bottom: calc(var(--vcp-chat-composer-inset, 0px) + var(--vcp-chat-composer-fade));
+ -webkit-mask-image:
+ linear-gradient(
+ to bottom,
+ black calc(100% - var(--vcp-chat-composer-inset, 0px) - var(--vcp-chat-composer-fade)),
+ transparent calc(100% - var(--vcp-chat-composer-inset, 0px))
+ ),
+ linear-gradient(black, black);
+ mask-image:
+ linear-gradient(
+ to bottom,
+ black calc(100% - var(--vcp-chat-composer-inset, 0px) - var(--vcp-chat-composer-fade)),
+ transparent calc(100% - var(--vcp-chat-composer-inset, 0px))
+ ),
+ linear-gradient(black, black);
+ -webkit-mask-size: 100% 100%, var(--vcp-chat-scrollbar-size, 0px) 100%;
+ mask-size: 100% 100%, var(--vcp-chat-scrollbar-size, 0px) 100%;
+ -webkit-mask-position: 0 0, 100% 0;
+ mask-position: 0 0, 100% 0;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+}
diff --git a/styles/ui-system/settings-template.css b/styles/ui-system/settings-template.css
index e3fe3a01b..f2098658a 100644
--- a/styles/ui-system/settings-template.css
+++ b/styles/ui-system/settings-template.css
@@ -2433,11 +2433,13 @@
}
}
-/* Bubble-only options are appearance-owned rows, but their switch belongs in
- the trailing control column like every other setting row. The appearance
- feature's vertical stack remains unchanged for its other rows. */
+/* Bubble-only options and the turn navigator toggle are appearance-owned
+ rows, but their switch belongs in the trailing control column like every
+ other setting row. The appearance feature's vertical stack remains
+ unchanged for its other rows. */
:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableUserChatBubbleUi),
-:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi) {
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi),
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableTurnNavigator) {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: center !important;
@@ -2446,14 +2448,16 @@
}
:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableUserChatBubbleUi) > div,
-:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi) > div {
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi) > div,
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableTurnNavigator) > div {
grid-column: 1;
grid-row: 1;
min-width: 0;
}
:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableUserChatBubbleUi) > .switch,
-:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi) > .switch {
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#showUserMetaInChatBubbleUi) > .switch,
+:is(html, html.vcp-global-settings-host) .vcp-ui-scope#globalSettingsModal .vcp-uiux-appearance-row:has(#enableTurnNavigator) > .switch {
grid-column: 2;
grid-row: 1;
justify-self: end;
diff --git a/styles/ui-system/turn-navigator.css b/styles/ui-system/turn-navigator.css
new file mode 100644
index 000000000..e7a08aa21
--- /dev/null
+++ b/styles/ui-system/turn-navigator.css
@@ -0,0 +1,106 @@
+/*
+ * 主聊天左侧的提问定位导轨(modules/ui-system/conversation-turn-navigator.js)。
+ * 版式与数值对照 ZCode 的 ConversationTurnNavigator(https://github.com/zai-org/ZCode ,Apache-2.0):
+ * 36×10px 的按钮里一根横条,悬停的那根按距离放大到约 31 / 20 / 15px(同 ZCode);
+ * 平时的横条比 ZCode 的 12px 短,只有 8px,聊天列不足 864px 时整条导轨隐藏(同 ZCode),
+ * 颜色取自 vcp-ui 令牌(定义在 .vcp-turn-nav 上),跟随浅色/深色主题。
+ */
+
+html .vcp-turn-nav:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ position: absolute;
+ left: 0;
+ width: 48px;
+ z-index: 10;
+ pointer-events: none;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateX(-8px);
+ transition: opacity 150ms ease-out, transform 150ms ease-out, visibility 150ms ease-out;
+ --vcp-turn-nav-fg: var(--vcp-ui-text-0);
+ --vcp-turn-nav-fg-subtle: var(--vcp-ui-text-2);
+ --vcp-turn-nav-fg-subtlest: var(--vcp-ui-text-3);
+ --vcp-turn-nav-border: var(--vcp-ui-border);
+ --vcp-turn-nav-popover: var(--vcp-ui-bg-1);
+ --vcp-turn-nav-shadow: var(--vcp-ui-shadow-md);
+}
+html .vcp-turn-nav[hidden]:where(.vcp-ui-scope, .vcp-ui-scope *) { display: none; }
+html .vcp-turn-nav.is-wide:where(.vcp-ui-scope, .vcp-ui-scope *) { visibility: visible; opacity: 1; transform: none; }
+
+html .vcp-turn-nav-scroll:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ position: absolute;
+ left: 4px; /* 消息气泡左对齐、左边距只有 ~27px,比 ZCode 的居中版面更靠边 */
+ top: 50%;
+ width: 36px;
+ max-height: calc(100% - 96px);
+ transform: translateY(-50%);
+ padding: 4px 0;
+ overflow-x: hidden; /* 悬停横条放大时不能冒出横向滚动条 */
+ overflow-y: auto;
+ scrollbar-width: none;
+ pointer-events: auto;
+}
+html .vcp-turn-nav-scroll:where(.vcp-ui-scope, .vcp-ui-scope *)::-webkit-scrollbar { display: none; }
+html .vcp-turn-nav-inner:where(.vcp-ui-scope, .vcp-ui-scope *) { position: relative; width: 36px; }
+
+html .vcp-turn-nav-item:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 36px;
+ height: 10px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 2px;
+ background: transparent;
+ cursor: pointer;
+}
+html .vcp-turn-nav-item:focus-visible:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ outline: 2px solid color-mix(in srgb, var(--vcp-turn-nav-fg) 45%, transparent);
+ outline-offset: 1px;
+}
+
+html .vcp-turn-nav-bar:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ display: block;
+ width: 8px;
+ height: 2px;
+ border-radius: 999px;
+ background: var(--vcp-turn-nav-fg-subtlest);
+ transform-origin: left center;
+ transition: height 150ms ease-out, opacity 150ms ease-out, transform 150ms ease-out, background-color 150ms ease-out;
+}
+html .vcp-turn-nav-bar.is-strong:where(.vcp-ui-scope, .vcp-ui-scope *) { background: var(--vcp-turn-nav-fg); }
+
+html .vcp-turn-nav-card:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ position: absolute;
+ left: 48px;
+ width: 320px;
+ max-width: calc(100vw - 96px);
+ transform: translateY(-50%);
+ box-sizing: border-box;
+ padding: 12px;
+ border: 1px solid var(--vcp-turn-nav-border);
+ border-radius: 10px;
+ background: var(--vcp-turn-nav-popover);
+ color: var(--vcp-turn-nav-fg);
+ box-shadow: var(--vcp-turn-nav-shadow);
+ pointer-events: auto;
+ font-size: calc(var(--vcp-ui-font-size) - 1px);
+ line-height: 20px;
+}
+html .vcp-turn-nav-card[hidden]:where(.vcp-ui-scope, .vcp-ui-scope *) { display: none; }
+html .vcp-turn-nav-card p:where(.vcp-ui-scope, .vcp-ui-scope *) {
+ margin: 0;
+ white-space: pre-line;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+html .vcp-turn-nav-card-user:where(.vcp-ui-scope, .vcp-ui-scope *) { font-weight: 500; -webkit-line-clamp: 2; }
+html .vcp-turn-nav-card .vcp-turn-nav-card-assistant:where(.vcp-ui-scope, .vcp-ui-scope *) { margin-top: 8px; color: color-mix(in srgb, var(--vcp-turn-nav-fg) 80%, transparent); -webkit-line-clamp: 3; }
+html .vcp-turn-nav-card-assistant.is-muted:where(.vcp-ui-scope, .vcp-ui-scope *) { color: var(--vcp-turn-nav-fg-subtle); }
+
+@media (prefers-reduced-motion: reduce) {
+ html .vcp-turn-nav:where(.vcp-ui-scope, .vcp-ui-scope *), html .vcp-turn-nav-bar:where(.vcp-ui-scope, .vcp-ui-scope *) { transition: none; }
+}
diff --git a/tests/chat-back-to-bottom.test.mjs b/tests/chat-back-to-bottom.test.mjs
new file mode 100644
index 000000000..5185f206b
--- /dev/null
+++ b/tests/chat-back-to-bottom.test.mjs
@@ -0,0 +1,102 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { JSDOM } from 'jsdom';
+import { createChatBackToBottom } from '../modules/ui-system/chat-back-to-bottom.js';
+
+const EVENT = 'vcp-chat-follow-change';
+const flushMutations = () => new Promise(resolve => setTimeout(resolve, 0));
+
+function makeChat({ following = true, messageCount = 1 } = {}) {
+ const dom = new JSDOM(`
+
+
+ `);
+ const doc = dom.window.document;
+ const scroller = doc.querySelector('.chat-messages-container');
+ const messages = doc.getElementById('chatMessages');
+ const addMessage = () => {
+ const item = doc.createElement('div');
+ item.className = 'message-item';
+ messages.appendChild(item);
+ };
+ for (let i = 0; i < messageCount; i += 1) addMessage();
+ const calls = [];
+ const uiHelper = {
+ CHAT_FOLLOW_CHANGE_EVENT: EVENT,
+ captureChatScrollFollow: () => ({ followBottom: following }),
+ resetChatScrollFollow: () => calls.push('reset'),
+ scrollToBottom: (options) => calls.push(['scrollToBottom', options])
+ };
+ const announce = (followBottom) => scroller.dispatchEvent(new dom.window.CustomEvent(EVENT, { detail: { followBottom } }));
+ return { dom, doc, scroller, messages, addMessage, uiHelper, calls, announce };
+}
+
+test('mounts above the composer and stays hidden while following', () => {
+ const { dom, doc, uiHelper } = makeChat();
+ const control = createChatBackToBottom({ document: doc, uiHelper });
+ const button = control.mount();
+ assert.equal(button.parentElement, doc.querySelector('.chat-input-area'));
+ assert.equal(button.parentElement.firstElementChild, button);
+ assert.equal(button.type, 'button');
+ assert.equal(button.getAttribute('aria-label'), '回到底部');
+ assert.equal(button.hidden, true);
+ control.dispose();
+ dom.window.close();
+});
+
+test('shows when follow is released and hides again when it comes back', () => {
+ const { dom, doc, uiHelper, announce } = makeChat();
+ const control = createChatBackToBottom({ document: doc, uiHelper });
+ const button = control.mount();
+ announce(false);
+ assert.equal(button.hidden, false);
+ announce(true);
+ assert.equal(button.hidden, true);
+ control.dispose();
+ dom.window.close();
+});
+
+test('starts visible when mounted away from the bottom, but never on an empty chat', async () => {
+ const { dom, doc, messages, addMessage, uiHelper } = makeChat({ following: false, messageCount: 0 });
+ const control = createChatBackToBottom({ document: doc, uiHelper });
+ const button = control.mount();
+ assert.equal(button.hidden, true, 'nothing to go back to');
+ addMessage();
+ await flushMutations();
+ assert.equal(button.hidden, false);
+ messages.replaceChildren();
+ await flushMutations();
+ assert.equal(button.hidden, true);
+ control.dispose();
+ dom.window.close();
+});
+
+test('click re-enables follow and forces a scroll to the bottom', () => {
+ const { dom, doc, uiHelper, calls, announce } = makeChat();
+ const control = createChatBackToBottom({ document: doc, uiHelper });
+ const button = control.mount();
+ announce(false);
+ button.click();
+ assert.deepEqual(calls, ['reset', ['scrollToBottom', { force: true }]]);
+ control.dispose();
+ dom.window.close();
+});
+
+test('dispose removes the button and stops listening', () => {
+ const { dom, doc, scroller, uiHelper, announce } = makeChat();
+ const control = createChatBackToBottom({ document: doc, uiHelper, scroller });
+ control.mount();
+ control.dispose();
+ assert.equal(doc.querySelector('.vcp-back-to-bottom'), null);
+ assert.equal(control.element, null);
+ announce(false); // 不应抛错
+ dom.window.close();
+});
+
+test('does nothing without the chat shell', () => {
+ const dom = new JSDOM('');
+ const control = createChatBackToBottom({ document: dom.window.document, uiHelper: {} });
+ assert.equal(control.mount(), null);
+ control.dispose();
+ dom.window.close();
+});
diff --git a/tests/chat-composer-inset.test.mjs b/tests/chat-composer-inset.test.mjs
new file mode 100644
index 000000000..cd6b05b9d
--- /dev/null
+++ b/tests/chat-composer-inset.test.mjs
@@ -0,0 +1,93 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { JSDOM } from 'jsdom';
+import { createChatComposerInset } from '../modules/ui-system/chat-composer-inset.js';
+
+function makeShell({ following = true } = {}) {
+ const dom = new JSDOM(`
+
+
+ `);
+ const { window } = dom;
+ const doc = window.document;
+ const observers = [];
+ window.ResizeObserver = class {
+ constructor(callback) { this.callback = callback; this.targets = []; observers.push(this); }
+ observe(target) { this.targets.push(target); }
+ disconnect() { this.targets = []; }
+ };
+ const main = doc.querySelector('main');
+ const scroller = doc.querySelector('.chat-messages-container');
+ const dock = doc.querySelector('.chat-input-area');
+ let dockHeight = 100;
+ let scrollbar = 15;
+ dock.getBoundingClientRect = () => ({ height: dockHeight });
+ Object.defineProperty(scroller, 'offsetWidth', { get: () => 1000 });
+ Object.defineProperty(scroller, 'clientWidth', { get: () => 1000 - scrollbar });
+ const calls = [];
+ const uiHelper = {
+ captureChatScrollFollow: () => ({ followBottom: following }),
+ scrollToBottom: (options) => calls.push(options)
+ };
+ return {
+ dom, doc, main, scroller, dock, uiHelper, calls, observers,
+ setDockHeight(value) { dockHeight = value; },
+ setFollowing(value) { following = value; },
+ fire() { observers.forEach(observer => observer.callback([])); }
+ };
+}
+
+test('mounts the overlay class and publishes composer height and scrollbar width', () => {
+ const shell = makeShell();
+ const control = createChatComposerInset({ document: shell.doc, uiHelper: shell.uiHelper });
+ assert.equal(control.mount(), true);
+ assert.equal(shell.main.classList.contains('vcp-chat-composer-overlay'), true);
+ assert.equal(shell.main.style.getPropertyValue('--vcp-chat-composer-inset'), '100px');
+ assert.equal(shell.main.style.getPropertyValue('--vcp-chat-scrollbar-size'), '15px');
+ assert.deepEqual(shell.observers[0].targets, [shell.dock, shell.scroller]);
+ assert.deepEqual(shell.calls, [{ force: true, immediate: true }], 'a following view stays pinned once the overlay applies');
+ control.dispose();
+ shell.dom.window.close();
+});
+
+test('a taller composer keeps a following view pinned but leaves a reader where they are', () => {
+ const shell = makeShell();
+ const control = createChatComposerInset({ document: shell.doc, uiHelper: shell.uiHelper });
+ control.mount();
+ shell.calls.length = 0;
+
+ shell.fire();
+ assert.equal(shell.calls.length, 0, 'unchanged height does nothing');
+
+ shell.setDockHeight(160);
+ shell.fire();
+ assert.equal(shell.main.style.getPropertyValue('--vcp-chat-composer-inset'), '160px');
+ assert.equal(shell.calls.length, 1);
+
+ shell.setFollowing(false);
+ shell.setDockHeight(100);
+ shell.fire();
+ assert.equal(shell.main.style.getPropertyValue('--vcp-chat-composer-inset'), '100px');
+ assert.equal(shell.calls.length, 1);
+ control.dispose();
+ shell.dom.window.close();
+});
+
+test('dispose restores the stacked layout', () => {
+ const shell = makeShell();
+ const control = createChatComposerInset({ document: shell.doc, uiHelper: shell.uiHelper });
+ control.mount();
+ control.dispose();
+ assert.equal(shell.main.classList.contains('vcp-chat-composer-overlay'), false);
+ assert.equal(shell.main.style.getPropertyValue('--vcp-chat-composer-inset'), '');
+ assert.equal(shell.observers[0].targets.length, 0);
+ shell.dom.window.close();
+});
+
+test('leaves the layout alone without the chat shell', () => {
+ const dom = new JSDOM('');
+ const control = createChatComposerInset({ document: dom.window.document });
+ assert.equal(control.mount(), false);
+ control.dispose();
+ dom.window.close();
+});
diff --git a/tests/conversation-turn-navigator.test.mjs b/tests/conversation-turn-navigator.test.mjs
new file mode 100644
index 000000000..5d1a10bcd
--- /dev/null
+++ b/tests/conversation-turn-navigator.test.mjs
@@ -0,0 +1,318 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { JSDOM } from 'jsdom';
+import {
+ buildPreviewText,
+ buildTurnNavigatorItems,
+ collectTurnEntries,
+ createConversationTurnNavigator,
+ resolveActiveTurnIndex,
+ resolveActiveTurnKey,
+ resolveBarVisualState
+} from '../modules/ui-system/conversation-turn-navigator.js';
+
+const wait = (ms = 220) => new Promise(resolve => setTimeout(resolve, ms));
+
+test('resolveBarVisualState follows the ZCode mountain table', () => {
+ assert.deepEqual(resolveBarVisualState({ itemIndex: 3, visualFocusItemIndex: undefined }), { colorTone: 'muted', opacity: 0.58, scaleX: 1, tone: 'idle' });
+ const scales = [0, 1, 2, 3, 4].map(distance => resolveBarVisualState({ itemIndex: 5 + distance, visualFocusItemIndex: 5 }).scaleX);
+ assert.deepEqual(scales, [3.9, 2.55, 1.875, 1, 1]);
+ assert.equal(resolveBarVisualState({ itemIndex: 4, visualFocusItemIndex: 5 }).opacity, 0.86);
+ assert.equal(resolveBarVisualState({ itemIndex: 5, visualFocusItemIndex: 5 }).colorTone, 'focus');
+});
+
+test('buildPreviewText keeps two paragraphs, collapses whitespace and truncates', () => {
+ assert.equal(buildPreviewText([' a b \n\n c\nd \n\n third'], 'x'), 'a b\nc d');
+ assert.equal(buildPreviewText([' '], '空'), '空');
+ const long = buildPreviewText(['x'.repeat(400)], '空');
+ assert.equal(long.length, 220);
+ assert.ok(long.endsWith('...'));
+});
+
+test('buildTurnNavigatorItems derives assistant previews and running state', () => {
+ const items = buildTurnNavigatorItems([
+ { id: 'u1', userText: '你好', assistantTexts: ['第一段\n\n第二段\n\n第三段'], running: false },
+ { id: 'u2', userText: '', assistantTexts: [], running: true },
+ { id: 'u3', userText: '再问', assistantTexts: ['', ' '], running: false }
+ ]);
+ assert.equal(items[0].assistantPreview, '第一段\n第二段');
+ assert.equal(items[0].assistantPreviewKind, 'text');
+ assert.equal(items[1].userPreview, '(没有文字内容)');
+ assert.equal(items[1].assistantPreviewKind, 'running');
+ assert.equal(items[1].isRunning, true);
+ assert.equal(items[2].assistantPreviewKind, 'empty');
+ assert.deepEqual(items.map(item => item.key), ['u1', 'u2', 'u3']);
+});
+
+test('resolveActiveTurnKey picks the visible query nearest the viewport top, else the nearest above', () => {
+ const positions = [
+ { key: 'a', start: 0, end: 100 },
+ { key: 'b', start: 400, end: 500 },
+ { key: 'c', start: 900, end: 1000 }
+ ];
+ assert.equal(resolveActiveTurnKey({ positions, scrollOffsetPx: 380, viewportHeightPx: 300 }), 'b');
+ assert.equal(resolveActiveTurnKey({ positions, scrollOffsetPx: 0, viewportHeightPx: 300 }), 'a');
+ assert.equal(resolveActiveTurnKey({ positions, scrollOffsetPx: 600, viewportHeightPx: 200 }), 'b'); // 视口里没有提问:取上方最近的
+ assert.equal(resolveActiveTurnKey({ positions: [], scrollOffsetPx: 0, viewportHeightPx: 100 }), undefined);
+});
+
+function makeChat(turns) {
+ const dom = new JSDOM('', { pretendToBeVisual: true });
+ const doc = dom.window.document;
+ const root = doc.getElementById('chatMessages');
+ const add = (role, text, id, extra = '') => {
+ const item = doc.createElement('div');
+ item.className = `message-item ${role} ${extra}`.trim();
+ if (id) item.dataset.messageId = id;
+ item.innerHTML = `${text}思考中
`;
+ root.appendChild(item);
+ return item;
+ };
+ turns.forEach((turn, index) => {
+ add('user', turn.q, `u${index}`);
+ if (turn.a !== undefined) add('assistant', turn.a, `a${index}`, turn.streaming ? 'streaming' : '');
+ });
+ const scroller = doc.querySelector('.chat-messages-container');
+ // jsdom 没有布局:每条消息占 100px,容器高 300px、宽 900px
+ let top = 0;
+ const scrollCalls = [];
+ Object.defineProperty(scroller, 'clientWidth', { value: 900, configurable: true });
+ Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true });
+ Object.defineProperty(scroller, 'scrollTop', { get: () => top, set: (v) => { top = v; }, configurable: true });
+ scroller.scrollTo = (options) => { scrollCalls.push(options); top = options.top; scroller.dispatchEvent(new dom.window.Event('scroll')); };
+ scroller.getBoundingClientRect = () => ({ top: 0, height: 300 });
+ const place = () => [...root.children].forEach((child, index) => {
+ child.getBoundingClientRect = () => ({ top: index * 100 - top, height: 100 });
+ });
+ place();
+ return { dom, doc, root, scroller, add, place, scrollCalls };
+}
+
+test('collectTurnEntries groups assistant messages under the preceding user message and ignores the thinking indicator', () => {
+ const { root } = makeChat([{ q: '问一', a: '答一' }, { q: '问二', a: '答二', streaming: true }]);
+ const entries = collectTurnEntries(root);
+ assert.deepEqual(entries.map(entry => [entry.id, entry.userText, entry.assistantTexts, entry.running]),
+ [['u0', '问一', ['答一'], false], ['u1', '问二', ['答二'], true]]);
+});
+
+test('mounts a rail for two or more queries, reflects the active one and jumps on click', async () => {
+ const { doc, root, scroller, add, place, scrollCalls, dom } = makeChat([{ q: '问一', a: '答一' }]);
+ const navigator = createConversationTurnNavigator({ document: doc, messagesRoot: root });
+ const nav = navigator.mount();
+ assert.equal(nav.hidden, true, 'a single query needs no rail');
+
+ add('user', '问二', 'u1');
+ add('assistant', '答二', 'a1');
+ add('user', '问三', 'u2');
+ place();
+ navigator.refresh();
+ assert.equal(nav.hidden, false);
+ assert.equal(nav.classList.contains('is-wide'), true);
+ const buttons = [...nav.querySelectorAll('.vcp-turn-nav-item')];
+ assert.equal(buttons.length, 3);
+ assert.equal(buttons[0].getAttribute('aria-label'), '跳转到第 1 条提问');
+ await wait(40);
+ assert.equal(buttons[0].dataset.active, 'true');
+
+ // 悬停:山峰放大,120ms 后出现卡片
+ buttons[1].dispatchEvent(new dom.window.Event('pointerenter'));
+ assert.equal(buttons[1].dataset.visualScale, '3.9');
+ assert.equal(buttons[2].dataset.visualScale, '2.55');
+ assert.equal(nav.querySelector('.vcp-turn-nav-card').hidden, true);
+ await wait(180);
+ const card = nav.querySelector('.vcp-turn-nav-card');
+ assert.equal(card.hidden, false);
+ assert.equal(card.querySelector('.vcp-turn-nav-card-user').textContent, '问二');
+ assert.equal(card.querySelector('.vcp-turn-nav-card-assistant').textContent, '答二');
+ buttons[1].dispatchEvent(new dom.window.Event('pointerleave'));
+ assert.equal(buttons[1].dataset.visualScale, '1');
+ await wait(150);
+ assert.equal(card.hidden, true);
+
+ // 点击:平滑滚到这条提问(留 12px 顶部间距)
+ buttons[2].click();
+ assert.deepEqual(scrollCalls.at(-1), { top: 388, behavior: 'smooth' });
+ await wait(60);
+ assert.equal(buttons[2].dataset.active, 'true');
+ assert.equal(buttons[2].getAttribute('aria-current'), 'location');
+ assert.equal(buttons[0].hasAttribute('aria-current'), false);
+ assert.equal(scroller.scrollTop, 388);
+
+ navigator.dispose();
+ assert.equal(doc.querySelector('.vcp-turn-nav'), null);
+});
+
+test('hides below the minimum width, follows streaming previews and topic switches, and disposes cleanly', async () => {
+ const { doc, root, scroller } = makeChat([{ q: '问一', a: '答一' }, { q: '问二', a: '', streaming: true }]);
+ Object.defineProperty(scroller, 'clientWidth', { value: 863, configurable: true });
+ const navigator = createConversationTurnNavigator({ document: doc, messagesRoot: root });
+ const nav = navigator.mount();
+ assert.equal(nav.classList.contains('is-wide'), false);
+ Object.defineProperty(scroller, 'clientWidth', { value: 864, configurable: true });
+ navigator.refresh();
+ assert.equal(nav.classList.contains('is-wide'), true);
+
+ const buttons = [...nav.querySelectorAll('.vcp-turn-nav-item')];
+ assert.equal(navigator.getItems()[1].assistantPreviewKind, 'running');
+ assert.equal(buttons[1].dataset.running, 'true');
+
+ // 流式内容到达:DOM 变化被观察到并刷新摘要
+ root.children[3].querySelector('.md-content').insertAdjacentText('afterbegin', '流式回答');
+ await wait(260);
+ assert.equal(navigator.getItems()[1].assistantPreview, '流式回答');
+ assert.equal(navigator.getItems()[1].assistantPreviewKind, 'text');
+
+ // 话题切换:清空后导轨消失
+ root.textContent = '';
+ await wait(260);
+ assert.equal(nav.hidden, true);
+ assert.equal(nav.querySelectorAll('.vcp-turn-nav-item').length, 0);
+
+ navigator.dispose();
+ navigator.dispose();
+ root.innerHTML = '';
+ await wait(200);
+ assert.equal(doc.querySelector('.vcp-turn-nav'), null);
+});
+
+test('mount is a no-op without a chat container', () => {
+ const dom = new JSDOM('');
+ const navigator = createConversationTurnNavigator({ document: dom.window.document });
+ assert.equal(navigator.mount(), null);
+ navigator.dispose();
+});
+
+test('releases bottom-follow only for jumps that land away from the bottom', () => {
+ const { doc, root, scroller, dom } = makeChat([{ q: '问一', a: '答一' }, { q: '问二', a: '答二' }, { q: '问三', a: '答三' }]);
+ // 6 条消息 × 100px,容器 300px:最大 scrollTop 为 300
+ Object.defineProperty(scroller, 'scrollHeight', { value: 600, configurable: true });
+ let released = 0;
+ const navigator = createConversationTurnNavigator({ document: doc, messagesRoot: root, releaseFollow: () => { released += 1; } });
+ const nav = navigator.mount();
+ const buttons = [...nav.querySelectorAll('.vcp-turn-nav-item')];
+
+ buttons[2].click(); // 目标 388 已越过底部:留给跟随
+ assert.equal(released, 0);
+ buttons[0].click(); // 目标在顶部:先放开跟随
+ assert.equal(released, 1);
+
+ navigator.dispose();
+ dom.window.close();
+});
+
+test('far jumps land at once, then hold the question in place until the user scrolls', async () => {
+ const turns = Array.from({ length: 10 }, (_, i) => ({ q: `问${i}`, a: `答${i}` }));
+ const { doc, root, scroller, scrollCalls, dom } = makeChat(turns);
+ Object.defineProperty(scroller, 'scrollHeight', { value: 2000, configurable: true });
+ const heights = [...root.children].map(() => 100);
+ const place = () => {
+ let offset = 0;
+ [...root.children].forEach((child, index) => {
+ const start = offset;
+ child.getBoundingClientRect = () => ({ top: start - scroller.scrollTop, height: heights[index] });
+ offset += heights[index];
+ });
+ };
+ place();
+ scroller.scrollTop = 1700;
+ let released = 0;
+ const navigator = createConversationTurnNavigator({ document: doc, messagesRoot: root, releaseFollow: () => { released += 1; } });
+ const buttons = [...navigator.mount().querySelectorAll('.vcp-turn-nav-item')];
+
+ buttons[1].click(); // 1512px 远:不平滑滚动,直接到位
+ assert.deepEqual(scrollCalls.at(-1), { top: 188, behavior: 'auto' });
+ assert.equal(released, 1);
+
+ // 目标上方的长回答渲染出真实高度,把这条提问往下挤了 500px:下一帧拉回
+ heights[1] = 600;
+ place();
+ await wait(60);
+ assert.equal(scroller.scrollTop, 688);
+
+ // 用户自己滚动以后不再守位
+ scroller.dispatchEvent(new dom.window.Event('wheel'));
+ heights[1] = 700;
+ place();
+ await wait(60);
+ assert.equal(scroller.scrollTop, 688);
+
+ buttons[2].click(); // 近处(一屏半以内)仍然平滑滚动
+ assert.deepEqual(scrollCalls.at(-1), { top: 988, behavior: 'smooth' });
+
+ // 跳完马上点聊天区外面的按钮(回到底部、发送)也不再守位
+ buttons[5].click();
+ assert.equal(scroller.scrollTop, 1588);
+ doc.body.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true }));
+ scroller.scrollTop = 1000;
+ place();
+ await wait(60);
+ assert.equal(scroller.scrollTop, 1000);
+
+ navigator.dispose();
+ dom.window.close();
+});
+
+test('resolveActiveTurnIndex agrees with resolveActiveTurnKey while measuring only O(log n) turns', () => {
+ let seed = 7;
+ const random = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
+ for (let round = 0; round < 200; round += 1) {
+ const count = 1 + Math.floor(random() * 1500);
+ const positions = [];
+ let cursor = random() * 50;
+ for (let i = 0; i < count; i += 1) {
+ const start = cursor + random() * 400; // 提问之间夹着高度不定的回答
+ const end = start + 20 + random() * 200;
+ positions.push({ key: `k${i}`, start, end });
+ cursor = end;
+ }
+ const scrollOffsetPx = random() * (cursor + 200);
+ const viewportHeightPx = 100 + random() * 900;
+ let measured = 0;
+ const index = resolveActiveTurnIndex({ count, measure: (i) => { measured += 1; return positions[i]; }, scrollOffsetPx, viewportHeightPx });
+ assert.equal(positions[index].key, resolveActiveTurnKey({ positions, scrollOffsetPx, viewportHeightPx }), `round ${round}`);
+ assert.ok(measured <= 2 * Math.ceil(Math.log2(count + 1)) + 4, `measured ${measured} of ${count}`);
+ }
+ assert.equal(resolveActiveTurnIndex({ count: 0, measure: () => null, scrollOffsetPx: 0, viewportHeightPx: 100 }), -1);
+});
+
+test('loading older turns reuses the existing bars and keeps the hover card on the same question', async () => {
+ const { doc, root, place, dom } = makeChat([{ q: '问三', a: '答三' }, { q: '问四', a: '答四' }]);
+ const navigator = createConversationTurnNavigator({ document: doc, messagesRoot: root });
+ const nav = navigator.mount();
+ const before = [...nav.querySelectorAll('.vcp-turn-nav-item')];
+ before[1].dispatchEvent(new dom.window.Event('pointerenter'));
+ await wait(180);
+ const card = nav.querySelector('.vcp-turn-nav-card');
+ assert.equal(card.querySelector('.vcp-turn-nav-card-user').textContent, '问四');
+
+ // 分批渲染历史时更早的消息插在最前面
+ const older = doc.createDocumentFragment();
+ for (const [role, text, id] of [['user', '问一', 'u-1'], ['assistant', '答一', 'a-1'], ['user', '问二', 'u-2'], ['assistant', '答二', 'a-2']]) {
+ const item = doc.createElement('div');
+ item.className = `message-item ${role}`;
+ item.dataset.messageId = id;
+ item.innerHTML = `${text}
`;
+ older.appendChild(item);
+ }
+ root.prepend(older);
+ place();
+ navigator.refresh();
+
+ const after = [...nav.querySelectorAll('.vcp-turn-nav-item')];
+ assert.equal(after.length, 4);
+ assert.equal(after[2], before[0], 'existing bars are moved, not rebuilt');
+ assert.equal(after[3], before[1]);
+ assert.deepEqual(after.map(button => button.dataset.turnKey), ['u-1', 'u-2', 'u0', 'u1']);
+ assert.equal(after[3].getAttribute('aria-label'), '跳转到第 4 条提问');
+ assert.equal(after[3].getAttribute('aria-setsize'), '4');
+ assert.equal(card.hidden, false);
+ assert.equal(card.querySelector('.vcp-turn-nav-card-user').textContent, '问四');
+ assert.equal(after[3].dataset.visualScale, '3.9', 'the hovered bar stays the peak');
+
+ after[0].click();
+ await wait(60);
+ assert.equal(after[0].dataset.active, 'true');
+
+ navigator.dispose();
+ dom.window.close();
+});
diff --git a/tests/settings-value-golden.test.mjs b/tests/settings-value-golden.test.mjs
index d3f7ef72d..77b7a2722 100644
--- a/tests/settings-value-golden.test.mjs
+++ b/tests/settings-value-golden.test.mjs
@@ -149,6 +149,7 @@ function legacyCollect({ doc, currentSettings, settingsManager, getAppearance, n
),
enableUserChatBubbleUi: getElementById('enableUserChatBubbleUi')?.checked !== false,
showUserMetaInChatBubbleUi: getElementById('showUserMetaInChatBubbleUi')?.checked !== false,
+ enableTurnNavigator: getElementById('enableTurnNavigator')?.checked !== false,
chatBubbleMaxWidthDefault: clampBubbleWidthPercent(currentSettings.chatBubbleMaxWidthDefault, 82),
chatBubbleMaxWidthNotifications: clampBubbleWidthPercent(currentSettings.chatBubbleMaxWidthNotifications, 90),
chatBubbleMaxWidthNarrow: clampBubbleWidthPercent(currentSettings.chatBubbleMaxWidthNarrow, 85),
diff --git a/tests/ui-helper-chat-scroll-follow.test.js b/tests/ui-helper-chat-scroll-follow.test.js
index 40fc2d2a8..51265453c 100644
--- a/tests/ui-helper-chat-scroll-follow.test.js
+++ b/tests/ui-helper-chat-scroll-follow.test.js
@@ -223,4 +223,54 @@ test('a stale resize compensation cannot override a newer user scroll generation
assert.equal(fixture.geometry().scrollTop, 400);
assert.equal(fixture.uiHelper.captureChatScrollFollow().followBottom, false);
fixture.dom.window.close();
-});
\ No newline at end of file
+});
+test('releaseChatScrollFollow cancels a pending bottom scroll and keeps later growth from snapping back', () => {
+ const fixture = createFixture();
+ fixture.uiHelper.captureChatScrollFollow();
+
+ // 跟随中内容增高,已排队一帧回底补偿
+ fixture.setGeometry({ scrollHeight: 1400 });
+ fixture.triggerResize();
+
+ // 程序化跳转(搜索结果、轮次导航)先释放跟随,再自己设置 scrollTop
+ fixture.uiHelper.releaseChatScrollFollow();
+ fixture.setGeometry({ scrollTop: 200 });
+ fixture.flushAnimationFrames();
+
+ assert.equal(fixture.geometry().scrollTop, 200);
+ assert.equal(fixture.uiHelper.captureChatScrollFollow().followBottom, false);
+
+ // 跳转途中经过的消息继续增高,也不能把视图拽回底部
+ fixture.setGeometry({ scrollHeight: 1800 });
+ fixture.triggerResize();
+ fixture.flushAnimationFrames();
+
+ assert.equal(fixture.geometry().scrollTop, 200);
+ assert.equal(fixture.uiHelper.captureChatScrollFollow().followBottom, false);
+ fixture.dom.window.close();
+});
+
+test('follow changes are announced once per flip on the scroll container', () => {
+ const fixture = createFixture();
+ const seen = [];
+ fixture.container.addEventListener(fixture.uiHelper.CHAT_FOLLOW_CHANGE_EVENT, event => {
+ seen.push(event.detail.followBottom);
+ });
+ fixture.uiHelper.captureChatScrollFollow();
+
+ fixture.uiHelper.releaseChatScrollFollow();
+ fixture.uiHelper.releaseChatScrollFollow();
+ assert.deepEqual(seen, [false], 'releasing twice must not announce twice');
+
+ fixture.uiHelper.resetChatScrollFollow();
+ assert.deepEqual(seen, [false, true]);
+
+ fixture.setGeometry({ scrollTop: 300 });
+ fixture.container.dispatchEvent(new fixture.window.WheelEvent('wheel', { deltaY: -100 }));
+ assert.deepEqual(seen, [false, true, false]);
+
+ fixture.uiHelper.scrollToBottom({ force: true });
+ fixture.flushAnimationFrames();
+ assert.deepEqual(seen, [false, true, false, true]);
+ fixture.dom.window.close();
+});