Skip to content

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

Open
ManthanNimodiya wants to merge 10 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 10 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

The PR adds a side-panel recorder for non-injectable Chrome pages and improves extension recovery behavior.

  • Adds the side-panel permission, manifest entry, lifecycle messages, toggle handling, and popup fallback.
  • Reworks content-script reinjection to replace orphaned extension generations and stale overlay UI.
  • Clears zombie offscreen recording phases during stop handling and idle watchdog checks.

Confidence Score: 4/5

The PR is not yet safe to merge because the first post-restart click can lose the user gesture required to open the side panel and fall back to the confusing popup window.

The startup-race fix waits for a callback-based context query inside the click listener, allowing the gesture-gated side-panel open to execute too late on the exact worker-restart path it is intended to repair.

Files Needing Attention: apps/chrome-extension/src/background/service-worker.ts

Important Files Changed

Filename Overview
apps/chrome-extension/src/background/service-worker.ts Adds side-panel lifecycle and toggle orchestration, but the startup-state fix awaits an asynchronous context query before the gesture-gated open call.
apps/chrome-extension/src/popup/main.tsx Reports standalone panel lifecycle to the worker and handles close broadcasts.
apps/chrome-extension/src/content/bootstrap.ts Replaces stale bootstrap generations and removes orphaned overlay UI during reinjection.
apps/chrome-extension/src/offscreen/recorder.ts Detects and clears recording phases that no longer have an active capture operation.
apps/chrome-extension/public/manifest.json Enables the Chrome side-panel permission and configures the standalone recorder page.
apps/chrome-extension/src/shared/types.ts Adds service-worker request variants for standalone panel lifecycle events.
Prompt To Fix All With AI
### Issue 1
apps/chrome-extension/src/background/service-worker.ts:1931
**Startup wait loses click gesture**

When the first icon click after an MV3 worker restart arrives before `standaloneFlagReady` settles, this `await` suspends the action handler until the asynchronous `getContexts` callback returns. `sidePanel.open` then runs outside the required click gesture, rejects, and the fallback path ultimately opens the legacy popup instead of the side panel.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (3): Last reviewed commit: "fix(extension): await standaloneFlagRead..." | 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;
}

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai

resolve();
}
});
const standaloneFlagReady = refreshStandalonePanelFlag();

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 Startup refresh races panel toggle

When the user clicks the extension icon immediately after an MV3 worker restart while the side panel remains open, the action listener can read the initial false value before getContexts completes. It then calls sidePanel.open() instead of sending the close message, so the visible recorder remains open and an open failure can additionally trigger the popup fallback.

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

Comment:
**Startup refresh races panel toggle**

When the user clicks the extension icon immediately after an MV3 worker restart while the side panel remains open, the action listener can read the initial `false` value before `getContexts` completes. It then calls `sidePanel.open()` instead of sending the close message, so the visible recorder remains open and an open failure can additionally trigger the popup fallback.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@ManthanNimodiya
ManthanNimodiya force-pushed the feat/extension-side-panel-fallback branch from 6f0ef2e to b99e560 Compare August 6, 2026 05:20
@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

@greptileai

// standaloneFlagReady resolves as a microtask when already settled
// (the common case), preserving Chrome's click-gesture window for
// sidePanel.open.
await standaloneFlagReady;

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 Startup wait loses click gesture

When the first icon click after an MV3 worker restart arrives before standaloneFlagReady settles, this await suspends the action handler until the asynchronous getContexts callback returns. sidePanel.open then runs outside the required click gesture, rejects, and the fallback path ultimately opens the legacy popup instead of the side panel.

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

Comment:
**Startup wait loses click gesture**

When the first icon click after an MV3 worker restart arrives before `standaloneFlagReady` settles, this `await` suspends the action handler until the asynchronous `getContexts` callback returns. `sidePanel.open` then runs outside the required click gesture, rejects, and the fallback path ultimately opens the legacy popup instead of the side panel.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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