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);