feat: improve cross-conversation reply UX

This commit is contained in:
shiyue
2026-06-21 23:28:49 +08:00
parent ae63e9717e
commit a50933807f
7 changed files with 635 additions and 41 deletions

View File

@@ -2,12 +2,42 @@
(function () {
'use strict';
const ASSET_VERSION = '20260618-mobile-session-switch';
const ASSET_VERSION = '20260621-cross-reply-collapse-last-section-offset';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time';
const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects';
const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies';
const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500;
const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn';
const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus';
const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72;
const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [
`.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`,
'.msg-tools',
'.tool-call',
'.tool-group',
'.msg-attachments',
'.msg-attachment-card',
'.cross-conversation-meta',
'.agent-message-divider',
].join(',');
const ASSISTANT_LAST_SECTION_SCOPE_SELECTOR = [
'p',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'td',
'th',
'pre',
'code',
'.msg-text',
].join(',');
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
@@ -178,6 +208,14 @@
return new Set();
}
})();
const collapsedCrossConversationReplyKeys = (() => {
try {
const parsed = JSON.parse(localStorage.getItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY) || '[]');
return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []);
} catch {
return new Set();
}
})();
const pendingNotesByTarget = new Map();
const userMessageIndex = new Map();
const expandedOldSessionAgents = new Set();
@@ -815,6 +853,51 @@
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
}
function getCrossConversationReplyCollapseKey(meta = {}) {
const source = meta?.crossConversation || {};
const messageId = source.replyToRequestId
|| source.messageId
|| meta.messageId
|| meta.id
|| [source.sourceSessionId, meta.timestamp || source.processedAt || source.sentAt].filter(Boolean).join(':');
if (!messageId) return '';
return `${currentSessionId || 'unknown'}:${messageId}`;
}
function persistCrossConversationReplyCollapseState() {
try {
const keys = Array.from(collapsedCrossConversationReplyKeys).slice(-CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT);
localStorage.setItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY, JSON.stringify(keys));
} catch {}
}
function setCrossConversationReplyCollapsed(key, collapsed) {
if (!key) return;
if (collapsed) {
collapsedCrossConversationReplyKeys.delete(key);
collapsedCrossConversationReplyKeys.add(key);
} else {
collapsedCrossConversationReplyKeys.delete(key);
}
persistCrossConversationReplyCollapseState();
}
function isCrossConversationReplyCollapsed(key) {
return !!key && collapsedCrossConversationReplyKeys.has(key);
}
function formatCrossConversationReplyTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function createLocalId(prefix = 'local') {
if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -893,6 +976,166 @@
messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
}
function isAssistantLastSectionTextNode(node, root) {
if (!node || node.nodeType !== Node.TEXT_NODE || !node.nodeValue?.trim()) return false;
const parent = node.parentElement;
if (!parent || !root.contains(parent)) return false;
if (parent.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
const tag = parent.tagName?.toLowerCase();
return !['button', 'script', 'style', 'textarea', 'input', 'select', 'option'].includes(tag);
}
function collectAssistantTextNodes(root) {
const nodes = [];
if (!root) return nodes;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return isAssistantLastSectionTextNode(node, root)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let node = walker.nextNode();
while (node) {
nodes.push(node);
node = walker.nextNode();
}
return nodes;
}
function findLastAssistantTextScope(bubble) {
const nodes = collectAssistantTextNodes(bubble);
const lastNode = nodes[nodes.length - 1];
if (!lastNode) return null;
return lastNode.parentElement?.closest(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR) || bubble;
}
function collectAssistantTextScopes(root) {
if (!root) return [];
const seen = new Set();
return Array.from(root.querySelectorAll(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR)).filter((scope) => {
if (!scope || seen.has(scope)) return false;
seen.add(scope);
if (scope.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
return collectAssistantTextNodes(scope).length > 0;
});
}
function findFirstAssistantTextScopeAfterDivider(bubble) {
const dividers = Array.from(bubble?.querySelectorAll?.('.agent-message-divider') || []);
const lastDivider = dividers[dividers.length - 1];
if (!lastDivider) return null;
return collectAssistantTextScopes(bubble).find((scope) => (
lastDivider.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING
)) || null;
}
function findFirstNonWhitespaceIndex(text, start = 0) {
for (let i = Math.max(0, start); i < text.length; i += 1) {
if (!/\s/.test(text[i])) return i;
}
return -1;
}
function mapTextIndexToNode(entries, index) {
for (const entry of entries) {
if (index >= entry.start && index < entry.end) {
return { node: entry.node, offset: index - entry.start };
}
}
const last = entries[entries.length - 1];
return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
}
function getAssistantTextScopeStartTarget(scope) {
if (!scope) return null;
const nodes = collectAssistantTextNodes(scope);
if (nodes.length === 0) return null;
const entries = [];
let text = '';
nodes.forEach((node) => {
const value = node.nodeValue || '';
const start = text.length;
text += value;
entries.push({ node, start, end: text.length });
});
const startIndex = findFirstNonWhitespaceIndex(text, 0);
if (startIndex < 0) return null;
const mapped = mapTextIndexToNode(entries, startIndex);
return mapped ? { ...mapped, scope } : null;
}
function getAssistantLastSectionTarget(bubble) {
const scope = findFirstAssistantTextScopeAfterDivider(bubble) || findLastAssistantTextScope(bubble);
return getAssistantTextScopeStartTarget(scope);
}
function getRangeRectFromTextPosition(node, offset) {
if (!node) return null;
const range = document.createRange();
const safeOffset = Math.min(Math.max(0, offset), node.nodeValue.length);
range.setStart(node, safeOffset);
range.setEnd(node, Math.min(node.nodeValue.length, safeOffset + 1));
const rect = Array.from(range.getClientRects()).find(item => item.width || item.height) || null;
range.detach?.();
return rect;
}
function scrollAssistantBubbleToLastSection(bubble) {
const target = getAssistantLastSectionTarget(bubble);
if (!target) return false;
const rect = getRangeRectFromTextPosition(target.node, target.offset) || target.scope.getBoundingClientRect();
if (!rect) return false;
const containerRect = messagesDiv.getBoundingClientRect();
const targetTop = messagesDiv.scrollTop + rect.top - containerRect.top - ASSISTANT_LAST_SECTION_SCROLL_OFFSET;
messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
requestAnimationFrame(() => {
target.scope.classList.add(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
window.setTimeout(() => target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS), 1100);
});
updateScrollbar();
return true;
}
function createAssistantLastSectionButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = ASSISTANT_LAST_SECTION_BUTTON_CLASS;
button.title = '定位到本条回复最后一段';
button.setAttribute('aria-label', '定位到本条回复最后一段');
button.innerHTML = `
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="7"></circle>
<circle cx="12" cy="12" r="2"></circle>
<path d="M12 2v3"></path>
<path d="M12 19v3"></path>
<path d="M2 12h3"></path>
<path d="M19 12h3"></path>
</svg>
`;
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const bubble = button.closest('.msg-bubble');
scrollAssistantBubbleToLastSection(bubble);
});
return button;
}
function syncAssistantLastSectionButton(messageEl) {
if (!messageEl?.classList?.contains('assistant')) return;
const bubble = messageEl.querySelector(':scope > .msg-bubble');
if (!bubble) return;
let button = bubble.querySelector(`:scope > .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`);
const hasTarget = !!getAssistantLastSectionTarget(bubble);
if (!button && !hasTarget) return;
if (!button) button = createAssistantLastSectionButton();
button.hidden = !hasTarget;
button.disabled = !hasTarget;
bubble.appendChild(button);
}
function updateSessionIdBadge() {
if (!chatSessionIdBtn) return;
if (!currentSessionId) {
@@ -907,24 +1150,70 @@
chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`);
}
function shouldPreferTextareaCopy() {
const ua = navigator.userAgent || '';
return /iPad|iPhone|iPod/.test(ua)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
function copyTextWithTextarea(value) {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.setAttribute('aria-hidden', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.width = '1px';
textarea.style.height = '1px';
textarea.style.padding = '0';
textarea.style.border = '0';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.style.fontSize = '16px';
document.body.appendChild(textarea);
const activeElement = document.activeElement;
try {
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
textarea.select();
try {
textarea.setSelectionRange(0, value.length);
} catch {}
if (!document.execCommand('copy')) throw new Error('copy_failed');
} finally {
textarea.remove();
if (activeElement && typeof activeElement.focus === 'function') {
try {
activeElement.focus({ preventScroll: true });
} catch {}
}
}
}
async function copyTextToClipboard(text, successText = '已复制') {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
let copied = false;
const preferTextarea = shouldPreferTextareaCopy();
if (preferTextarea) {
try {
copyTextWithTextarea(value);
copied = true;
} catch {}
}
if (!copied && navigator.clipboard?.writeText && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value);
copied = true;
} catch {}
}
if (!copied) copyTextWithTextarea(value);
showToast(successText);
return true;
} catch {
@@ -3008,7 +3297,7 @@
}
const canPreview = PREVIEW_LANGS.has(lang);
const previewBtn = canPreview
? `<button class="code-preview-btn" onclick="ccTogglePreview(this)">Preview</button>`
? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
: '';
const previewPane = canPreview
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
@@ -3018,24 +3307,37 @@
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
<div class="code-block-header">
<span>${escapeHtml(lang)}</span>
<div class="code-block-actions">${previewBtn}<button class="code-copy-btn" onclick="ccCopyCode(this)">Copy</button></div>
<div class="code-block-actions">${previewBtn}<button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制代码块">Copy</button></div>
</div>
${previewPane}<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
</div>`;
};
marked.setOptions({ renderer, breaks: true, gfm: true });
window.ccCopyCode = function (btn) {
const wrapper = btn.closest('.code-block-wrapper');
window.ccCopyCode = async function (btn, event) {
event?.preventDefault();
event?.stopPropagation();
const wrapper = btn?.closest?.('.code-block-wrapper');
if (!wrapper) return;
const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code').textContent;
navigator.clipboard.writeText(code).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code')?.textContent;
const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy';
btn.dataset.defaultLabel = defaultLabel;
btn.disabled = true;
const copied = await copyTextToClipboard(code, '代码已复制');
btn.disabled = false;
if (!copied) return;
if (btn._copyResetTimer) clearTimeout(btn._copyResetTimer);
btn.textContent = 'Copied!';
btn._copyResetTimer = setTimeout(() => {
btn.textContent = btn.dataset.defaultLabel || 'Copy';
btn._copyResetTimer = null;
}, 1500);
};
window.ccTogglePreview = function (btn) {
window.ccTogglePreview = function (btn, event) {
event?.preventDefault();
event?.stopPropagation();
const wrapper = btn.closest('.code-block-wrapper');
const inPreview = wrapper.classList.contains('preview-mode');
if (inPreview) {
@@ -3617,6 +3919,7 @@
toolsDiv.className = 'msg-tools';
bubble.appendChild(textDiv);
bubble.appendChild(toolsDiv);
syncAssistantLastSectionButton(msgEl);
messagesDiv.appendChild(msgEl);
scrollToBottom();
return true;
@@ -3666,6 +3969,7 @@
}
}
streamEl.removeAttribute('id');
syncAssistantLastSectionButton(streamEl);
}
if (sessionId) {
@@ -3702,6 +4006,7 @@
if (!textDiv) { textDiv = bubble; }
textDiv.innerHTML = renderMarkdown(pendingText);
}
syncAssistantLastSectionButton(streamEl);
scrollToBottom();
}
@@ -3877,6 +4182,7 @@
const div = document.createElement('div');
const isCrossConversation = !!meta.crossConversation;
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply;
const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
if (role === 'user') {
@@ -3932,6 +4238,28 @@
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
let crossConversationReplyCollapseKey = '';
let crossConversationReplyToggle = null;
let crossConversationReplyBody = null;
const applyCrossConversationReplyCollapseState = (collapsed) => {
if (!crossConversationReplyToggle || !crossConversationReplyBody) return;
div.classList.toggle('cross-conversation-collapsed', collapsed);
bubble.dataset.collapsed = collapsed ? 'true' : 'false';
crossConversationReplyBody.hidden = collapsed;
crossConversationReplyToggle.textContent = collapsed ? '展开' : '收起';
crossConversationReplyToggle.title = collapsed ? '展开返回消息' : '收起返回消息';
crossConversationReplyToggle.setAttribute('aria-label', collapsed ? '展开返回消息' : '收起返回消息');
crossConversationReplyToggle.setAttribute('aria-expanded', String(!collapsed));
updateScrollbar();
};
if (canCollapseCrossConversationReply) {
crossConversationReplyCollapseKey = getCrossConversationReplyCollapseKey(meta);
if (crossConversationReplyCollapseKey) {
div.dataset.crossConversationReplyKey = crossConversationReplyCollapseKey;
}
}
if (isCrossConversation) {
const source = meta.crossConversation || {};
@@ -3960,6 +4288,27 @@
sourceMeta.appendChild(copyBtn);
}
if (canCollapseCrossConversationReply) {
const replyTimeText = formatCrossConversationReplyTime(meta.timestamp || source.processedAt || source.sentAt);
if (replyTimeText) {
const time = document.createElement('span');
time.className = 'cross-conversation-time';
time.textContent = replyTimeText;
sourceMeta.appendChild(time);
}
crossConversationReplyToggle = document.createElement('button');
crossConversationReplyToggle.type = 'button';
crossConversationReplyToggle.className = 'cross-conversation-collapse-btn';
crossConversationReplyToggle.addEventListener('click', (event) => {
event.stopPropagation();
const collapsed = !div.classList.contains('cross-conversation-collapsed');
setCrossConversationReplyCollapsed(crossConversationReplyCollapseKey, collapsed);
applyCrossConversationReplyCollapseState(collapsed);
});
sourceMeta.appendChild(crossConversationReplyToggle);
}
bubble.appendChild(sourceMeta);
}
@@ -3994,15 +4343,29 @@
const mentionsStrip = renderComposerMentionsStrip(meta);
if (mentionsStrip) bubble.appendChild(mentionsStrip);
} else {
renderAssistantContent(bubble, content);
const assistantContentTarget = canCollapseCrossConversationReply ? document.createElement('div') : bubble;
if (canCollapseCrossConversationReply) {
assistantContentTarget.className = 'cross-conversation-reply-body';
crossConversationReplyBody = assistantContentTarget;
}
renderAssistantContent(assistantContentTarget, content);
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
assistantContentTarget.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
}
if (canCollapseCrossConversationReply) {
bubble.appendChild(assistantContentTarget);
}
}
hydrateAttachmentPreviews(bubble, attachments);
div.appendChild(avatar);
div.appendChild(bubble);
if (role === 'assistant') {
syncAssistantLastSectionButton(div);
}
if (canCollapseCrossConversationReply) {
applyCrossConversationReplyCollapseState(isCrossConversationReplyCollapsed(crossConversationReplyCollapseKey));
}
if (role === 'user' && meta.codexAppSteerStatus) {
setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
}
@@ -4699,6 +5062,7 @@
const el = createMsgElement(m.role, m.content, m.attachments || [], m);
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
const bubble = el.querySelector('.msg-bubble');
const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble;
const FOLD_AT = 3;
let grouped = false;
const mergedCollabTool = mergeCollabAgentTools(m.toolCalls);
@@ -4711,9 +5075,9 @@
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
const loose = Array.from(bubble.children).filter(isGroupableToolCall);
const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
if (loose.length >= FOLD_AT) {
let group = bubble.querySelector(':scope > .tool-group');
let group = toolMount.querySelector(':scope > .tool-group');
if (!group) {
group = document.createElement('details');
group.className = 'tool-group';
@@ -4723,20 +5087,20 @@
const inner = document.createElement('div');
inner.className = 'tool-group-inner';
group.appendChild(inner);
bubble.insertBefore(group, bubble.firstChild);
toolMount.insertBefore(group, toolMount.firstChild);
grouped = true;
}
const inner = group.querySelector('.tool-group-inner');
loose.forEach(c => inner.appendChild(c));
_refreshGroupSummary(group);
}
bubble.appendChild(details);
toolMount.appendChild(details);
}
// 结束时若出现过父目录,收尾散落项
if (grouped) {
const loose = Array.from(bubble.children).filter(isGroupableToolCall);
const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
if (loose.length > 0) {
const group = bubble.querySelector(':scope > .tool-group');
const group = toolMount.querySelector(':scope > .tool-group');
if (group) {
const inner = group.querySelector('.tool-group-inner');
loose.forEach(c => inner.appendChild(c));