fix(copilot): correct composer popover positioning and stacking in the widget shadow DOM
Closed
RO
2mo ago+382+382+012345678901234567890123456789-164-164-012345678901234567890123456789
Summary
In the Copilot widget, the message-composer popovers — tools picker (CommandPopoverButton), ModePicker, and FavoriteButton — could render far off-target (and, once portaled, behind the chat) when the widget is embedded in a host page that scales it. This fixes both the positioning and the stacking, scoped to the widget's shadow DOM. The standalone Chainlit app is unaffected.
How to reproduce
- Embed the Copilot widget in a host page whose root shrinks text, e.g.
html { font-size: 10px }(common in some design systems). The widget's own text now renders tiny and hard to read. - To fix the text size, scale the widget up via
customCssUrl:#chainlit-copilot-chat { zoom: 1.45; } - Open the commands/tools popover (or the mode picker / favorites) in the composer.
- Bug: the popover is detached from its trigger, and at some zoom levels renders outside the visible page area entirely.
A transformed host root reproduces the same thing without zoom — e.g. body { transform: translateZ(0) } (or filter / perspective / will-change: transform), which is common on real hosts.
Root causes
Two compounding, shadow-DOM-specific issues:
- Mispositioning under host
zoom/transform/ rootfont-size. The popovers rendered theirPopoverContentinline (no Portal). In floating mode the whole chat panel is itself a Radix popper whose wrapper carries atransform, so it becomes the containing block for the popovers'position: fixedcontent. floating-ui can't compensate for the combined host scale + panel transform, so the computed offset drifts — and the drift grows with the zoom factor, pushing the popover off-screen. - Stacking (once portaled). Radix copies the content's computed
z-indexonto[data-radix-popper-content-wrapper]as an inline style. These pickers set noz-index, so the wrapper inlinesautoand loses to the widget'sz-50chat surfaces.
Fix
- Portal each popover into the widget's shadow root via
<PopoverPortal container={window.cl_shadowRootElement}>— the convention the sharedfrontend/src/components/ui/*primitives already use. This moves the content out of the transformed/zoomed panel, so position is computed against the shadow root, not a scaled containing block. In the standalone app that global isundefined, so Radix falls back todocument.body(unchanged behavior). - Set an explicit
z-[51]on each picker's own content, so Radix copies it onto the popper wrapper (matching thez-50-on-content convention of the other poppers) and it stays above thez-50chat. Scoped per-component — no global rule, no!important, no effect on other poppers.
Files changed
CommandPopoverButton.tsxModePicker.tsxFavoriteButton.tsxfrontend/tests/{CommandPopoverButton,ModePicker,FavoriteButton}.spec.tsx(tests)
Testing
- Added unit tests for all three pickers: each verifies the popover is portaled into
cl_shadowRootElementand carries thez-[51]stacking class.pnpm test→ 38/38. pnpm lint,pnpm format-check,pnpm type-checkpass.- Manual: verified each picker in floating and sidebar modes, under host
zoomand non-default rootfont-size, in light and dark themes.