AI Extension FinderTESTED · SEP 2026
Install Free Extension
side-panel-apimanifest-v3ai-assistantextension-architecture

Chrome Side Panel API for AI Assistants: Persistent Context Without a Popup Trap

Arjun Mehta

Arjun Mehta

Senior Browser & Extensions Analyst

•6 min read

Every AI extension starts with a popup and regrets it within a month. A popup closes the moment the user clicks the page, which kills the conversation surface exactly when the assistant is supposed to be useful. The Chrome Side Panel API fixes that by docked, persistent UI. It also introduces a second document with its own lifecycle, its own message channel, and its own layout constraints. Here is what that actually changes in the code.

Why the popup lifecycle breaks AI workflows

A browser action popup is bound to focus. Blur the window or move the cursor onto the page and the popup document is torn down. Any in-flight fetch is cancelled, any React state is discarded, and a streaming response mid-token simply disappears. Extensions work around this by moving the request to the service worker, but the worker is not a UI: it can buffer tokens, cannot render them, and dies after roughly 30 seconds of inactivity unless kept alive.

The side panel is a normal extension page. It stays mounted while open, survives tab switches, and can run a streaming fetch for minutes. For chat-style assistance, document comparison, or a log that updates while the user reads the page, that difference is the whole feature.

Registering the panel and controlling access

Declare the permission in the manifest and call chrome.sidePanel.setPanelBehavior to make the toolbar icon open the panel instead of a popup:

{
  "permissions": ["sidePanel", "storage", "activeTab"],
  "side_panel": { "default_path": "sidepanel.html" }
}
chrome.sidePanel
  .setPanelBehavior({ openPanelOnActionClick: true })
  .catch(err => console.error(err));

chrome.sidePanel.open({ tabId }) is gated on a user gesture. Calling it from a service worker after an async fetch resolves will reject with a gesture error; open the panel synchronously in the click or context-menu handler and pass the work into the panel once it is mounted. Per-tab panels (setOptions({ tabId, path })) let saved research on one tab coexist with a different conversation on another, but each tab then holds independent state.

There is no "panel closed" event in the API. If state must persist across close and reopen, write it to chrome.storage.session keyed by tab, and rehydrate on load. chrome.storage.session lives in memory for the browser session, which is the right retention for transient chat context and the wrong one for a saved prompt library.

Message routing between page, panel, and worker

A side panel is a top-level extension context: it can call chrome.runtime.sendMessage, but chrome.runtime.onMessage does not receive its own messages. Route explicitly with an envelope that names the target.

// panel -> worker
const res = await chrome.runtime.sendMessage({
  target: "worker",
  type: "summarize-selection",
  tabId
});

When the worker needs to stream back, it cannot use a single sendResponse for partial output. Use a MessageChannel: transfer one port to the content script or panel and post tokens on it. For page context, chrome.tabs.sendMessage(tabId, ...) still works, but guard against tabs with no content script — PDF viewers, the Web Store, and chrome:// pages all reject injection, and chrome:// URLs cannot be scripted at all.

Two ordering bugs show up repeatedly. First, the panel mounts after the user gesture that opened it, so a "summarize on open" action races the listener registration; buffer the initial request in storage.session and consume it after DOMContentLoaded. Second, multiple panels for multiple tabs can all receive a broadcast; include tabId in every request and reject mismatches.

Layout and performance inside a narrow column

The side panel is user-resizable, commonly 320–480 px wide. Fixed multi-column grids and 14 px body text at 260 px produce horizontal scroll. Set min-width: 280px on the root, use container queries instead of viewport media queries, and keep the composer pinned with position: sticky; bottom: 0 inside a scrolling message list.

Rendering streamed tokens is the other trap. Appending each token to a DOM node forces layout hundreds of times per response. Batch with requestAnimationFrame, render plain text into a single <pre> while streaming, and only run Markdown parsing once the stream ends. Syntax highlighting and copy buttons should be attached after completion, not per token.

Security and the permission boundary

The panel has access to every API the extension declares, so it should not be the place where untrusted model output is rendered. Model responses are untrusted input: render Markdown with raw HTML disabled, sanitize links, and allowlist URL schemes before setting href. A response that emits javascript: or an extension-internal chrome-extension:// URL must be neutralized, not trusted because it came from "our" model.

Keep API keys in the worker or a backend. A side panel is still a client context: anything bundled into sidepanel.html is readable from the installed package. Proxy requests through a service-worker fetch with an allowlisted origin, or terminate auth server-side and store only short-lived tokens.

Finally, remember that the panel persists visually while the page changes. If the assistant claims "this page says X", it needs the current tab's extracted text, fetched at request time, not the DOM snapshot from when the panel opened. A stale snapshot presented as live context is the most common trust failure in docked assistants.

Resizing, tear-off, and state across multiple panels

Chrome lets users drag the panel divider and, in some versions, open it in a separate window. Your layout must survive both. Listen for ResizeObserver on the panel root rather than the window; the window size does not change when the panel does. Recompute any virtualized list height on resize, and debounce relayout to one frame so dragging does not thrash.

Tearing the panel into a window changes the viewport from roughly 400×900 to a wide, short rectangle. A chat UI with a sidebar designed for 900 px of height collapses awkwardly. Use a single-column layout under 500 px and switch to a two-column layout above 900 px, keyed on container width, not screen width.

Per-tab panels mean several panel documents can be alive at once. If each holds its own conversation state, a user who switches tabs sees different histories and assumes messages were lost.

Keep conversation state keyed by tabId in the worker or in storage.session, and treat the panel as a projection. On mount, the panel reads the conversation for its tab and subscribes to changes. When a message completes, the authoritative write happens once, in one place.

Do not broadcast updates to every panel without a target. Include tabId in the message envelope and let each panel ignore events for other tabs. A broadcast that updates every panel is a fast route to duplicated messages.

Printing, exporting, and a panel-specific launch checklist

Users will try Ctrl+P on a panel and get a screenshot of the browser chrome or a blank page. If export matters, provide a dedicated button that renders the conversation into a print-styled document. That path is more reliable than print CSS inside a panel document and avoids the browser's inconsistent handling of panel printing.

Open the panel from the toolbar, from a keyboard command, and from a context menu; confirm the initial state is correct in all three. Switch tabs with the panel open and confirm the conversation follows the tab or explicitly does not. Resize to the narrowest width and check that the composer and send button remain reachable. Close and reopen and confirm the panel restores from storage rather than memory. Run with a screen reader once; a docked assistant that cannot be operated without a mouse excludes the users most likely to rely on it.

Related Guides & Reviews