diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index e9688b8..aadd690 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/codex-rollouts.js b/lib/codex-rollouts.js index 6417c27..d96f27d 100644 --- a/lib/codex-rollouts.js +++ b/lib/codex-rollouts.js @@ -18,15 +18,38 @@ function createCodexRolloutStore(deps) { turn.content = turn.content ? `${turn.content}\n\n${text}` : text; } + function extractCcwebSourceConversation(text) { + const match = String(text || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/); + if (!match) return null; + return { title: match[1], id: match[2].toLowerCase() }; + } + function parseCodexRolloutLines(lines) { const messages = []; const pendingToolCalls = new Map(); - const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null }; + const meta = { + threadId: null, + cwd: null, + title: '', + updatedAt: null, + cliVersion: null, + source: null, + sourceConversationId: null, + sourceConversationTitle: '', + }; const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }; let currentAssistant = null; let sawRealUserMessage = false; const fallbackUserMessages = []; + function rememberSourceConversation(text) { + if (meta.sourceConversationId) return; + const sourceConversation = extractCcwebSourceConversation(text); + if (!sourceConversation) return; + meta.sourceConversationId = sourceConversation.id; + meta.sourceConversationTitle = sourceConversation.title; + } + function ensureAssistant(ts) { if (!currentAssistant) { currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null }; @@ -81,6 +104,7 @@ function createCodexRolloutStore(deps) { if (text) { sawRealUserMessage = true; flushAssistant(); + rememberSourceConversation(text); if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' '); messages.push({ role: 'user', content: text, timestamp: ts }); } @@ -103,6 +127,7 @@ function createCodexRolloutStore(deps) { } else if (payload.role === 'user' && !sawRealUserMessage) { const text = extractCodexMessageText(payload.content); if (text.trim()) { + rememberSourceConversation(text); fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts }); } } diff --git a/public/app.js b/public/app.js index 1be3710..24859aa 100644 --- a/public/app.js +++ b/public/app.js @@ -9878,8 +9878,32 @@ // --- Import Native Session Modal --- let _onNativeSessions = null; + function appendImportVisibilityToggle(body, options) { + const hiddenCount = Number(options?.hiddenCount || 0); + if (!body || hiddenCount <= 0) return; + const row = document.createElement('label'); + row.className = 'import-filter-row'; + row.innerHTML = ` + + 显示已导入会话 + 已隐藏 ${hiddenCount} 个 cc-web 已存在的会话 + + + + + + `; + const input = row.querySelector('input'); + input.addEventListener('change', () => options.onToggle(!!input.checked)); + body.appendChild(row); + } + function showImportSessionModal() { if (currentAgent !== 'claude') return; + let nativeGroups = []; + let showImported = false; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'import-session-overlay'; @@ -9907,15 +9931,40 @@ overlay.querySelector('#is-close-btn').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - _onNativeSessions = (groups) => { + function renderNativeSessions() { const body = overlay.querySelector('#is-body'); if (!body) return; - if (!groups || groups.length === 0) { + if (!nativeGroups || nativeGroups.length === 0) { body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}`; return; } body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。'); - for (const group of groups) { + const hiddenCount = nativeGroups.reduce((sum, group) => ( + sum + (Array.isArray(group.sessions) ? group.sessions.filter((sess) => sess.alreadyImported).length : 0) + ), 0); + appendImportVisibilityToggle(body, { + hiddenCount, + showImported, + onToggle: (next) => { + showImported = next; + renderNativeSessions(); + }, + }); + + const visibleGroups = nativeGroups + .map((group) => ({ + ...group, + sessions: showImported + ? (group.sessions || []) + : (group.sessions || []).filter((sess) => !sess.alreadyImported), + })) + .filter((group) => group.sessions.length > 0); + if (visibleGroups.length === 0) { + body.insertAdjacentHTML('beforeend', ``); + return; + } + + for (const group of visibleGroups) { const groupEl = document.createElement('div'); groupEl.className = 'import-group'; // Convert slug dir to readable path @@ -9959,6 +10008,11 @@ } body.appendChild(groupEl); } + } + + _onNativeSessions = (groups) => { + nativeGroups = Array.isArray(groups) ? groups : []; + renderNativeSessions(); }; send({ type: 'list_native_sessions' }); @@ -9967,6 +10021,8 @@ function showImportCodexSessionModal() { if (!isCodexLikeAgent(currentAgent)) return; const importAgent = currentAgent; + let codexItems = []; + let showImported = false; const label = AGENT_LABELS[importAgent] || 'Codex'; const contextTitle = importAgent === 'codexapp' ? '从 Codex App rollout 历史导入' @@ -10001,16 +10057,32 @@ overlay.querySelector('#ics-close-btn').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - _onCodexSessions = (items) => { + function renderCodexSessions() { const body = overlay.querySelector('#ics-body'); if (!body) return; - if (!items || items.length === 0) { + if (!codexItems || codexItems.length === 0) { body.innerHTML = `${buildAgentContextCard(importAgent, contextTitle, contextCopy)}`; return; } body.innerHTML = buildAgentContextCard(importAgent, contextTitle, contextCopy); - items.forEach((sess) => { + const hiddenCount = codexItems.filter((sess) => sess.alreadyImported).length; + appendImportVisibilityToggle(body, { + hiddenCount, + showImported, + onToggle: (next) => { + showImported = next; + renderCodexSessions(); + }, + }); + + const visibleItems = showImported ? codexItems : codexItems.filter((sess) => !sess.alreadyImported); + if (visibleItems.length === 0) { + body.insertAdjacentHTML('beforeend', ``); + return; + } + + visibleItems.forEach((sess) => { const item = document.createElement('div'); item.className = 'import-item'; @@ -10043,6 +10115,12 @@ source.textContent = sess.source; tags.appendChild(source); } + if ((sess.duplicateCount || 0) > 1) { + const merged = document.createElement('span'); + merged.className = 'import-item-tag'; + merged.textContent = `合并 ${sess.duplicateCount} 条`; + tags.appendChild(merged); + } info.appendChild(titleEl); info.appendChild(meta); @@ -10064,6 +10142,11 @@ item.appendChild(btn); body.appendChild(item); }); + } + + _onCodexSessions = (items) => { + codexItems = Array.isArray(items) ? items : []; + renderCodexSessions(); }; send({ type: 'list_codex_sessions', agent: importAgent }); diff --git a/public/style.css b/public/style.css index bdfd7aa..eec9601 100644 --- a/public/style.css +++ b/public/style.css @@ -5396,6 +5396,33 @@ html[data-theme='coolvibe'] .settings-back:hover { } /* === Import Session List === */ +.import-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0 14px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + cursor: pointer; +} +.import-filter-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.import-filter-title { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} +.import-filter-meta { + font-size: 12px; + color: var(--text-muted); +} .import-group { margin-bottom: 20px; } diff --git a/scripts/regression.js b/scripts/regression.js index 10e402c..8cd7277 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -515,6 +515,17 @@ function assertFrontendGenerationControlsContract() { source.includes("send({ type: 'import_codex_session', agent: importAgent"), 'Frontend Codex import modal should pass the selected Codex-like agent' ); + assert( + source.includes('function appendImportVisibilityToggle') && + source.includes('显示已导入会话') && + source.includes('cc-web 已存在的会话'), + 'Frontend import modal should expose a toggle for already imported sessions' + ); + assert( + source.includes('(group.sessions || []).filter((sess) => !sess.alreadyImported)') && + source.includes('codexItems.filter((sess) => !sess.alreadyImported)'), + 'Frontend import modal should hide already imported sessions by default' + ); } function assertFrontendComposerMcpContract() { @@ -697,6 +708,32 @@ async function main() { source: 'vscode', fileStamp: '2026-03-12T00-00-10', }); + const duplicateSourceConversationId = '11111111-1111-4111-8111-111111111111'; + const duplicateSourceConversationTitle = '你能看下 00a7cbc2-d0c3-457f-a262-aa5a5859fa54 这个对话么, 你来评估下,这个对话中'; + createFakeCodexHistory(homeDir, { + threadId: 'codexapp-duplicate-thread-a', + cwd: '/tmp/project-c', + userText: `来自「${duplicateSourceConversationTitle}」对话(ID: ${duplicateSourceConversationId})的消息:\n\n旧候选`, + answerText: 'duplicate import answer a', + source: 'vscode', + fileStamp: '2026-03-12T00-00-20', + }); + createFakeCodexHistory(homeDir, { + threadId: 'codexapp-duplicate-thread-b', + cwd: '/tmp/project-c', + userText: `来自「${duplicateSourceConversationTitle}」对话(ID: ${duplicateSourceConversationId})的消息:\n\n新候选`, + answerText: 'duplicate import answer b', + source: 'vscode', + fileStamp: '2026-03-12T00-00-21', + }); + const codexAppObjectSourceFixture = createFakeCodexHistory(homeDir, { + threadId: 'codexapp-object-source-thread', + cwd: '/tmp/project-c', + userText: 'Object source import prompt', + answerText: 'Object source import answer', + source: { subagent: { thread_spawn: { parent_thread_id: 'parent-thread', depth: 1 } } }, + fileStamp: '2026-03-12T00-00-22', + }); const port = await getFreePort(); const password = 'Regression!234'; @@ -1899,6 +1936,11 @@ async function main() { assert(codexAppImportItem, 'Codex App session listing failed'); assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent'); assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state'); + const duplicateSourceItems = codexAppImportSessions.sessions.filter((item) => item.sourceConversationId === duplicateSourceConversationId); + assert(duplicateSourceItems.length === 1, 'Codex App import list should collapse rollout entries from the same cc-web source conversation'); + assert(duplicateSourceItems[0].duplicateCount === 2, 'Collapsed Codex App import item should report duplicate rollout count'); + const objectSourceItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppObjectSourceFixture.threadId); + assert(objectSourceItem?.source === 'subagent', 'Codex App import list should format object source metadata'); ws.send(JSON.stringify({ type: 'import_codex_session', diff --git a/server.js b/server.js index 4b0086c..5b7ee01 100644 --- a/server.js +++ b/server.js @@ -9882,10 +9882,46 @@ function resolveCodexImportAgent(value) { return value === 'codexapp' ? 'codexapp' : 'codex'; } +function codexImportSourceLabel(source) { + if (!source) return ''; + if (typeof source === 'string') return source; + if (source && typeof source === 'object') { + if (source.subagent?.thread_spawn) return 'subagent'; + if (source.type) return String(source.type); + } + return ''; +} + +function extractCcwebSourceConversation(title) { + const match = String(title || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/); + if (!match) return null; + return { + id: match[2].toLowerCase(), + title: match[1], + }; +} + +function codexImportDedupeKey(item) { + if (item.sourceConversationId) return `ccweb-source:${item.sourceConversationId}`; + return `thread:${item.threadId}`; +} + +function codexImportItemTime(item) { + const time = new Date(item?.updatedAt || 0).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function preferCodexImportItem(next, current) { + const nextTime = codexImportItemTime(next); + const currentTime = codexImportItemTime(current); + if (nextTime !== currentTime) return nextTime > currentTime ? next : current; + return String(next.rolloutPath || '') > String(current.rolloutPath || '') ? next : current; +} + function handleListCodexSessions(ws, msg = {}) { const importAgent = resolveCodexImportAgent(msg?.agent); const imported = getImportedCodexThreadIds(importAgent); - const items = []; + const itemsByKey = new Map(); const seen = new Set(); for (const filePath of getCodexRolloutFiles()) { const parsed = parseCodexRolloutFile(filePath); @@ -9893,18 +9929,41 @@ function handleListCodexSessions(ws, msg = {}) { if (seen.has(parsed.meta.threadId)) continue; seen.add(parsed.meta.threadId); const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20); - items.push({ + const sourceConversation = parsed.meta.sourceConversationId + ? { + id: parsed.meta.sourceConversationId, + title: parsed.meta.sourceConversationTitle || '', + } + : extractCcwebSourceConversation(title); + const item = { threadId: parsed.meta.threadId, title, cwd: parsed.meta.cwd || null, updatedAt: parsed.meta.updatedAt || null, cliVersion: parsed.meta.cliVersion || '', - source: parsed.meta.source || '', + source: codexImportSourceLabel(parsed.meta.source), + sourceConversationId: sourceConversation?.id || null, + sourceConversationTitle: sourceConversation?.title || '', + duplicateCount: 1, rolloutPath: filePath, agent: importAgent, alreadyImported: imported.has(parsed.meta.threadId), - }); + }; + const dedupeKey = codexImportDedupeKey(item); + const current = itemsByKey.get(dedupeKey); + if (!current) { + itemsByKey.set(dedupeKey, item); + continue; + } + const preferred = preferCodexImportItem(item, current); + preferred.duplicateCount = (current.duplicateCount || 1) + 1; + itemsByKey.set(dedupeKey, preferred); } + const items = Array.from(itemsByKey.values()).sort((a, b) => { + const timeDiff = codexImportItemTime(b) - codexImportItemTime(a); + if (timeDiff) return timeDiff; + return String(b.rolloutPath || '').localeCompare(String(a.rolloutPath || '')); + }); wsSend(ws, { type: 'codex_sessions', sessions: items }); }