- commit
- 499bd69
- parent
- eb118ac
- author
- im_wower
- date
- 2026-03-28 14:48:20 +0800 CST
feat: add page-level conductor pause controls
3 files changed,
+1112,
-37
+335,
-22
1@@ -1,5 +1,6 @@
2 const CONTENT_SCRIPT_RUNTIME_KEY = "__baaFirefoxContentScriptRuntime__";
3 const CONTROL_STATE_STORAGE_KEY = "baaFirefox.controlState";
4+const PAGE_CONTROL_STATE_STORAGE_KEY = "baaFirefox.pageControls";
5 const PAGE_CONTROL_OVERLAY_ID = "__baaFirefoxPageControlOverlay__";
6
7 const previousContentScriptRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
8@@ -73,19 +74,118 @@ function formatConnectionLabel(connection) {
9 }
10 }
11
12+function resolveOverlayVisualState(snapshot, pendingAction) {
13+ if (trimToNull(pendingAction)) {
14+ return "pending";
15+ }
16+
17+ const connection = normalizeControlConnection(snapshot?.controlConnection);
18+ if (connection === "connecting" || connection === "retrying") {
19+ return "pending";
20+ }
21+
22+ if (connection !== "connected") {
23+ return "disconnected";
24+ }
25+
26+ return normalizeMode(snapshot?.mode);
27+}
28+
29+function resolveCompactControlAction(snapshot, pendingAction) {
30+ if (trimToNull(pendingAction)) {
31+ return {
32+ action: null,
33+ label: "处理中",
34+ title: `正在执行 ${pendingAction}`
35+ };
36+ }
37+
38+ const mode = normalizeMode(snapshot?.mode);
39+
40+ if (mode === "paused" || mode === "draining") {
41+ return {
42+ action: "resume",
43+ label: "恢复",
44+ title: "恢复 Conductor"
45+ };
46+ }
47+
48+ return {
49+ action: "pause",
50+ label: "暂停",
51+ title: "暂停 Conductor"
52+ };
53+}
54+
55+function normalizePageControlSnapshot(value) {
56+ if (!value || typeof value !== "object" || Array.isArray(value)) {
57+ return null;
58+ }
59+
60+ return {
61+ key: trimToNull(value.key),
62+ platform: trimToNull(value.platform),
63+ tabId: Number.isInteger(value.tabId) ? value.tabId : null,
64+ conversationId: trimToNull(value.conversationId),
65+ pageUrl: trimToNull(value.pageUrl),
66+ pageTitle: trimToNull(value.pageTitle),
67+ paused: value.paused === true,
68+ shellPage: value.shellPage === true,
69+ updatedAt: Number(value.updatedAt) || 0
70+ };
71+}
72+
73+function selectPageControlSnapshot(rawMap, key) {
74+ if (!key || !rawMap || typeof rawMap !== "object" || Array.isArray(rawMap)) {
75+ return null;
76+ }
77+
78+ return normalizePageControlSnapshot(rawMap[key]);
79+}
80+
81+function formatPageStatusLabel(snapshot) {
82+ if (!snapshot) {
83+ return "未识别";
84+ }
85+
86+ return snapshot.paused ? "本页已暂停" : "本页运行中";
87+}
88+
89+function formatPendingActionLabel(action) {
90+ switch (String(action || "").trim().toLowerCase()) {
91+ case "pause":
92+ return "全局暂停";
93+ case "resume":
94+ return "全局恢复";
95+ case "drain":
96+ return "全局排空";
97+ case "page_pause":
98+ return "暂停本页";
99+ case "page_resume":
100+ return "恢复本页";
101+ default:
102+ return null;
103+ }
104+}
105+
106 function createPageControlOverlayRuntime() {
107 let root = null;
108 let shadow = null;
109+ let dock = null;
110 let panel = null;
111 let statusText = null;
112 let errorText = null;
113 let pendingAction = null;
114 let controlSnapshot = null;
115+ let pageControlSnapshot = null;
116+ let pageControlKey = null;
117 let destroyed = false;
118 let dismissButton = null;
119 let hidden = false;
120 let buttonMap = {};
121- let toggleButton = null;
122+ let badgeButton = null;
123+ let compactActionButton = null;
124+ let pageActionButton = null;
125
126 const overlayStyle = `
127 :host {
128@@ -158,6 +258,13 @@ function createPageControlOverlayRuntime() {
129 word-break: break-word;
130 }
131
132+ .baa-page-actions {
133+ display: grid;
134+ grid-template-columns: minmax(0, 1fr);
135+ gap: 6px;
136+ margin-bottom: 6px;
137+ }
138+
139 .baa-error {
140 min-height: 14px;
141 font-size: 11px;
142@@ -207,11 +314,30 @@ function createPageControlOverlayRuntime() {
143 color: #f2cb78;
144 }
145
146- .baa-toggle {
147+ .baa-btn[data-role="page"] {
148+ color: #9fd9ff;
149+ }
150+
151+ .baa-btn[data-role="page"][data-paused="true"] {
152+ color: #b9f0c7;
153+ }
154+
155+ .baa-dock {
156 position: fixed;
157 right: 14px;
158 bottom: 14px;
159 z-index: 2147483647;
160+ display: none;
161+ align-items: center;
162+ gap: 8px;
163+ }
164+
165+ .baa-dock[data-visible="true"] {
166+ display: flex;
167+ }
168+
169+ .baa-badge,
170+ .baa-compact-action {
171 appearance: none;
172 border: 1px solid rgba(242, 203, 120, 0.35);
173 border-radius: 999px;
174@@ -222,11 +348,57 @@ function createPageControlOverlayRuntime() {
175 font-size: 11px;
176 line-height: 1;
177 cursor: pointer;
178- display: none;
179+ transition: border-color 120ms ease, color 120ms ease, background 120ms ease, opacity 120ms ease;
180+ }
181+
182+ .baa-badge {
183+ min-width: 48px;
184+ font-weight: 700;
185+ letter-spacing: 0.08em;
186+ }
187+
188+ .baa-compact-action {
189+ min-width: 52px;
190+ }
191+
192+ .baa-badge:hover,
193+ .baa-compact-action:hover:not(:disabled) {
194+ background: rgba(255, 255, 255, 0.12);
195+ }
196+
197+ .baa-compact-action:disabled {
198+ opacity: 0.58;
199+ cursor: default;
200 }
201
202- .baa-toggle[data-visible="true"] {
203- display: block;
204+ .baa-badge[data-state="running"],
205+ .baa-compact-action[data-state="running"] {
206+ color: #9df3bb;
207+ border-color: rgba(98, 214, 139, 0.45);
208+ }
209+
210+ .baa-badge[data-state="paused"],
211+ .baa-compact-action[data-state="paused"] {
212+ color: #f2cb78;
213+ border-color: rgba(242, 203, 120, 0.48);
214+ }
215+
216+ .baa-badge[data-state="draining"],
217+ .baa-compact-action[data-state="draining"] {
218+ color: #ffbf7d;
219+ border-color: rgba(255, 191, 125, 0.48);
220+ }
221+
222+ .baa-badge[data-state="pending"],
223+ .baa-compact-action[data-state="pending"] {
224+ color: #c7d2ff;
225+ border-color: rgba(199, 210, 255, 0.42);
226+ }
227+
228+ .baa-badge[data-state="disconnected"],
229+ .baa-compact-action[data-state="disconnected"] {
230+ color: #d0cec7;
231+ border-color: rgba(208, 206, 199, 0.36);
232 }
233 `;
234
235@@ -244,16 +416,30 @@ function createPageControlOverlayRuntime() {
236 }
237
238 const snapshot = controlSnapshot || {};
239+ const pageSnapshot = pageControlSnapshot || null;
240 const modeLabel = formatModeLabel(snapshot.mode);
241 const connectionLabel = formatConnectionLabel(snapshot.controlConnection);
242- const actionLabel = pendingAction ? `\n动作: ${pendingAction}` : "";
243+ const pageLabel = formatPageStatusLabel(pageSnapshot);
244+ const conversationLabel = pageSnapshot?.conversationId ? `\n对话: ${pageSnapshot.conversationId}` : "";
245+ const actionLabel = formatPendingActionLabel(pendingAction)
246+ ? `\n动作: ${formatPendingActionLabel(pendingAction)}`
247+ : "";
248
249- statusText.textContent = `Conductor: ${connectionLabel}\n模式: ${modeLabel}${actionLabel}`;
250+ statusText.textContent = `本页: ${pageLabel}${conversationLabel}\nConductor: ${connectionLabel}\n系统: ${modeLabel}${actionLabel}`;
251 }
252
253 function updateButtons() {
254 const activePending = trimToNull(pendingAction);
255 const normalizedMode = normalizeMode(controlSnapshot?.mode);
256+ const pagePaused = pageControlSnapshot?.paused === true;
257+
258+ if (pageActionButton) {
259+ pageActionButton.disabled = activePending != null || !pageControlSnapshot;
260+ pageActionButton.dataset.paused = pagePaused ? "true" : "false";
261+ pageActionButton.textContent = pageControlSnapshot
262+ ? (pagePaused ? "恢复本页" : "暂停本页")
263+ : "本页未识别";
264+ }
265
266 for (const [action, button] of Object.entries(buttonMap)) {
267 if (!button) {
268@@ -264,13 +450,33 @@ function createPageControlOverlayRuntime() {
269 }
270 }
271
272+ function updateCompactControls() {
273+ const visualState = resolveOverlayVisualState(controlSnapshot, pendingAction);
274+ const quickAction = resolveCompactControlAction(controlSnapshot, pendingAction);
275+ const connectionLabel = formatConnectionLabel(controlSnapshot?.controlConnection);
276+ const modeLabel = formatModeLabel(controlSnapshot?.mode);
277+
278+ if (badgeButton) {
279+ badgeButton.dataset.state = visualState;
280+ badgeButton.title = `打开 BAA 控制面板(${connectionLabel} / ${modeLabel})`;
281+ }
282+
283+ if (compactActionButton) {
284+ compactActionButton.dataset.state = visualState;
285+ compactActionButton.dataset.action = quickAction.action || "";
286+ compactActionButton.disabled = quickAction.action == null;
287+ compactActionButton.textContent = quickAction.label;
288+ compactActionButton.title = quickAction.title;
289+ }
290+ }
291+
292 function updateVisibility() {
293 if (panel) {
294 panel.hidden = hidden;
295 }
296
297- if (toggleButton) {
298- toggleButton.dataset.visible = hidden ? "true" : "false";
299+ if (dock) {
300+ dock.dataset.visible = hidden ? "true" : "false";
301 }
302
303 if (dismissButton) {
304@@ -282,13 +488,38 @@ function createPageControlOverlayRuntime() {
305 function render() {
306 updateStatus();
307 updateButtons();
308+ updateCompactControls();
309 updateVisibility();
310 }
311
312- async function readStoredControlState() {
313+ async function readStoredOverlayState() {
314 try {
315- const stored = await browser.storage.local.get(CONTROL_STATE_STORAGE_KEY);
316+ const stored = await browser.storage.local.get([
317+ CONTROL_STATE_STORAGE_KEY,
318+ PAGE_CONTROL_STATE_STORAGE_KEY
319+ ]);
320 controlSnapshot = stored?.[CONTROL_STATE_STORAGE_KEY] || null;
321+ pageControlSnapshot = selectPageControlSnapshot(stored?.[PAGE_CONTROL_STATE_STORAGE_KEY], pageControlKey);
322+ render();
323+ } catch (error) {
324+ setError(error instanceof Error ? error.message : String(error));
325+ }
326+ }
327+
328+ async function loadRuntimeOverlayState() {
329+ try {
330+ const result = await browser.runtime.sendMessage({
331+ type: "get_page_control_state",
332+ source: "page_overlay"
333+ });
334+
335+ if (!result?.ok) {
336+ throw new Error(trimToNull(result?.error) || "get_page_control_state failed");
337+ }
338+
339+ controlSnapshot = result.control || controlSnapshot;
340+ pageControlSnapshot = normalizePageControlSnapshot(result.page);
341+ pageControlKey = trimToNull(result?.page?.key) || pageControlKey;
342 render();
343 } catch (error) {
344 setError(error instanceof Error ? error.message : String(error));
345@@ -320,12 +551,49 @@ function createPageControlOverlayRuntime() {
346 }
347 }
348
349+ async function runPageControlAction(action) {
350+ pendingAction = action === "pause" ? "page_pause" : "page_resume";
351+ setError("");
352+ render();
353+
354+ try {
355+ const result = await browser.runtime.sendMessage({
356+ type: "page_control_command",
357+ action,
358+ source: "page_overlay"
359+ });
360+
361+ if (!result?.ok) {
362+ throw new Error(trimToNull(result?.error) || `${action} page control failed`);
363+ }
364+
365+ controlSnapshot = result.control || controlSnapshot;
366+ pageControlSnapshot = normalizePageControlSnapshot(result.page);
367+ pageControlKey = trimToNull(result?.page?.key) || pageControlKey;
368+ } catch (error) {
369+ setError(error instanceof Error ? error.message : String(error));
370+ } finally {
371+ pendingAction = null;
372+ render();
373+ }
374+ }
375+
376 function handleStorageChanged(changes, areaName) {
377- if (destroyed || areaName !== "local" || !changes?.[CONTROL_STATE_STORAGE_KEY]) {
378+ if (destroyed || areaName !== "local") {
379 return;
380 }
381
382- controlSnapshot = changes[CONTROL_STATE_STORAGE_KEY].newValue || null;
383+ if (changes?.[CONTROL_STATE_STORAGE_KEY]) {
384+ controlSnapshot = changes[CONTROL_STATE_STORAGE_KEY].newValue || null;
385+ }
386+
387+ if (changes?.[PAGE_CONTROL_STATE_STORAGE_KEY]) {
388+ pageControlSnapshot = selectPageControlSnapshot(
389+ changes[PAGE_CONTROL_STATE_STORAGE_KEY].newValue,
390+ pageControlKey
391+ );
392+ }
393+
394 render();
395 }
396
397@@ -334,6 +602,21 @@ function createPageControlOverlayRuntime() {
398 render();
399 }
400
401+ function handleOpenPanel() {
402+ hidden = false;
403+ render();
404+ }
405+
406+ function handleCompactAction() {
407+ const quickAction = resolveCompactControlAction(controlSnapshot, pendingAction);
408+
409+ if (!quickAction.action) {
410+ return;
411+ }
412+
413+ runControlAction(quickAction.action).catch(() => {});
414+ }
415+
416 function mount() {
417 if (destroyed || root?.isConnected) {
418 return;
419@@ -347,13 +630,24 @@ function createPageControlOverlayRuntime() {
420 style.textContent = overlayStyle;
421 shadow.appendChild(style);
422
423- toggleButton = document.createElement("button");
424- toggleButton.type = "button";
425- toggleButton.className = "baa-toggle";
426- toggleButton.dataset.visible = "false";
427- toggleButton.textContent = "BAA";
428- toggleButton.addEventListener("click", handleDismiss);
429- shadow.appendChild(toggleButton);
430+ dock = document.createElement("div");
431+ dock.className = "baa-dock";
432+ dock.dataset.visible = "false";
433+
434+ badgeButton = document.createElement("button");
435+ badgeButton.type = "button";
436+ badgeButton.className = "baa-badge";
437+ badgeButton.textContent = "BAA";
438+ badgeButton.addEventListener("click", handleOpenPanel);
439+ dock.appendChild(badgeButton);
440+
441+ compactActionButton = document.createElement("button");
442+ compactActionButton.type = "button";
443+ compactActionButton.className = "baa-compact-action";
444+ compactActionButton.addEventListener("click", handleCompactAction);
445+ dock.appendChild(compactActionButton);
446+
447+ shadow.appendChild(dock);
448
449 panel = document.createElement("div");
450 panel.className = "baa-overlay baa-panel";
451@@ -378,6 +672,21 @@ function createPageControlOverlayRuntime() {
452 statusText.className = "baa-status";
453 panel.appendChild(statusText);
454
455+ const pageActions = document.createElement("div");
456+ pageActions.className = "baa-page-actions";
457+
458+ pageActionButton = document.createElement("button");
459+ pageActionButton.type = "button";
460+ pageActionButton.className = "baa-btn";
461+ pageActionButton.dataset.role = "page";
462+ pageActionButton.dataset.paused = "false";
463+ pageActionButton.textContent = "暂停本页";
464+ pageActionButton.addEventListener("click", () => {
465+ runPageControlAction(pageControlSnapshot?.paused === true ? "resume" : "pause").catch(() => {});
466+ });
467+ pageActions.appendChild(pageActionButton);
468+ panel.appendChild(pageActions);
469+
470 errorText = document.createElement("div");
471 errorText.className = "baa-error";
472 panel.appendChild(errorText);
473@@ -415,7 +724,8 @@ function createPageControlOverlayRuntime() {
474 }
475
476 mount();
477- readStoredControlState().catch(() => {});
478+ loadRuntimeOverlayState().catch(() => {});
479+ readStoredOverlayState().catch(() => {});
480 browser.storage.onChanged.addListener(handleStorageChanged);
481
482 return {
483@@ -429,11 +739,14 @@ function createPageControlOverlayRuntime() {
484
485 root = null;
486 shadow = null;
487+ dock = null;
488 panel = null;
489 statusText = null;
490 errorText = null;
491 dismissButton = null;
492- toggleButton = null;
493+ badgeButton = null;
494+ compactActionButton = null;
495+ pageActionButton = null;
496 buttonMap = {};
497 }
498 };
+656,
-14
1@@ -10,6 +10,7 @@ const CONTROLLER_STORAGE_KEYS = {
2 wsUrl: "baaFirefox.wsUrl",
3 controlBaseUrl: "baaFirefox.controlBaseUrl",
4 controlState: "baaFirefox.controlState",
5+ pageControls: "baaFirefox.pageControls",
6 statusSchemaVersion: "baaFirefox.statusSchemaVersion",
7 trackedTabs: "baaFirefox.trackedTabs",
8 desiredTabs: "baaFirefox.desiredTabs",
9@@ -182,6 +183,7 @@ const state = {
10 wsUrl: DEFAULT_WS_URL,
11 controlBaseUrl: DEFAULT_CONTROL_BASE_URL,
12 controlState: null,
13+ pageControls: {},
14 wsState: null,
15 ws: null,
16 wsConnected: false,
17@@ -463,6 +465,294 @@ function cloneControllerRuntimeState(value) {
18 });
19 }
20
21+function createDefaultPageControlState(overrides = {}) {
22+ return {
23+ key: null,
24+ platform: null,
25+ tabId: null,
26+ conversationId: null,
27+ pageUrl: null,
28+ pageTitle: null,
29+ shellPage: false,
30+ paused: false,
31+ pauseSource: null,
32+ pauseReason: null,
33+ updatedAt: 0,
34+ ...overrides
35+ };
36+}
37+
38+function clonePageControlState(value) {
39+ if (!isRecord(value)) {
40+ return createDefaultPageControlState();
41+ }
42+
43+ const platform = trimToNull(value.platform);
44+ const normalizedPlatform = platform && PLATFORMS[platform] ? platform : null;
45+
46+ return createDefaultPageControlState({
47+ key: trimToNull(value.key),
48+ platform: normalizedPlatform,
49+ tabId: Number.isInteger(value.tabId) ? value.tabId : null,
50+ conversationId: trimToNull(value.conversationId),
51+ pageUrl: trimToNull(value.pageUrl),
52+ pageTitle: trimToNull(value.pageTitle),
53+ shellPage: value.shellPage === true,
54+ paused: value.paused === true,
55+ pauseSource: trimToNull(value.pauseSource),
56+ pauseReason: trimToNull(value.pauseReason),
57+ updatedAt: Number(value.updatedAt) || 0
58+ });
59+}
60+
61+function getPageControlKey(platform, tabId) {
62+ if (!platform || !PLATFORMS[platform] || !Number.isInteger(tabId) || tabId < 0) {
63+ return null;
64+ }
65+
66+ return `${platform}:${tabId}`;
67+}
68+
69+function loadPageControls(raw) {
70+ const next = {};
71+
72+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
73+ return next;
74+ }
75+
76+ for (const value of Object.values(raw)) {
77+ const entry = clonePageControlState(value);
78+ const key = getPageControlKey(entry.platform, entry.tabId);
79+
80+ if (!key) {
81+ continue;
82+ }
83+
84+ next[key] = createDefaultPageControlState({
85+ ...entry,
86+ key
87+ });
88+ }
89+
90+ return next;
91+}
92+
93+function listPageControlStates(options = {}) {
94+ const platform = trimToNull(options.platform);
95+ return Object.values(state.pageControls || {})
96+ .map((entry) => clonePageControlState(entry))
97+ .filter((entry) => entry.platform && Number.isInteger(entry.tabId))
98+ .filter((entry) => !platform || entry.platform === platform)
99+ .sort((left, right) => {
100+ const leftKey = `${left.platform}:${String(left.tabId).padStart(12, "0")}`;
101+ const rightKey = `${right.platform}:${String(right.tabId).padStart(12, "0")}`;
102+ return leftKey.localeCompare(rightKey);
103+ });
104+}
105+
106+function getPageControlState(platform, tabId) {
107+ const key = getPageControlKey(platform, tabId);
108+ return key ? clonePageControlState(state.pageControls[key]) : createDefaultPageControlState();
109+}
110+
111+function summarizePageControls(platform) {
112+ const entries = listPageControlStates({ platform });
113+
114+ if (entries.length === 0) {
115+ return "无";
116+ }
117+
118+ const pausedEntries = entries.filter((entry) => entry.paused);
119+
120+ if (pausedEntries.length === 0) {
121+ return `${entries.length} 页观察中`;
122+ }
123+
124+ const labels = pausedEntries.map((entry) => {
125+ const conversation = entry.conversationId ? ` conv=${entry.conversationId.slice(0, 12)}` : "";
126+ return `#${entry.tabId}${conversation}`;
127+ });
128+
129+ return `paused(${pausedEntries.length}/${entries.length}): ${labels.join(", ")}`;
130+}
131+
132+function serializePageControlState(entry) {
133+ const normalized = clonePageControlState(entry);
134+
135+ if (!normalized.platform || !Number.isInteger(normalized.tabId)) {
136+ return null;
137+ }
138+
139+ return {
140+ key: getPageControlKey(normalized.platform, normalized.tabId),
141+ platform: normalized.platform,
142+ tabId: normalized.tabId,
143+ conversationId: normalized.conversationId,
144+ pageUrl: normalized.pageUrl,
145+ pageTitle: normalized.pageTitle,
146+ shellPage: normalized.shellPage,
147+ paused: normalized.paused,
148+ pauseSource: normalized.pauseSource,
149+ pauseReason: normalized.pauseReason,
150+ status: normalized.paused ? "paused" : "running",
151+ updatedAt: normalized.updatedAt || 0
152+ };
153+}
154+
155+function updatePageControlState(input = {}, options = {}) {
156+ const platform = trimToNull(input.platform);
157+ const tabId = Number.isInteger(input.tabId) ? input.tabId : null;
158+ const key = getPageControlKey(platform, tabId);
159+
160+ if (!key) {
161+ return null;
162+ }
163+
164+ const previous = clonePageControlState(state.pageControls[key]);
165+ const next = createDefaultPageControlState({
166+ ...previous,
167+ key,
168+ platform,
169+ tabId
170+ });
171+ let changed = !state.pageControls[key];
172+
173+ if (Object.prototype.hasOwnProperty.call(input, "conversationId")) {
174+ const conversationId = trimToNull(input.conversationId);
175+ if (next.conversationId !== conversationId) {
176+ next.conversationId = conversationId;
177+ changed = true;
178+ }
179+ }
180+
181+ if (Object.prototype.hasOwnProperty.call(input, "pageUrl")) {
182+ const pageUrl = trimToNull(input.pageUrl);
183+ if (next.pageUrl !== pageUrl) {
184+ next.pageUrl = pageUrl;
185+ changed = true;
186+ }
187+ }
188+
189+ if (Object.prototype.hasOwnProperty.call(input, "pageTitle")) {
190+ const pageTitle = trimToNull(input.pageTitle);
191+ if (next.pageTitle !== pageTitle) {
192+ next.pageTitle = pageTitle;
193+ changed = true;
194+ }
195+ }
196+
197+ if (Object.prototype.hasOwnProperty.call(input, "shellPage")) {
198+ const shellPage = input.shellPage === true;
199+ if (next.shellPage !== shellPage) {
200+ next.shellPage = shellPage;
201+ changed = true;
202+ }
203+ }
204+
205+ if (Object.prototype.hasOwnProperty.call(input, "paused")) {
206+ const paused = input.paused === true;
207+ const pauseSource = paused
208+ ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
209+ : (trimToNull(input.pauseSource) || trimToNull(options.source) || null);
210+ const pauseReason = paused
211+ ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "page_paused")
212+ : (trimToNull(input.pauseReason) || trimToNull(options.reason) || null);
213+
214+ if (next.paused !== paused) {
215+ next.paused = paused;
216+ changed = true;
217+ }
218+
219+ if (next.pauseSource !== pauseSource) {
220+ next.pauseSource = pauseSource;
221+ changed = true;
222+ }
223+
224+ if (next.pauseReason !== pauseReason) {
225+ next.pauseReason = pauseReason;
226+ changed = true;
227+ }
228+ }
229+
230+ if (!changed) {
231+ return serializePageControlState(previous);
232+ }
233+
234+ next.updatedAt = Number(input.updatedAt) || Date.now();
235+ state.pageControls[key] = next;
236+
237+ if (options.persist !== false) {
238+ persistState().catch(() => {});
239+ }
240+
241+ if (options.render !== false) {
242+ render();
243+ }
244+
245+ return serializePageControlState(next);
246+}
247+
248+function removePageControlState(platform, tabId, options = {}) {
249+ const key = getPageControlKey(platform, tabId);
250+
251+ if (!key || !state.pageControls[key]) {
252+ return false;
253+ }
254+
255+ delete state.pageControls[key];
256+
257+ if (options.persist !== false) {
258+ persistState().catch(() => {});
259+ }
260+
261+ if (options.render !== false) {
262+ render();
263+ }
264+
265+ return true;
266+}
267+
268+function removePageControlStatesByTabId(tabId, options = {}) {
269+ if (!Number.isInteger(tabId)) {
270+ return 0;
271+ }
272+
273+ const removedKeys = Object.keys(state.pageControls || {}).filter((key) => {
274+ const entry = state.pageControls[key];
275+ return Number.isInteger(entry?.tabId) && entry.tabId === tabId;
276+ });
277+
278+ if (removedKeys.length === 0) {
279+ return 0;
280+ }
281+
282+ for (const key of removedKeys) {
283+ delete state.pageControls[key];
284+ }
285+
286+ if (options.persist !== false) {
287+ persistState().catch(() => {});
288+ }
289+
290+ if (options.render !== false) {
291+ render();
292+ }
293+
294+ return removedKeys.length;
295+}
296+
297+function findPausedPageControlByConversation(platform, conversationId) {
298+ const normalizedConversationId = trimToNull(conversationId);
299+
300+ if (!platform || !normalizedConversationId) {
301+ return null;
302+ }
303+
304+ return listPageControlStates({ platform }).find((entry) =>
305+ entry.paused && entry.conversationId === normalizedConversationId
306+ ) || null;
307+}
308+
309 function normalizeClaudeMessageRole(value) {
310 if (value === "human" || value === "user") return "user";
311 if (value === "assistant") return "assistant";
312@@ -649,6 +939,38 @@ function parseClaudeApiContext(url) {
313 }
314 }
315
316+function extractChatgptConversationIdFromPageUrl(url) {
317+ try {
318+ const parsed = new URL(url, PLATFORMS.chatgpt.rootUrl);
319+ const pathname = parsed.pathname || "/";
320+ const match = pathname.match(/\/c\/([^/?#]+)/u);
321+
322+ if (match?.[1]) {
323+ return match[1];
324+ }
325+
326+ return trimToNull(parsed.searchParams.get("conversation_id"));
327+ } catch (_) {
328+ return null;
329+ }
330+}
331+
332+function extractGeminiConversationIdFromPageUrl(url) {
333+ try {
334+ const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
335+ const pathname = parsed.pathname || "/";
336+ const match = pathname.match(/\/app\/([^/?#]+)/u);
337+
338+ if (match?.[1]) {
339+ return match[1];
340+ }
341+
342+ return trimToNull(parsed.searchParams.get("conversation_id"));
343+ } catch (_) {
344+ return null;
345+ }
346+}
347+
348 function extractClaudeConversationIdFromPageUrl(url) {
349 try {
350 const parsed = new URL(url, PLATFORMS.claude.rootUrl);
351@@ -659,6 +981,59 @@ function extractClaudeConversationIdFromPageUrl(url) {
352 }
353 }
354
355+function extractConversationIdFromPageUrl(platform, url) {
356+ switch (platform) {
357+ case "claude":
358+ return extractClaudeConversationIdFromPageUrl(url);
359+ case "chatgpt":
360+ return extractChatgptConversationIdFromPageUrl(url);
361+ case "gemini":
362+ return extractGeminiConversationIdFromPageUrl(url);
363+ default:
364+ return null;
365+ }
366+}
367+
368+function extractChatgptConversationIdFromRequestBody(reqBody) {
369+ if (typeof reqBody !== "string" || !reqBody.trim()) {
370+ return null;
371+ }
372+
373+ try {
374+ const parsed = JSON.parse(reqBody);
375+
376+ if (!isRecord(parsed)) {
377+ return null;
378+ }
379+
380+ return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
381+ } catch (_) {
382+ return null;
383+ }
384+}
385+
386+function extractObservedConversationId(platform, data, context = null) {
387+ if (context?.conversationId) {
388+ return context.conversationId;
389+ }
390+
391+ switch (platform) {
392+ case "claude":
393+ return parseClaudeApiContext(data?.url || "").conversationId || null;
394+ case "chatgpt":
395+ return extractChatgptConversationIdFromRequestBody(data?.reqBody)
396+ || trimToNull(data?.conversation_id)
397+ || trimToNull(data?.conversationId)
398+ || null;
399+ case "gemini":
400+ return trimToNull(data?.conversation_id)
401+ || trimToNull(data?.conversationId)
402+ || null;
403+ default:
404+ return null;
405+ }
406+}
407+
408 function extractClaudeOrgId(headers = {}, requestUrl = "") {
409 const headerOrgId = trimToNull(headers["x-org-id"]);
410 if (headerOrgId) return headerOrgId;
411@@ -1694,6 +2069,9 @@ function getPlatformsNeedingShellRestore() {
412 function buildPluginStatusPayload(options = {}) {
413 const includeVolatile = options.includeVolatile !== false;
414 const platforms = {};
415+ const pageControls = listPageControlStates()
416+ .map(serializePageControlState)
417+ .filter(Boolean);
418
419 for (const platform of PLATFORM_ORDER) {
420 platforms[platform] = buildPlatformRuntimeSnapshot(platform);
421@@ -1707,7 +2085,8 @@ function buildPluginStatusPayload(options = {}) {
422 summary: {
423 desired_count: getDesiredCount(),
424 actual_count: getTrackedCount(),
425- drift_count: getRuntimeDriftCount()
426+ drift_count: getRuntimeDriftCount(),
427+ paused_page_count: pageControls.filter((entry) => entry.paused).length
428 },
429 controller: {
430 tab_id: controller.tabId,
431@@ -1729,6 +2108,19 @@ function buildPluginStatusPayload(options = {}) {
432 last_message_at: ws.lastMessageAt || null,
433 last_error: ws.lastError
434 },
435+ page_controls: pageControls.map((entry) => ({
436+ key: entry.key,
437+ platform: entry.platform,
438+ tab_id: entry.tabId,
439+ conversation_id: entry.conversationId,
440+ page_url: entry.pageUrl,
441+ page_title: entry.pageTitle,
442+ shell_page: entry.shellPage,
443+ paused: entry.paused,
444+ pause_source: entry.pauseSource,
445+ pause_reason: entry.pauseReason,
446+ updated_at: entry.updatedAt
447+ })),
448 platforms
449 };
450
451@@ -2712,6 +3104,7 @@ async function persistState() {
452 [CONTROLLER_STORAGE_KEYS.wsUrl]: state.wsUrl,
453 [CONTROLLER_STORAGE_KEYS.controlBaseUrl]: state.controlBaseUrl,
454 [CONTROLLER_STORAGE_KEYS.controlState]: state.controlState,
455+ [CONTROLLER_STORAGE_KEYS.pageControls]: state.pageControls,
456 [CONTROLLER_STORAGE_KEYS.statusSchemaVersion]: STATUS_SCHEMA_VERSION,
457 [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
458 [CONTROLLER_STORAGE_KEYS.desiredTabs]: state.desiredTabs,
459@@ -2822,12 +3215,13 @@ function renderPlatformStatus() {
460 const fingerprint = trimToNull(state.credentialFingerprint[platform]);
461 const credentialLabel = `${getCredentialFreshness(credential.reason)} / ${describeCredentialReason(credential.reason)}`;
462 const endpointCount = getEndpointCount(platform);
463+ const pageControlLabel = summarizePageControls(platform);
464 const actualLabel = runtime.actual.exists
465 ? `#${runtime.actual.tab_id}${runtime.actual.issue === "loading" ? "/loading" : ""}`
466 : (runtime.actual.candidate_tab_id ? `missing(candidate#${runtime.actual.candidate_tab_id})` : runtime.actual.issue);
467 const driftLabel = runtime.drift.reason;
468 lines.push(
469- `${platformLabel(platform).padEnd(8)} desired=${String(runtime.desired.exists).padEnd(5)} actual=${actualLabel.padEnd(24)} drift=${driftLabel.padEnd(16)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount}`
470+ `${platformLabel(platform).padEnd(8)} desired=${String(runtime.desired.exists).padEnd(5)} actual=${actualLabel.padEnd(24)} drift=${driftLabel.padEnd(16)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount} 页面=${pageControlLabel}`
471 );
472 }
473 return lines.join("\n");
474@@ -2909,7 +3303,8 @@ function renderControlSnapshot() {
475 source: snapshot.source,
476 error: snapshot.error,
477 message: snapshot.message,
478- raw: snapshot.raw
479+ raw: snapshot.raw,
480+ pageControls: listPageControlStates().map(serializePageControlState).filter(Boolean)
481 }, null, 2);
482 }
483
484@@ -3180,6 +3575,49 @@ async function refreshControlPlaneState(options = {}) {
485 }
486 }
487
488+function normalizePageControlAction(action) {
489+ const methodName = String(action || "").trim().toLowerCase();
490+ return methodName === "pause" || methodName === "resume" ? methodName : null;
491+}
492+
493+async function runPageControlAction(action, sender, options = {}) {
494+ const methodName = normalizePageControlAction(action);
495+
496+ if (!methodName) {
497+ throw new Error(`未知页面控制动作:${action || "-"}`);
498+ }
499+
500+ const context = getSenderContext(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
501+
502+ if (!context) {
503+ throw new Error("当前页面不是受支持的 AI 页面");
504+ }
505+
506+ const page = syncPageControlFromContext(context, {
507+ paused: methodName === "pause",
508+ pauseSource: options.source || "runtime",
509+ pauseReason: methodName === "pause"
510+ ? (options.reason || "page_paused_by_user")
511+ : (options.reason || "page_resumed_by_user")
512+ }, {
513+ persist: true,
514+ render: true,
515+ reason: options.reason,
516+ source: options.source
517+ });
518+ addLog(
519+ "info",
520+ `${platformLabel(context.platform)} 页面 ${page?.paused ? "已暂停" : "已恢复"},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
521+ false
522+ );
523+
524+ return {
525+ action: methodName,
526+ control: cloneControlState(state.controlState),
527+ page
528+ };
529+}
530+
531 async function runControlPlaneAction(action, options = {}) {
532 const methodName = String(action || "").trim().toLowerCase();
533
534@@ -4834,8 +5272,64 @@ function getSenderContext(sender, fallbackPlatform = null) {
535 platform,
536 tabId,
537 senderUrl,
538- isShellPage
539+ isShellPage,
540+ conversationId: isShellPage ? null : extractConversationIdFromPageUrl(platform, senderUrl),
541+ pageTitle: trimToNull(sender?.tab?.title)
542+ };
543+}
544+
545+function syncPageControlFromContext(context, overrides = {}, options = {}) {
546+ if (!context?.platform || !Number.isInteger(context.tabId)) {
547+ return null;
548+ }
549+
550+ const input = {
551+ platform: context.platform,
552+ tabId: context.tabId,
553+ conversationId: Object.prototype.hasOwnProperty.call(overrides, "conversationId")
554+ ? overrides.conversationId
555+ : context.conversationId,
556+ pageUrl: Object.prototype.hasOwnProperty.call(overrides, "pageUrl")
557+ ? overrides.pageUrl
558+ : context.senderUrl,
559+ pageTitle: Object.prototype.hasOwnProperty.call(overrides, "pageTitle")
560+ ? overrides.pageTitle
561+ : context.pageTitle,
562+ shellPage: Object.prototype.hasOwnProperty.call(overrides, "shellPage")
563+ ? overrides.shellPage
564+ : context.isShellPage
565 };
566+
567+ if (Object.prototype.hasOwnProperty.call(overrides, "paused")) {
568+ input.paused = overrides.paused;
569+ }
570+
571+ if (Object.prototype.hasOwnProperty.call(overrides, "pauseSource")) {
572+ input.pauseSource = overrides.pauseSource;
573+ }
574+
575+ if (Object.prototype.hasOwnProperty.call(overrides, "pauseReason")) {
576+ input.pauseReason = overrides.pauseReason;
577+ }
578+
579+ if (Object.prototype.hasOwnProperty.call(overrides, "updatedAt")) {
580+ input.updatedAt = overrides.updatedAt;
581+ }
582+
583+ return updatePageControlState(input, options);
584+}
585+
586+function buildPageControlSnapshotForSender(sender, fallbackPlatform = null) {
587+ const context = getSenderContext(sender, fallbackPlatform);
588+
589+ if (!context) {
590+ return null;
591+ }
592+
593+ return syncPageControlFromContext(context, {}, {
594+ persist: false,
595+ render: false
596+ });
597 }
598
599 function resolvePlatformFromRequest(details) {
600@@ -4847,10 +5341,22 @@ function getObservedPagePlatform(sender, fallbackPlatform = null) {
601 return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
602 }
603
604-function relayObservedFinalMessage(platform, relay, source = "page_observed") {
605+function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
606 const observer = state.finalMessageRelayObservers[platform];
607 if (!observer || !relay?.payload) return false;
608
609+ const pageControl = context ? getPageControlState(context.platform, context.tabId) : createDefaultPageControlState();
610+
611+ if (pageControl.paused) {
612+ FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
613+ addLog(
614+ "info",
615+ `${platformLabel(platform)} 最终消息已抑制:页面 #${context.tabId}${pageControl.conversationId ? ` conversation=${pageControl.conversationId}` : ""} 处于暂停状态`,
616+ false
617+ );
618+ return false;
619+ }
620+
621 if (!wsSend(relay.payload)) {
622 addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
623 return false;
624@@ -4865,12 +5371,12 @@ function relayObservedFinalMessage(platform, relay, source = "page_observed") {
625 return true;
626 }
627
628-function observeFinalMessageFromPageNetwork(data, sender) {
629+function observeFinalMessageFromPageNetwork(data, sender, context = null) {
630 if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
631 return;
632 }
633
634- const platform = getObservedPagePlatform(sender, data.platform || null);
635+ const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
636 const observer = platform ? state.finalMessageRelayObservers[platform] : null;
637 if (!observer) return;
638
639@@ -4880,16 +5386,16 @@ function observeFinalMessageFromPageNetwork(data, sender) {
640 });
641
642 if (relay) {
643- relayObservedFinalMessage(platform, relay, "page_network");
644+ relayObservedFinalMessage(platform, relay, "page_network", context);
645 }
646 }
647
648-function observeFinalMessageFromPageSse(data, sender) {
649+function observeFinalMessageFromPageSse(data, sender, context = null) {
650 if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
651 return;
652 }
653
654- const platform = getObservedPagePlatform(sender, data.platform || null);
655+ const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
656 const observer = platform ? state.finalMessageRelayObservers[platform] : null;
657 if (!observer) return;
658
659@@ -4899,7 +5405,7 @@ function observeFinalMessageFromPageSse(data, sender) {
660 });
661
662 if (relay) {
663- relayObservedFinalMessage(platform, relay, "page_sse");
664+ relayObservedFinalMessage(platform, relay, "page_sse", context);
665 }
666 }
667
668@@ -4976,8 +5482,13 @@ function applyObservedClaudeSse(data, tabId) {
669 }
670
671 function handlePageNetwork(data, sender) {
672- observeFinalMessageFromPageNetwork(data, sender);
673 const context = getSenderContext(sender, data?.platform || null);
674+ if (context) {
675+ syncPageControlFromContext(context, {
676+ conversationId: extractObservedConversationId(context.platform, data, context)
677+ });
678+ }
679+ observeFinalMessageFromPageNetwork(data, sender, context);
680 if (!context || !data || !data.url || !data.method) return;
681 const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
682 ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
683@@ -5000,8 +5511,13 @@ function handlePageNetwork(data, sender) {
684 }
685
686 function handlePageSse(data, sender) {
687- observeFinalMessageFromPageSse(data, sender);
688 const context = getSenderContext(sender, data?.platform || null);
689+ if (context) {
690+ syncPageControlFromContext(context, {
691+ conversationId: extractObservedConversationId(context.platform, data, context)
692+ });
693+ }
694+ observeFinalMessageFromPageSse(data, sender, context);
695 if (!context || !data || !data.url) return;
696
697 if (context.platform === "claude") {
698@@ -5085,6 +5601,11 @@ function handlePageSse(data, sender) {
699
700 function handlePageProxyResponse(data, sender) {
701 const context = getSenderContext(sender, data?.platform || null);
702+ if (context) {
703+ syncPageControlFromContext(context, {
704+ conversationId: extractObservedConversationId(context.platform, data, context)
705+ });
706+ }
707 if (!context || !data || !data.id) return;
708 const pending = pendingProxyRequests.get(data.id);
709 if (!pending) return;
710@@ -5181,6 +5702,10 @@ function handlePageBridgeReady(data, sender) {
711 const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || data?.platform || null);
712 if (!context) return;
713
714+ syncPageControlFromContext(context, {
715+ conversationId: context.conversationId
716+ });
717+
718 if (context.platform === "claude") {
719 updateClaudeState({
720 tabId: context.tabId,
721@@ -5348,9 +5873,40 @@ function buildDeliveryShellRuntime(platform) {
722 }
723 }
724
725+function resolvePausedPageControlForDelivery(platform, conversationId, tabId = null) {
726+ const pausedByConversation = findPausedPageControlByConversation(platform, conversationId);
727+
728+ if (pausedByConversation) {
729+ return pausedByConversation;
730+ }
731+
732+ if (!Number.isInteger(tabId)) {
733+ return null;
734+ }
735+
736+ const pausedByTab = getPageControlState(platform, tabId);
737+ return pausedByTab.paused ? pausedByTab : null;
738+}
739+
740+function createPausedPageDeliveryError(command, pageControl, conversationId = null) {
741+ const targetConversationId = pageControl?.conversationId || trimToNull(conversationId);
742+ const parts = [`${command} blocked: ${platformLabel(pageControl?.platform || "-")} 页面已暂停`];
743+
744+ if (Number.isInteger(pageControl?.tabId)) {
745+ parts.push(`tab=${pageControl.tabId}`);
746+ }
747+
748+ if (targetConversationId) {
749+ parts.push(`conversation=${targetConversationId}`);
750+ }
751+
752+ return new Error(parts.join(" "));
753+}
754+
755 async function runDeliveryAction(message, command) {
756 const platform = trimToNull(message?.platform);
757 const planId = trimToNull(message?.plan_id || message?.planId);
758+ const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
759
760 if (!platform) {
761 throw new Error(`${command} 缺少 platform`);
762@@ -5360,7 +5916,21 @@ async function runDeliveryAction(message, command) {
763 throw new Error(`${command} 缺少 plan_id`);
764 }
765
766+ const pausedByConversation = resolvePausedPageControlForDelivery(platform, conversationId);
767+
768+ if (pausedByConversation) {
769+ addLog("info", createPausedPageDeliveryError(command, pausedByConversation, conversationId).message, false);
770+ throw createPausedPageDeliveryError(command, pausedByConversation, conversationId);
771+ }
772+
773 const tab = await resolveDeliveryTab(platform);
774+ const pausedByTab = resolvePausedPageControlForDelivery(platform, conversationId, tab.id);
775+
776+ if (pausedByTab) {
777+ addLog("info", createPausedPageDeliveryError(command, pausedByTab, conversationId).message, false);
778+ throw createPausedPageDeliveryError(command, pausedByTab, conversationId);
779+ }
780+
781 const payload = {
782 command,
783 platform,
784@@ -5746,6 +6316,25 @@ function registerRuntimeListeners() {
785 error: error.message,
786 snapshot: cloneControlState(state.controlState)
787 }));
788+ case "page_control_command":
789+ return runPageControlAction(message.action, sender, {
790+ source: message.source || "runtime",
791+ reason: message.reason || "page_overlay"
792+ }).then((result) => ({
793+ ok: true,
794+ ...result
795+ })).catch((error) => ({
796+ ok: false,
797+ error: error.message,
798+ control: cloneControlState(state.controlState),
799+ page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
800+ }));
801+ case "get_page_control_state":
802+ return Promise.resolve({
803+ ok: true,
804+ control: cloneControlState(state.controlState),
805+ page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
806+ });
807 case "get_control_plane_state":
808 return Promise.resolve({
809 ok: true,
810@@ -5806,11 +6395,59 @@ function registerTabListeners() {
811 scheduleTrackedTabRefresh("create");
812 });
813
814- browser.tabs.onRemoved.addListener(() => {
815+ browser.tabs.onRemoved.addListener((tabId) => {
816+ removePageControlStatesByTabId(tabId, {
817+ persist: true,
818+ render: true
819+ });
820 scheduleTrackedTabRefresh("remove");
821 });
822
823 browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
824+ const nextUrl = changeInfo.url || tab?.url || "";
825+ const currentPageControlEntries = listPageControlStates().filter((entry) => entry.tabId === tabId);
826+ let pageControlChanged = false;
827+
828+ for (const entry of currentPageControlEntries) {
829+ if (!isPlatformUrl(entry.platform, nextUrl)) {
830+ pageControlChanged = removePageControlState(entry.platform, tabId, {
831+ persist: false,
832+ render: false
833+ }) || pageControlChanged;
834+ continue;
835+ }
836+
837+ const nextConversationId = extractConversationIdFromPageUrl(entry.platform, nextUrl);
838+ const nextShellPage = isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true });
839+ const nextTitle = trimToNull(tab?.title);
840+
841+ if (
842+ entry.pageUrl !== trimToNull(nextUrl)
843+ || entry.pageTitle !== nextTitle
844+ || entry.conversationId !== nextConversationId
845+ || entry.shellPage !== nextShellPage
846+ ) {
847+ pageControlChanged = true;
848+ }
849+
850+ syncPageControlFromContext({
851+ conversationId: extractConversationIdFromPageUrl(entry.platform, nextUrl),
852+ isShellPage: isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true }),
853+ pageTitle: trimToNull(tab?.title),
854+ platform: entry.platform,
855+ senderUrl: nextUrl,
856+ tabId
857+ }, {}, {
858+ persist: false,
859+ render: false
860+ });
861+ }
862+
863+ if (pageControlChanged) {
864+ persistState().catch(() => {});
865+ render();
866+ }
867+
868 const platform = findTrackedPlatformByTabId(tabId);
869 const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
870 if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
871@@ -5879,6 +6516,7 @@ async function init() {
872 state.wsUrl = normalizeSavedWsUrl(saved[CONTROLLER_STORAGE_KEYS.wsUrl]);
873 state.controlBaseUrl = normalizeSavedControlBaseUrl(saved[CONTROLLER_STORAGE_KEYS.controlBaseUrl]);
874 state.controlState = loadControlState(saved[CONTROLLER_STORAGE_KEYS.controlState]);
875+ state.pageControls = loadPageControls(saved[CONTROLLER_STORAGE_KEYS.pageControls]);
876
877 state.trackedTabs = loadTrackedTabs(
878 saved[CONTROLLER_STORAGE_KEYS.trackedTabs],
879@@ -6006,14 +6644,18 @@ function exposeControllerTestApi() {
880 }
881
882 Object.assign(target, {
883+ buildPageControlSnapshotForSender,
884 getSenderContext,
885 handlePageBridgeReady,
886 handlePageNetwork,
887 handlePageSse,
888 reinjectAllOpenPlatformTabs,
889 reinjectPlatformTabs,
890+ runDeliveryAction,
891+ runPageControlAction,
892 runPluginManagementAction,
893 setDesiredTabState,
894+ syncPageControlFromContext,
895 state
896 });
897 }
1@@ -13,12 +13,13 @@ const {
2 createDeliveryRuntime,
3 getPlatformAdapter,
4 } = require("../../plugins/baa-firefox/delivery-adapters.js");
5+const finalMessageHelpers = require("../../plugins/baa-firefox/final-message.js");
6 const {
7 createRelayState,
8 observeNetwork,
9 observeSse,
10 rememberRelay
11-} = require("../../plugins/baa-firefox/final-message.js");
12+} = finalMessageHelpers;
13 const controllerSource = readFileSync(
14 new URL("../../plugins/baa-firefox/controller.js", import.meta.url),
15 "utf8"
16@@ -1086,6 +1087,125 @@ test("controller tab_reload refreshes observer scripts on existing Claude chat t
17 );
18 });
19
20+test("controller page-level pause suppresses only the paused page relay and resume re-enables it", async () => {
21+ const harness = createControllerHarness({
22+ finalMessageHelpers
23+ });
24+ const pausedSender = {
25+ tab: {
26+ id: 41,
27+ title: "Paused ChatGPT Page",
28+ url: "https://chatgpt.com/c/conv-page-paused"
29+ }
30+ };
31+ const otherSender = {
32+ tab: {
33+ id: 42,
34+ title: "Other ChatGPT Page",
35+ url: "https://chatgpt.com/c/conv-page-other"
36+ }
37+ };
38+
39+ const pauseResult = await harness.hooks.runPageControlAction("pause", pausedSender, {
40+ source: "smoke_test",
41+ reason: "pause_page_for_smoke"
42+ });
43+ assert.equal(pauseResult.page.platform, "chatgpt");
44+ assert.equal(pauseResult.page.tabId, 41);
45+ assert.equal(pauseResult.page.paused, true);
46+ assert.equal(pauseResult.page.conversationId, "conv-page-paused");
47+
48+ harness.hooks.handlePageSse(
49+ {
50+ chunk: 'data: {"conversation_id":"conv-page-paused","message":{"id":"msg-page-paused","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["paused page answer"]}}}',
51+ done: true,
52+ platform: "chatgpt",
53+ reqBody: JSON.stringify({
54+ conversation_id: "conv-page-paused"
55+ }),
56+ url: "https://chatgpt.com/backend-api/conversation"
57+ },
58+ pausedSender
59+ );
60+
61+ assert.equal(
62+ harness.sentMessages.filter((message) => message.type === "browser.final_message").length,
63+ 0
64+ );
65+
66+ harness.hooks.handlePageSse(
67+ {
68+ chunk: 'data: {"conversation_id":"conv-page-other","message":{"id":"msg-page-other","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["other page answer"]}}}',
69+ done: true,
70+ platform: "chatgpt",
71+ reqBody: JSON.stringify({
72+ conversation_id: "conv-page-other"
73+ }),
74+ url: "https://chatgpt.com/backend-api/conversation"
75+ },
76+ otherSender
77+ );
78+
79+ const unpausedRelay = harness.sentMessages.find((message) =>
80+ message.type === "browser.final_message" && message.assistant_message_id === "msg-page-other"
81+ );
82+ assert.ok(unpausedRelay);
83+ assert.equal(unpausedRelay.conversation_id, "conv-page-other");
84+
85+ const resumeResult = await harness.hooks.runPageControlAction("resume", pausedSender, {
86+ source: "smoke_test",
87+ reason: "resume_page_for_smoke"
88+ });
89+ assert.equal(resumeResult.page.paused, false);
90+
91+ harness.hooks.handlePageSse(
92+ {
93+ chunk: 'data: {"conversation_id":"conv-page-paused","message":{"id":"msg-page-resumed","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["resumed page answer"]}}}',
94+ done: true,
95+ platform: "chatgpt",
96+ reqBody: JSON.stringify({
97+ conversation_id: "conv-page-paused"
98+ }),
99+ url: "https://chatgpt.com/backend-api/conversation"
100+ },
101+ pausedSender
102+ );
103+
104+ const resumedRelay = harness.sentMessages.find((message) =>
105+ message.type === "browser.final_message" && message.assistant_message_id === "msg-page-resumed"
106+ );
107+ assert.ok(resumedRelay);
108+ assert.equal(resumedRelay.conversation_id, "conv-page-paused");
109+});
110+
111+test("controller blocks delivery bridge when the target page conversation is paused", async () => {
112+ const harness = createControllerHarness();
113+ const sender = {
114+ tab: {
115+ id: 51,
116+ title: "Paused Delivery Page",
117+ url: "https://chatgpt.com/c/conv-delivery-paused"
118+ }
119+ };
120+
121+ await harness.hooks.runPageControlAction("pause", sender, {
122+ source: "smoke_test",
123+ reason: "pause_delivery_target"
124+ });
125+
126+ await assert.rejects(
127+ () => harness.hooks.runDeliveryAction(
128+ {
129+ conversation_id: "conv-delivery-paused",
130+ plan_id: "plan-delivery-paused",
131+ platform: "chatgpt"
132+ },
133+ "inject_message"
134+ ),
135+ /页面已暂停/u
136+ );
137+});
138+
139 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
140 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
141 const runtime = new ConductorRuntime(