Skip to content

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

Open
RoyBA wants to merge 8 commits into
Chainlit:mainfrom
RoyBA:fix/copilot-popover-positioning
Open

RoyBA wants to merge 8 commits into
Chainlit:mainfrom
RoyBA:fix/copilot-popover-positioning

Conversation

@RoyBA

@RoyBA RoyBA commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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.

Written for commit a8ac3ce. Summary will update on new commits.

Review in cubic

RoyBA and others added 6 commits August 26, 2026 14:37
The command, favorite, and mode picker popovers rendered their content inline. In the widget's floating mode the chat panel is itself a transformed Radix popper, which becomes the containing block for the fixed-positioned popovers and breaks floating-ui's scale compensation under host zoom/font-size, throwing them off-screen.

Portal each popover into cl_shadowRootElement (the convention the shared ui/* primitives already use) and set an explicit z-index on each picker's content so Radix copies it onto the popper wrapper, keeping them above the z-50 chat. This matches how ui/popover, dropdown, select set z-50 on their own content and avoids a global wrapper override.

Co-Authored-By: GitHub Copilot <noreply@github.com>
Add tests for the three composer pickers verifying each popover is portaled into cl_shadowRootElement and carries the z-[51] stacking class that keeps it above the chat surfaces.

Co-Authored-By: GitHub Copilot <noreply@github.com>
Address review: use attachShadow instead of a plain div and assert the popover lands in the encapsulated shadow tree (not the light DOM), add a standalone document.body fallback test, centralize the ResizeObserver/scrollIntoView shims in setup-tests, and move shadow-host cleanup into afterEach.

Co-Authored-By: GitHub Copilot <noreply@github.com>
Address review: move cleanupShadowHosts into setup-tests' global afterEach so mountShadowHost consumers can't leak the DOM host or the cl_shadowRootElement global, removing the per-spec afterEach boilerplate.

Co-Authored-By: GitHub Copilot <noreply@github.com>
Co-Authored-By: GitHub Copilot <noreply@github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread frontend/tests/setup-tests.ts
Comment thread frontend/tests/testUtils.ts
RoyBA and others added 2 commits September 18, 2026 20:36
Address review: clear mock call history in the global afterEach so the shared prototype scrollIntoView stub (and other mocks) can't accumulate calls across tests in a worker.

Co-Authored-By: GitHub Copilot <noreply@github.com>
Co-Authored-By: GitHub Copilot <noreply@github.com>

This branch has not been deployed

No deployments
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