fix(copilot): correct composer popover positioning and stacking in the widget shadow DOM

RO
1mo ago
+384-164

[!NOTE]
Continues #3025, which the stale bot auto-closed for inactivity — reopen wasn't available, so this is a fresh PR from the same branch. Nothing was outstanding on the original.

Status: ready to merge. Same commits as #3025, up to date with main (0 commits behind). CI was green there (24/24) and re-runs here. The tests @dokterbob asked for are in — each of the three composer pickers (CommandPopoverButton, ModePicker, FavoriteButton) asserts portaling into cl_shadowRootElement and the z-[51] stacking, and cubic's review comments are resolved.

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.

recording2

How to reproduce

  1. 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.
  2. To fix the text size, scale the widget up via customCssUrl:
    #chainlit-copilot-chat { zoom: 1.45; }
    
  3. Open the commands/tools popover (or the mode picker / favorites) in the composer.
  4. 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:

  1. Mispositioning under host zoom / transform / root font-size. The popovers rendered their PopoverContent inline (no Portal). In floating mode the whole chat panel is itself a Radix popper whose wrapper carries a transform, so it becomes the containing block for the popovers' position: fixed content. 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.
  2. Stacking (once portaled). Radix copies the content's computed z-index onto [data-radix-popper-content-wrapper] as an inline style. These pickers set no z-index, so the wrapper inlines auto and loses to the widget's z-50 chat surfaces.

Fix

  • Portal each popover into the widget's shadow root via <PopoverPortal container={window.cl_shadowRootElement}> — the convention the shared frontend/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 is undefined, so Radix falls back to document.body (unchanged behavior).
  • Set an explicit z-[51] on each picker's own content, so Radix copies it onto the popper wrapper (matching the z-50-on-content convention of the other poppers) and it stays above the z-50 chat. Scoped per-component — no global rule, no !important, no effect on other poppers.

Files changed

  • CommandPopoverButton.tsx
  • ModePicker.tsx
  • FavoriteButton.tsx
  • frontend/tests/{CommandPopoverButton,ModePicker,FavoriteButton}.spec.tsx (tests)

Testing

  • Added unit tests for all three pickers: each verifies the popover is portaled into cl_shadowRootElement and carries the z-[51] stacking class. pnpm test → 38/38.
  • pnpm lint, pnpm format-check, pnpm type-check pass.
  • Manual: verified each picker in floating and sidebar modes, under host zoom and non-default root font-size, in light and dark themes.

Summary by cubic

Fixes the Copilot widget's composer popovers (commands picker, mode picker, favorites) so they stay anchored to their triggers and render above the chat when the widget is embedded in a host page that scales or transforms it. Previously they could drift far off-target (or off-screen) and, once portaled, fall behind the chat.

  • Popovers were rendered inline, so a scaled or transformed chat panel became the containing block for their fixed-position content, and floating-ui couldn't compensate for the combined scale.
  • Each popover is now portaled into the widget's shadow root via PopoverPortal container={window.cl_shadowRootElement}; the standalone app keeps its current behavior because that global is undefined there.
  • Each picker's content now sets z-[51], so the popper wrapper stays above the z-50 chat surfaces. The change is scoped per component — no global rules.

Testing

  • Added unit tests for all three pickers verifying they portal into the shadow root and carry the z-[51] stacking class.
  • Centralized the ResizeObserver/scrollIntoView shims and shadow-host cleanup in setup-tests.ts, with shared mock call history cleared between tests.
  • Verified manually in floating and sidebar modes, under host zoom and non-default root font-size, in light and dark themes.