From 99c05c0346a63dbe7fd58fed8f87c5fe31cba003 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Wed, 1 Jul 2026 09:42:54 +0800 Subject: [PATCH] Fix Stepwise refresh controls --- assets/inject/stepwise-inject.js | 130 +++++++++++++++++++-- crates/codex-plus-core/tests/cdp_bridge.rs | 29 +++++ 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/assets/inject/stepwise-inject.js b/assets/inject/stepwise-inject.js index c70f0809..dda71b68 100644 --- a/assets/inject/stepwise-inject.js +++ b/assets/inject/stepwise-inject.js @@ -43,6 +43,7 @@ lastAssistantHash: "", lastAssistantAt: 0, currentHash: "", + lastScanStatus: "", bridgeCache: new Map(), bridgePendingHash: "", bridgeStatus: "idle", @@ -278,6 +279,9 @@ if (name === "sun") { return ``; } + if (name === "refresh") { + return ``; + } return ``; } @@ -501,6 +505,11 @@ color: var(--csw-text); } + .csw-icon:disabled { + cursor: not-allowed; + opacity: .42; + } + .csw-icon svg { display: block; height: 18px; @@ -1000,10 +1009,13 @@ state.popover.dataset.open = state.open ? "true" : "false"; if (!state.open) return; + const refreshBlocked = state.bridgeStatus === "pending" || chatBusy(); + const refreshTitle = refreshBlocked ? "生成结束后可重新生成" : "重新生成"; state.popover.innerHTML = `
Stepwise
+ @@ -1021,6 +1033,7 @@ state.open = false; renderFloat(); }); + state.popover.querySelector("[data-action='refresh']")?.addEventListener("click", () => forceRefreshStepwise()); state.popover.querySelector("[data-action='theme']")?.addEventListener("click", toggleCodexTheme); if (state.activeTab === "settings") attachSettingsEvents(); @@ -1382,6 +1395,13 @@ }); } + function setScanStatus(status, details = {}) { + const key = `${status}:${JSON.stringify(details)}`; + if (state.lastScanStatus === key) return; + state.lastScanStatus = key; + pushDiagnostic(`scan:${status}`, details); + } + function composerBusy(target) { let current = target?.parentElement || null; for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { @@ -1423,6 +1443,45 @@ return /^(复制|喜欢|不喜欢|从此处开始分叉|挂钩|copy|like|dislike|fork)/i.test(label); } + function classTokenMatch(node, token) { + return node instanceof Element && Array.from(node.classList || []).some((className) => className === token); + } + + function assistantBubbleCandidates() { + const root = chatRoot(); + if (!root) return []; + + return Array.from(root.querySelectorAll(".group.flex.min-w-0.flex-col")) + .filter((node) => { + if (!(node instanceof HTMLElement)) return false; + if (state.root?.contains(node)) return false; + if (classTokenMatch(node, "items-end")) return false; + const text = directText(node); + if (text.length < 24 || text.length > MAX_TEXT_LENGTH) return false; + return true; + }) + .map((node) => ({ + node, + role: "assistant", + text: elementText(node), + })); + } + + function latestMessageByDocumentOrder(candidates) { + return candidates + .filter((item) => item?.node instanceof Node && item.text?.length > 8) + .sort((left, right) => { + if (left.node === right.node) return 0; + const position = left.node.compareDocumentPosition(right.node); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + if (left.node.contains(right.node)) return -1; + if (right.node.contains(left.node)) return 1; + return 0; + }) + .at(-1) || null; + } + function actionRowForMessage(root) { const buttons = Array.from(root.querySelectorAll("button,[role='button']")).filter(actionButton); for (const button of buttons) { @@ -1482,15 +1541,17 @@ } function findLatestAssistantMessage() { + const candidates = []; const rows = allActionRows(); - for (let index = rows.length - 1; index >= 0; index -= 1) { + for (let index = 0; index < rows.length; index += 1) { const node = assistantContainerForActionRow(rows[index]); const text = elementText(node); - if (text.length > 8) return { node, role: "assistant", text }; + if (text.length > 8) candidates.push({ node, role: "assistant", text }); } - const fallback = messageCandidates().filter((item) => item.role === "assistant"); - return fallback[fallback.length - 1] || null; + candidates.push(...messageCandidates().filter((item) => item.role === "assistant")); + candidates.push(...assistantBubbleCandidates()); + return latestMessageByDocumentOrder(candidates); } function findPreviousUserText(assistantNode) { @@ -1747,6 +1808,52 @@ }); } + function forceRefreshStepwise() { + if (!isCurrentInstance()) return; + if (state.bridgeStatus === "pending") { + setScanStatus("manual-refresh-pending", {}); + return; + } + if (chatBusy()) { + if (!state.prompts.length) state.bridgeError = "回答生成中,结束后再刷新"; + setScanStatus("manual-refresh-busy", {}); + renderFloat(); + return; + } + + const message = findLatestAssistantMessage(); + if (!message) { + state.bridgeError = "未找到可用于生成的回答"; + state.prompts = []; + setScanStatus("manual-refresh-no-assistant", {}); + renderFloat(); + return; + } + + const stepwisePayload = extractStepwisePayload(message); + hideStepwisePayload(message.node); + const assistantText = shortText(stepwisePayload.textWithoutPayload || message.text); + const userText = findPreviousUserText(message.node); + const bridgeKey = bridgeRequestKey(userText, assistantText); + if (bridgeKey) state.bridgeCache.delete(bridgeKey); + + state.lastAssistantHash = hashText(assistantText); + state.lastAssistantAt = 0; + state.currentHash = `${state.lastAssistantHash}:manual-refresh`; + state.prompts = []; + state.bridgeError = ""; + setScanStatus("manual-refresh", { hash: state.lastAssistantHash, textLength: assistantText.length }); + requestBridgeStepwise(bridgeKey, userText, assistantText); + renderFloat(); + } + + function clearPromptsForNewAssistant(hash) { + state.currentHash = `${hash}:pending`; + state.prompts = []; + state.bridgeError = ""; + renderFloat(); + } + function setNativeValue(element, value) { const prototype = Object.getPrototypeOf(element); const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); @@ -1928,8 +2035,9 @@ installFloat(); if (!chatSurfaceReady()) { - pushDiagnostic("scan:not-ready", { - hasChatRoot: Boolean(chatRoot()), + setScanStatus("not-ready", { + hasRoot: Boolean(chatRoot()), + composerCount: composerCandidates().length, busy: chatBusy(), }); renderFloat(); @@ -1938,7 +2046,7 @@ const message = findLatestAssistantMessage(); if (!message) { - pushDiagnostic("scan:no-assistant-message", { + setScanStatus("no-assistant-message", { messageCandidateCount: messageCandidates().length, actionRowCount: allActionRows().length, }); @@ -1956,11 +2064,14 @@ if (hash !== state.lastAssistantHash) { state.lastAssistantHash = hash; state.lastAssistantAt = now; + if (state.prompts.length || state.currentHash) clearPromptsForNewAssistant(hash); + setScanStatus("assistant-changed", { hash, textLength: assistantText.length }); scheduleScan(STREAM_IDLE_MS + 120); return; } if (now - state.lastAssistantAt < STREAM_IDLE_MS) { + setScanStatus("assistant-settling", { hash }); scheduleScan(STREAM_IDLE_MS); return; } @@ -1979,6 +2090,11 @@ }); requestBridgeStepwise(bridgeKey, userText, assistantText); } + setScanStatus("ready", { + hash, + bridgeCached: Boolean(bridgeResult), + promptCount: prompts.length, + }); const nextHash = hashText(prompts.map((item) => `${item.label}\n${item.prompt}`).join("\n\n")); if (state.currentHash !== `${hash}:${nextHash}`) { diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index e7683a5d..2ba4fe25 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -186,6 +186,35 @@ fn stepwise_assistant_detection_accepts_two_action_buttons() { assert!(!script.contains("if (count < 3) continue;")); } +#[test] +fn stepwise_refreshes_suggestions_for_virtualized_assistant_bubbles() { + let script = assets::stepwise_script(); + + assert!(script.contains("function assistantBubbleCandidates(")); + assert!(script.contains("\".group.flex.min-w-0.flex-col\"")); + assert!(script.contains("candidates.push(...assistantBubbleCandidates())")); + assert!(script.contains("function latestMessageByDocumentOrder(")); + assert!(script.contains("function clearPromptsForNewAssistant(")); + assert!(script.contains("if (state.prompts.length || state.currentHash) clearPromptsForNewAssistant(hash);")); + assert!(script.contains("function setScanStatus(")); + assert!(script.contains("setScanStatus(\"not-ready\"")); + assert!(script.contains("setScanStatus(\"no-assistant-message\"")); + assert!(!script.contains("setScanStatus(\"surface-not-ready\"")); + assert!(!script.contains("return fallback[fallback.length - 1] || null;")); +} + +#[test] +fn stepwise_exposes_manual_refresh_without_refreshing_busy_chats() { + let script = assets::stepwise_script(); + + assert!(script.contains("data-action=\"refresh\"")); + assert!(script.contains("function forceRefreshStepwise(")); + assert!(script.contains("state.bridgeStatus === \"pending\" || chatBusy()")); + assert!(script.contains("setScanStatus(\"manual-refresh-busy\"")); + assert!(script.contains("state.bridgeCache.delete(bridgeKey)")); + assert!(script.contains("requestBridgeStepwise(bridgeKey, userText, assistantText)")); +} + #[test] fn injection_script_defers_backend_mapped_toggles_until_settings_load() { let script = assets::injection_script(57321);