feat(chrome-extension): open the recorder in Chrome's side panel on non-injectable pages - #2027
Conversation
| if (standalonePanelOpen) { | ||
| closeStandalonePanel(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
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.| const closeStandalonePanel = () => { | ||
| if (!standalonePanelOpen) return; | ||
| standalonePanelOpen = false; | ||
| chrome.runtime.sendMessage( | ||
| { target: "standalone-panel", type: "close" }, | ||
| () => { | ||
| void chrome.runtime.lastError; | ||
| }, | ||
| ); |
There was a problem hiding this comment.
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.
| 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; | |
| }, | |
| ); | |
| }; |
| if (windowId !== undefined && chrome.sidePanel) { | ||
| await chrome.sidePanel.open({ windowId }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
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.
| if (windowId !== undefined && chrome.sidePanel) { | |
| await chrome.sidePanel.open({ windowId }); | |
| return; | |
| } | |
| if (windowId !== undefined && chrome.sidePanel) { | |
| await chrome.sidePanel.open({ windowId }); | |
| standalonePanelOpen = true; | |
| return; | |
| } |
| chrome.sidePanel.open({ windowId: tab.windowId }).then( | ||
| () => undefined, | ||
| (error) => { | ||
| console.warn("sidePanel.open failed, using popup window", error); | ||
| return openRecorderPanel(tab); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| } | |
| 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; | |
| } |
| resolve(); | ||
| } | ||
| }); | ||
| const standaloneFlagReady = refreshStandalonePanelFlag(); |
There was a problem hiding this 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.
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.… is not injectable
…eardown, and single-recorder rule
…ailures fall back to the side panel
…abs instead of deferring to a dead flag
…the recorder doc can idle-close
…for reliable startup recovery
6f0ef2e to
b99e560
Compare
| // standaloneFlagReady resolves as a microtask when already settled | ||
| // (the common case), preserving Chrome's click-gesture window for | ||
| // sidePanel.open. | ||
| await standaloneFlagReady; |
There was a problem hiding this 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.
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.
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):
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.
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
Prompt To Fix All With AI
Reviews (3): Last reviewed commit: "fix(extension): await standaloneFlagRead..." | Re-trigger Greptile