diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 3341b98ea..80c49f14b 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -4796,18 +4796,75 @@ return rows; } + function archivedSessionRows() { + if (!archivePageHintVisible()) return []; + return sessionRows().filter((row) => row.querySelector('button[aria-label="取消归档对话"]') || row.outerHTML.includes("取消归档") || row.outerHTML.includes("unarchive")); + } + + function archivedRows() { + if (!archivePageHintVisible()) return []; + return [...archivedSessionRows(), ...archivedPageRows()]; + } + + function archivedPageVisible() { + return archivePageHintVisible() && archivedRows().length > 0; + } + + function isClientNewThreadId(value) { + return /^(?:local:)?client-new-thread:/i.test(String(value || "").trim()); + } + + function normalizedCodexThreadUuid(value) { + const id = String(value || "").trim().replace(/^local:/i, ""); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ? id : ""; + } + + function reactConversationIdFromRow(row) { + const fiberKey = Object.getOwnPropertyNames(row).find((key) => key.startsWith("__reactFiber$")); + let fiber = fiberKey ? row[fiberKey] : null; + for (let fiberDepth = 0; fiber && fiberDepth < 16; fiberDepth += 1, fiber = fiber.return) { + for (const props of [fiber.pendingProps, fiber.memoizedProps]) { + const directId = normalizedCodexThreadUuid(props?.conversationId); + if (directId) return directId; + const childId = normalizedCodexThreadUuid( + props?.children?.props?.conversationId, + ); + if (childId) return childId; + } + } + return ""; + } + function sessionRefFromRow(row) { const href = row.getAttribute("href") || row.querySelector("a")?.getAttribute("href") || ""; const idMatch = href.match(/(?:session|conversation|thread)[=/:-]([A-Za-z0-9_.-]+)/i) || href.match(/([A-Za-z0-9_-]{8,})$/); const codexThreadId = row.getAttribute("data-app-action-sidebar-thread-id") || ""; const fallbackId = row.getAttribute("data-session-id") || row.getAttribute("data-testid") || ""; - const sessionId = codexThreadId || (idMatch && idMatch[1]) || fallbackId; + const placeholderThreadId = isClientNewThreadId(codexThreadId); + const hrefId = idMatch && idMatch[1]; + const canonicalHrefId = normalizedCodexThreadUuid(hrefId); + const hrefIsTemporary = isClientNewThreadId(href) + || isClientNewThreadId(hrefId) + || /(?:^|[=/])(?:local:)?client-new-thread:/i.test(href); + const sessionId = placeholderThreadId + ? canonicalHrefId || (!hrefIsTemporary ? reactConversationIdFromRow(row) : "") + : normalizedCodexThreadUuid(codexThreadId) + || canonicalHrefId + || codexThreadId + || hrefId + || fallbackId; const titleNode = row.querySelector(`${selectors.threadTitle}, .truncate.select-none, .truncate.text-base`); const rawTitle = (titleNode?.textContent || (titleNode ? "" : (row.textContent || "Untitled session"))); const title = (titleNode ? rawTitle : rawTitle.replace(/\s*(导出|删除|移动|移出项目)(\s*(导出|删除|移动|移出项目))*$/g, "")).trim().slice(0, 160); return { session_id: sessionId, title }; } + if (window.__CODEX_PLUS_TEST_SESSION_REF__) { + window.__codexPlusSessionRefTest = { + fromRow: sessionRefFromRow, + }; + } + function threadIdBadgeTitleNode(row) { return row.querySelector(`${selectors.threadTitle}, .truncate.select-none, .truncate.text-base`); } @@ -8317,7 +8374,16 @@ const row = button?.closest?.("[data-app-action-sidebar-thread-id]"); if (!button || !row) return; const ref = sessionRefFromRow(row); - if (!ref.session_id) return; + if (!ref.session_id) { + const placeholderId = row.getAttribute("data-app-action-sidebar-thread-id"); + if (isClientNewThreadId(placeholderId)) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + showToast("会话仍在同步,请稍后重试", null); + } + return; + } openDeleteConfirmForRow(row, button, ref, event); }; window.__codexSessionDeleteDocumentDeleteHandler = handler; @@ -8625,7 +8691,7 @@ deleteButton.className = `${actionButtonClass} ${buttonClass}`; deleteButton.dataset.codexDeleteVersion = codexDeleteVersion; configureSvgActionButton(deleteButton, "删除", trashIconSvg()); - const openDeleteConfirm = (event) => openDeleteConfirmForRow(row, deleteButton, ref, event); + const openDeleteConfirm = (event) => openDeleteConfirmForRow(row, deleteButton, sessionRefFromRow(row), event); installActionButtonEvents(row, deleteButton, openDeleteConfirm); group.appendChild(deleteButton); setTimeout(() => refreshActionButton(deleteButton, row, openDeleteConfirm), 0); @@ -9923,7 +9989,15 @@ window.addEventListener("resize", window.__codexPlusResizeHandler); window.__codexSessionDeleteObserver?.disconnect(); window.__codexSessionDeleteObserver = new MutationObserver(scheduleScan); - window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); + window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { + childList: true, + subtree: true, + // Codex may promote a newly-created row from a temporary client ID to its + // persisted UUID without replacing the DOM node. Re-scan those rows so the + // action button and its delete reference are rebuilt from the canonical ID. + attributes: true, + attributeFilter: ["data-app-action-sidebar-thread-id", "href"], + }); })(); // === 粘贴修复 (CodexPlusPlus 页面增强) === diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index eb1bcfe8b..a4e076336 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -1306,6 +1306,138 @@ fn injection_script_refreshes_sidebar_after_session_undo() { assert!(toast.contains("if (!refreshed) window.location.reload();")); } +#[test] +fn injection_script_guards_temporary_new_thread_ids_before_delete() { + let script = assets::injection_script(57321); + + assert!(script.contains("function isClientNewThreadId(value)")); + assert!(script.contains("function normalizedCodexThreadUuid(value)")); + assert!(script.contains("function reactConversationIdFromRow(row)")); + assert!(script.contains("__reactFiber$")); + assert!(script.contains("props?.conversationId")); + assert!(!script.contains("props?.threadId || props?.sessionId")); + assert!(script.contains("|| /(?:^|[=/])(?:local:)?client-new-thread:/i.test(href)")); + assert!(script.contains( + "? canonicalHrefId || (!hrefIsTemporary ? reactConversationIdFromRow(row) : \"\")" + )); + assert!(script.contains("const openDeleteConfirm = (event) => openDeleteConfirmForRow(row, deleteButton, sessionRefFromRow(row), event)")); + assert!(script.contains("会话仍在同步,请稍后重试")); + assert!(script.contains("attributeFilter: [\"data-app-action-sidebar-thread-id\", \"href\"]")); + + let cases = run_session_ref_contract_harness(); + assert_eq!( + cases["canonicalAttribute"], + "11111111-1111-4111-8111-111111111111" + ); + assert_eq!( + cases["canonicalHref"], + "22222222-2222-4222-8222-222222222222" + ); + assert_eq!( + cases["conversationId"], + "33333333-3333-4333-8333-333333333333" + ); + assert_eq!(cases["unrelatedIds"], ""); + assert_eq!(cases["temporaryHref"], ""); +} + +fn run_session_ref_contract_harness() -> serde_json::Value { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let script_path = temp.path().join("renderer-inject.js"); + let harness_path = temp.path().join("session-ref-harness.cjs"); + std::fs::write(&script_path, assets::injection_script(57321)) + .expect("injection script should be written"); + let mut harness = std::fs::File::create(&harness_path).expect("harness should be created"); + write!( + harness, + r#" +const scriptPath = {script_path}; +function node() {{ + return {{ + appendChild() {{}}, prepend() {{}}, remove() {{}}, setAttribute() {{}}, removeAttribute() {{}}, + addEventListener() {{}}, querySelector() {{ return null; }}, querySelectorAll() {{ return []; }}, + closest() {{ return null; }}, getAttribute() {{ return null; }}, + classList: {{ add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }} }}, + dataset: {{}}, style: {{}}, children: [], isConnected: true, textContent: "", innerHTML: "", + }}; +}} +globalThis.window = globalThis; +window.__CODEX_PLUS_TEST_SESSION_REF__ = true; +window.addEventListener = () => {{}}; +window.removeEventListener = () => {{}}; +window.dispatchEvent = () => true; +globalThis.MutationObserver = class {{ observe() {{}} disconnect() {{}} }}; +globalThis.ResizeObserver = class {{ observe() {{}} disconnect() {{}} }}; +globalThis.IntersectionObserver = class {{ observe() {{}} disconnect() {{}} }}; +globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.setInterval = () => 0; +globalThis.clearInterval = () => {{}}; +globalThis.document = {{ + scripts: [], documentElement: node(), body: node(), createElement: () => node(), + getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], + addEventListener() {{}}, removeEventListener() {{}}, +}}; +globalThis.localStorage = {{ getItem: () => null, setItem() {{}}, removeItem() {{}} }}; +globalThis.sessionStorage = globalThis.localStorage; +globalThis.location = {{ href: "https://codex.test/index.html", pathname: "/index.html", search: "", hash: "" }}; +window.location = globalThis.location; +globalThis.navigator = {{ userAgent: "node-test", sendBeacon: () => false }}; +globalThis.performance = {{ getEntriesByType: () => [] }}; +globalThis.fetch = async () => ({{ ok: true, json: async () => ({{}}) }}); +require(scriptPath); + +const api = window.__codexPlusSessionRefTest; +const placeholder = "local:client-new-thread:fixture"; +const row = (attributes, props = null) => {{ + const value = {{ + getAttribute: (name) => attributes[name] || null, + querySelector: () => null, + textContent: "Fixture", + }}; + if (props) value.__reactFiber$fixture = {{ pendingProps: props, memoizedProps: null, return: null }}; + return value; +}}; +const sessionId = (value) => api.fromRow(value).session_id; +const cases = {{ + canonicalAttribute: sessionId(row({{ "data-app-action-sidebar-thread-id": "11111111-1111-4111-8111-111111111111" }})), + canonicalHref: sessionId(row({{ + "data-app-action-sidebar-thread-id": placeholder, + href: "/thread/22222222-2222-4222-8222-222222222222", + }})), + conversationId: sessionId(row( + {{ "data-app-action-sidebar-thread-id": placeholder }}, + {{ conversationId: "33333333-3333-4333-8333-333333333333" }}, + )), + unrelatedIds: sessionId(row( + {{ "data-app-action-sidebar-thread-id": placeholder }}, + {{ threadId: "44444444-4444-4444-8444-444444444444", sessionId: "55555555-5555-4555-8555-555555555555" }}, + )), + temporaryHref: sessionId(row( + {{ "data-app-action-sidebar-thread-id": placeholder, href: "/thread/client-new-thread:fixture" }}, + {{ conversationId: "66666666-6666-4666-8666-666666666666" }}, + )), +}}; +process.stdout.write(JSON.stringify(cases)); +process.exit(0); +"#, + script_path = serde_json::to_string(&script_path.to_string_lossy().to_string()) + .expect("script path should serialize") + ) + .expect("harness should be written"); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should execute session reference harness"); + assert!( + output.status.success(), + "session reference harness failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("session reference harness output should be JSON") +} + #[test] fn injection_script_moves_export_and_project_move_into_more_menu() { let script = assets::injection_script(57321).replace("\r\n", "\n");