diff --git a/.cbmignore b/.cbmignore new file mode 100644 index 0000000..35587f9 --- /dev/null +++ b/.cbmignore @@ -0,0 +1,43 @@ +# codebase-memory-mcp 专用忽略规则。 +# 保留源码级 public/app.js、public/style.css 等文件;只排除生成物、压缩物和临时产物。 + +# 依赖、运行态数据和日志(多数已在 .gitignore,这里显式补强) +node_modules/ +sessions/ +logs/ +attachments/ +config/cross-conversation-replies.json + +# 构建与覆盖率产物 +dist/ +build/ +coverage/ +.cache/ +.parcel-cache/ +.vite/ +.next/ +.nuxt/ + +# 压缩、归档和二进制产物 +*.zip +*.tar +*.tar.gz +*.tgz +*.gz +*.7z +*.rar + +# 前端生成物:保留普通源码,只排除压缩/映射/打包结果 +*.min.js +*.min.css +*.bundle.js +*.bundle.css +*.map + +# 临时文件 +*.tmp +*.temp +*.bak +*.swp +*.log +* TO DO list.csv diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..91e7486 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +[mcp_servers.codebase-memory-mcp] +type = "stdio" +command = "/home/hdzx/.local/bin/codebase-memory-mcp" +args = [] +enabled = true +startup_timeout_sec = 20 +tool_timeout_sec = 120 +env = { CBM_LOG_LEVEL = "info", CBM_CACHE_DIR = "/home/hdzx/.cache/codebase-memory-mcp" } diff --git a/AGENTS.md b/AGENTS.md index 47ca019..30e18ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,74 @@ Keep this managed block so 'trellis update' can refresh the instructions. 重启 cc-web 服务: +修改 ccweb 项目后如果需要重启服务,必须先查看当前会话列表中的运行状态。 +只有在除当前对话外没有其他 `running` 对话时,才能执行重启;如果仍有其他 +运行中的对话,应暂缓重启并告知用户原因。 + ```bash pm2 restart ccweb --update-env ``` +## Codebase Memory 代码检索约定 + +本项目内 **graphify 已全面弃用**。后续涉及代码定位、调用链、架构理解、影响面分析、跨模块关系梳理时,默认优先使用 `codebase-memory-mcp`,不要再启用 graphify 技能、graphify CLI 或 `graphify-out` 产物作为主路径。 + +当前项目的 codebase-memory 项目名: + +```text +home-cc-web +``` + +### 默认检索流程 + +1. 先确认索引状态: + - `list_projects` + - `index_status(project="home-cc-web")` +2. 如果索引缺失或明显过期,先执行: + - `index_repository(repo_path="/home/cc-web", mode="full", persistence=false)` +3. 需要理解整体结构时,先用: + - `get_architecture(project="home-cc-web", aspects=["all"])` +4. 需要找功能入口、类、函数、路由时,优先用: + - `search_graph` + - `search_code` +5. 需要确认调用关系时,使用: + - `trace_path(mode="calls", direction="inbound" | "outbound" | "both")` +6. 需要读取具体实现时,先通过 `search_graph` 找到精确 `qualified_name`,再用: + - `get_code_snippet` +7. `rg` / `sed` 只作为补充校验: + - 校验最终行号 + - 查未被索引的配置或纯文本 + - 对 MCP 命中结果做交叉验证 + +### 子代理引导词模板 + +派发需要理解代码的子代理时,默认加入以下引导: + +```text +请优先使用 codebase-memory-mcp 做代码理解: +先 list_projects / index_status 确认项目索引; +再用 search_graph 或 search_code 定位候选函数; +需要调用链时用 trace_path; +需要源码时先拿 qualified_name,再调用 get_code_snippet; +最后只用 rg/sed 校验行号或补查未索引文本。 +不要使用 graphify。 +``` + +### 本项目已验证的使用经验 + +- 无明确引导时,子代理不一定会主动使用 `codebase-memory-mcp`,可能退回 `rg` 或旧的 graphify 路径。 +- 明确要求优先使用 `codebase-memory-mcp` 后,能稳定命中函数级入口、调用链和源码片段。 +- 对 `codexapp` / `ccweb` 这类跨模块链路,`search_code` + `trace_path` + `get_code_snippet` 的组合比单纯全文检索更快建立上下文。 +- 自然语言检索遇到 `developer`、`config`、`message` 等通用词会有噪声;这时应收敛到明确标识符,如 `collaborationMode`、`mcp_servers.ccweb`、`CC_WEB_SOURCE_SESSION_ID`。 +- 最终答复中的文件行号仍建议用 `rg -n` 或 `nl -ba` 做一次轻量核验。 + +### 索引更新与忽略规则 + +- `auto_index` 是 `codebase-memory-mcp` 的本地配置,按当前 `CBM_CACHE_DIR` 生效;它不是 `.codex/config.toml` 里的项目级开关。 +- 当前项目的项目级忽略规则写在 `.cbmignore`。普通源码级 `*.js` / `*.css` 不应一刀切排除;只排除 `*.min.js`、`*.map`、压缩包、构建目录、日志、临时文件、运行态状态文件等。 +- watcher 是 Git-based polling:非 Git 项目跳过轮询;Git 项目的轮询间隔为基础 5 秒,每 500 个文件加 1 秒,最长 60 秒。 +- 修改 `.cbmignore` 或大范围调整文件后,建议手动执行一次 `index_repository(repo_path="/home/cc-web", mode="full", persistence=false)`,让当前索引立即收敛到最新规则。 + ## Codex App / hapi 对齐经验 以下约定来自本项目对 `/home/hdzx/2026/hapi` 中 Codex app-server 接入方式的对比结果。后续如果继续维护 `codexapp`,默认按这些约定实现,避免再走偏到“看起来能跑、但拿不到原生协作能力”的分叉路径。 diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json new file mode 100644 index 0000000..b48f43f --- /dev/null +++ b/config/cross-conversation-replies.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "updatedAt": "2026-06-21T15:06:34.507Z", + "replies": [] +} \ No newline at end of file diff --git a/public/app.js b/public/app.js index 65b36b0..de2e8cf 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = ` + + `; + 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 - ? `` + ? `` : ''; const previewPane = canPreview ? `
` @@ -3018,24 +3307,37 @@ return `
${escapeHtml(lang)} -
${previewBtn}
+
${previewBtn}
${previewPane}
${highlighted}
`; }; 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)); diff --git a/public/index.html b/public/index.html index 0ae6a72..89166a1 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -154,6 +154,6 @@ - + diff --git a/public/style.css b/public/style.css index 8ea377f..7d494d2 100644 --- a/public/style.css +++ b/public/style.css @@ -2279,19 +2279,24 @@ body.session-loading-active { border-color: rgba(93, 138, 84, 0.28); } .msg.cross-conversation-reply .cross-conversation-meta, -.msg.cross-conversation-reply .cross-conversation-id-btn { +.msg.cross-conversation-reply .cross-conversation-id-btn, +.msg.cross-conversation-reply .cross-conversation-collapse-btn, +.msg.cross-conversation-reply .cross-conversation-time { color: var(--success); } -.msg.cross-conversation-reply .cross-conversation-id-btn { +.msg.cross-conversation-reply .cross-conversation-id-btn, +.msg.cross-conversation-reply .cross-conversation-collapse-btn { border-color: rgba(93, 138, 84, 0.28); } -.msg.cross-conversation-reply .cross-conversation-id-btn:hover { +.msg.cross-conversation-reply .cross-conversation-id-btn:hover, +.msg.cross-conversation-reply .cross-conversation-collapse-btn:hover { background: rgba(93, 138, 84, 0.14); } .cross-conversation-meta { display: flex; align-items: center; gap: 8px; + row-gap: 6px; flex-wrap: wrap; margin-bottom: 7px; color: var(--info); @@ -2299,10 +2304,19 @@ body.session-loading-active { font-weight: 800; } .cross-conversation-label { + flex: 1 1 180px; min-width: 0; overflow-wrap: anywhere; } -.cross-conversation-id-btn { +.cross-conversation-time { + flex-shrink: 0; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.cross-conversation-id-btn, +.cross-conversation-collapse-btn { appearance: none; border: 1px solid rgba(91, 126, 161, 0.24); border-radius: 999px; @@ -2313,15 +2327,72 @@ body.session-loading-active { font-size: 10px; cursor: pointer; } -.cross-conversation-id-btn:hover { +.cross-conversation-collapse-btn { + min-width: 44px; +} +.cross-conversation-id-btn:hover, +.cross-conversation-collapse-btn:hover { background: rgba(91, 126, 161, 0.14); } +.cross-conversation-collapse-btn:focus-visible { + outline: 2px solid rgba(93, 138, 84, 0.28); + outline-offset: 2px; +} +.msg.cross-conversation-collapsed .cross-conversation-meta { + margin-bottom: 0; +} +.msg.cross-conversation-collapsed .msg-last-section-btn { + display: none; +} .msg.assistant .msg-bubble { background: var(--bg-bubble-assistant); border: 1px solid var(--border-color); border-bottom-left-radius: 4px; color: var(--text-primary); } +.msg-last-section-btn { + appearance: none; + width: 28px; + height: 28px; + margin: 8px 0 0 auto; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + opacity: 0.72; + transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; +} +.msg-last-section-btn[hidden] { + display: none; +} +.msg-last-section-btn svg { + display: block; + flex-shrink: 0; +} +.msg-last-section-btn:hover { + opacity: 1; + background: var(--bg-tertiary); + border-color: var(--accent); + color: var(--accent); + transform: translateY(-1px); +} +.msg-last-section-btn:focus-visible { + opacity: 1; + outline: 2px solid rgba(91, 126, 161, 0.28); + outline-offset: 2px; +} +.msg-last-section-focus { + animation: lastSectionFocus 1.1s ease; +} +@keyframes lastSectionFocus { + 0% { background: rgba(91, 126, 161, 0.18); } + 100% { background: transparent; } +} .msg.assistant .msg-mention-chip { border-color: rgba(48, 62, 82, 0.14); background: rgba(91, 126, 161, 0.07); @@ -2577,26 +2648,50 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { display: flex; justify-content: space-between; align-items: center; + gap: 8px; padding: 6px 12px; background: #2b2b2b; font-size: 12px; color: #999; } +.code-block-header > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .code-block-actions { display: flex; gap: 4px; align-items: center; + flex: 0 0 auto; } .code-copy-btn, .code-preview-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; background: none; border: none; color: #999; cursor: pointer; font-size: 12px; - padding: 2px 8px; + line-height: 1; + padding: 4px 10px; border-radius: 4px; + touch-action: manipulation; + user-select: none; + -webkit-tap-highlight-color: transparent; } .code-copy-btn:hover, .code-preview-btn:hover { color: #fff; background: #444; } +.code-copy-btn:focus-visible, .code-preview-btn:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.45); + outline-offset: 2px; +} +.code-copy-btn:disabled, .code-preview-btn:disabled { + cursor: default; + opacity: 0.72; +} .code-preview-btn { border: 1px solid #555; } .code-block-wrapper pre { margin: 0; @@ -2628,6 +2723,20 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { .code-block-wrapper.preview-mode .code-preview-pane { display: block; } .code-block-wrapper.preview-mode pre { display: none; } +@media (pointer: coarse) { + .code-block-header { + padding: 8px 10px; + } + .code-block-actions { + gap: 6px; + } + .code-copy-btn, .code-preview-btn { + min-width: 44px; + min-height: 36px; + padding: 0 12px; + } +} + /* Tool calls */ .msg-tools { display: flex; @@ -5317,7 +5426,8 @@ html[data-theme='coolvibe'] .settings-back:hover { box-shadow: var(--dark-shadow-soft); } -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .cross-conversation-id-btn { +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .cross-conversation-id-btn, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .cross-conversation-collapse-btn { background: var(--dark-panel-soft); border-color: var(--border-color); }