Skip to content

feat(chrome-extension): open the recorder in Chrome's side panel on non-injectable pages#2027

Open
ManthanNimodiya wants to merge 7 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/extension-side-panel-fallback
Open

feat(chrome-extension): open the recorder in Chrome's side panel on non-injectable pages#2027
ManthanNimodiya wants to merge 7 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/extension-side-panel-fallback

Conversation

@ManthanNimodiya

@ManthanNimodiya ManthanNimodiya commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the "recorder opens in a separate window" confusion from the support thread.

On pages the extension can't inject into (new tab, chrome://, Web Store), the Cap icon now docks the recorder in Chrome's side panel instead of a floating popup window. It survives tab switches, the icon toggles it, and normal websites keep the in-page panel exactly as shipped. The popup window stays as a logged last resort.

Implementation notes: sidePanel.open() must ride the click gesture (any timer voids it), so the decision happens synchronously in the action handler and the fallback delivery path avoids timed retries. The standalone panel reports open/closed to the service worker so teardown reaches it and only one recorder shows at a time.

Also includes two bug fixes found while testing, both reproducible on main (can split into a separate PR):

  • After every extension update/reload, orphaned content scripts blocked re-injection via a stale global flag, leaving open tabs unable to show Cap UI until refreshed. Injection now takes over the tab, mounts a fresh overlay module, and sweeps stale UI.
  • A recording that dies without a terminal status left the offscreen doc pinned in a phantom "recording" phase: stop was a no-op and every tab's bar froze until browser restart. Stop now clears zombie phases and the idle-close interval doubles as a watchdog.

Tested manually on Chrome/macOS (side panel open/toggle/persistence, normal sites unchanged incl. the cap.so dashboard flow, recording from the side panel, orphan recovery after reload). Typecheck, unit tests, and builds pass. sidePanel needs Chrome 116 = existing minimum_chrome_version. Firefox has no sidePanel API; the Firefox port handles this separately.

Greptile Summary

This PR moves the standalone Chrome-extension recorder into the side panel and adds recovery for stale content-script and offscreen recording state.

  • Adds the side-panel permission and standalone panel lifecycle messaging.
  • Preserves the in-page recorder on injectable tabs and retains the popup-window fallback.
  • Replaces the stale bootstrap guard with generation-based takeover and overlay cleanup.
  • Clears zombie capture phases during stop handling and idle watchdog checks.

Confidence Score: 4/5

This PR should not merge until side-panel toggling remains correct across routine MV3 service-worker suspension.

The visible side panel can outlive the service worker that owns its unpersisted open flag, causing the next icon click to reopen rather than close the recorder.

apps/chrome-extension/src/background/service-worker.ts and apps/chrome-extension/src/popup/main.tsx

Important Files Changed

Filename Overview
apps/chrome-extension/public/manifest.json Adds the sidePanel permission and maps the standalone recorder page as the default side-panel document.
apps/chrome-extension/src/background/service-worker.ts Adds side-panel routing and lifecycle tracking, but the in-memory open flag breaks action-button toggling after service-worker restart.
apps/chrome-extension/src/content/bootstrap.ts Replaces the stale global boolean with generation ownership, fresh module imports, listener detachment, and stale overlay cleanup.
apps/chrome-extension/src/offscreen/recorder.ts Detects capture phases with no live owner and resets them during stop handling and periodic idle checks.
apps/chrome-extension/src/popup/main.tsx Reports standalone lifecycle events and handles service-worker close requests, but opened state is reported only when the document loads.
apps/chrome-extension/src/shared/types.ts Extends service-worker request types with standalone panel opened and closed lifecycle messages.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/chrome-extension/src/background/service-worker.ts:1903-1906
**Side-panel state is lost**

When Chrome restarts the MV3 service worker while the side panel remains open, `standalonePanelOpen` resets to `false` and the surviving panel does not report itself again, so the next icon click calls `sidePanel.open()` instead of closing the visible recorder.

Reviews (1): Last reviewed commit: "fix(chrome-extension): sweep stale overl..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Comment on lines +1903 to +1906
if (standalonePanelOpen) {
closeStandalonePanel();
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Side-panel state is lost

When Chrome restarts the MV3 service worker while the side panel remains open, standalonePanelOpen resets to false and the surviving panel does not report itself again, so the next icon click calls sidePanel.open() instead of closing the visible recorder.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/chrome-extension/src/background/service-worker.ts
Line: 1903-1906

Comment:
**Side-panel state is lost**

When Chrome restarts the MV3 service worker while the side panel remains open, `standalonePanelOpen` resets to `false` and the surviving panel does not report itself again, so the next icon click calls `sidePanel.open()` instead of closing the visible recorder.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +93 to +101
const closeStandalonePanel = () => {
if (!standalonePanelOpen) return;
standalonePanelOpen = false;
chrome.runtime.sendMessage(
{ target: "standalone-panel", type: "close" },
() => {
void chrome.runtime.lastError;
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the service worker gets restarted while the side panel is open, standalonePanelOpen can desync and this early return prevents teardown/toggle from reaching the panel. Might be safer to always send the close message and just keep the flag best-effort.

Suggested change
const closeStandalonePanel = () => {
if (!standalonePanelOpen) return;
standalonePanelOpen = false;
chrome.runtime.sendMessage(
{ target: "standalone-panel", type: "close" },
() => {
void chrome.runtime.lastError;
},
);
const closeStandalonePanel = () => {
standalonePanelOpen = false;
chrome.runtime.sendMessage(
{ target: "standalone-panel", type: "close" },
() => {
void chrome.runtime.lastError;
},
);
};

Comment on lines +812 to +815
if (windowId !== undefined && chrome.sidePanel) {
await chrome.sidePanel.open({ windowId });
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness: if the side panel opens successfully but the standalone page never manages to ping standalone-panel-opened (race/suspension), standalonePanelOpen stays false and toggle/teardown can miss it. Consider setting the flag on successful open() as well.

Suggested change
if (windowId !== undefined && chrome.sidePanel) {
await chrome.sidePanel.open({ windowId });
return;
}
if (windowId !== undefined && chrome.sidePanel) {
await chrome.sidePanel.open({ windowId });
standalonePanelOpen = true;
return;
}

Comment on lines +1907 to +1913
chrome.sidePanel.open({ windowId: tab.windowId }).then(
() => undefined,
(error) => {
console.warn("sidePanel.open failed, using popup window", error);
return openRecorderPanel(tab);
},
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same idea in the click-path: setting the flag on success makes the toggle behavior less dependent on the standalone page sending its lifecycle message.

Suggested change
chrome.sidePanel.open({ windowId: tab.windowId }).then(
() => undefined,
(error) => {
console.warn("sidePanel.open failed, using popup window", error);
return openRecorderPanel(tab);
},
);
chrome.sidePanel.open({ windowId: tab.windowId }).then(
() => {
standalonePanelOpen = true;
},
(error) => {
console.warn("sidePanel.open failed, using popup window", error);
return openRecorderPanel(tab);
},
);

if (standalonePanelOpen) {
closeStandalonePanel();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case: on a cold-started MV3 worker, refreshStandalonePanelFlag() runs via callback, so this click can hit the standalonePanelOpen check before the flag is rebuilt. That makes the first click after SW restart re-open/focus the already-open panel instead of toggling it closed.

Suggested change
}
if (!standalonePanelOpen) {
try {
standalonePanelOpen = await new Promise<boolean>((resolve) => {
chrome.runtime.getContexts(
{
contextTypes: [
"SIDE_PANEL",
"TAB",
] as chrome.runtime.ContextType[],
documentUrls: [chrome.runtime.getURL(POPUP_URL)],
},
(contexts) => {
if (chrome.runtime.lastError) return resolve(false);
resolve((contexts ?? []).length > 0);
},
);
});
} catch {
// best-effort
}
}
if (standalonePanelOpen) {
closeStandalonePanel();
return;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant