baa-conductor

git clone 

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
M plugins/baa-firefox/content-script.js
+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   };
M plugins/baa-firefox/controller.js
+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 }
M tests/browser/browser-control-e2e-smoke.test.mjs
+121, -1
  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(