fix(session-ui): stop the message footer flickering as its menu closes (#62144)

This commit is contained in:
Luke Parker
2026-11-05 12:08:29 -06:00
committed by GitHub
Unverified
parent 324c89dd63
commit f7cf3a10f4
3 changed files with 51 additions and 4 deletions
@@ -122,6 +122,43 @@ story.describe("message actions menu", () => {
})
})
story("keeps the footer in view while an outside click closes the actions menu", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]')
await timeline.getByLabel("Delivery state").selectOption("delivered")
await timeline.locator('[data-component="user-message"]').hover()
await timeline.getByRole("button", { name: "More actions", exact: true }).click()
await expect(page.getByRole("menu")).toBeVisible()
await page.mouse.move(0, 0)
// Kobalte drops the expanded state a task before it returns focus to the trigger, so record every frame from the
// click until a few frames after the menu has gone rather than the settled state.
const frames = await footer.evaluateHandle((element) => {
const opacities: string[] = []
const closed = { frames: 0 }
const done = new Promise<string[]>((resolve) => {
const tick = () => {
opacities.push(getComputedStyle(element).opacity)
if (!document.querySelector('[role="menu"]')) closed.frames += 1
if (closed.frames < 5) return requestAnimationFrame(tick)
resolve(opacities)
}
requestAnimationFrame(tick)
})
return { done }
})
await page.mouse.click(0, 0)
expect(new Set(await frames.evaluate((sampler) => sampler.done))).toEqual(new Set(["1"]))
})
story("keeps pending steer actions usable after a request fails", async ({ mount }) => {
const timeline = await mount("current-prompt-message--pending")
const queue = timeline.getByRole("button", { name: "Move to queue", exact: true })
@@ -418,7 +418,7 @@
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"],
[data-slot="user-message-copy-wrapper"]:has([data-expanded]),
[data-slot="user-message-copy-wrapper"]:has([data-expanded], [data-closing]),
&:hover [data-slot="user-message-meta-tail"],
&:focus-within [data-slot="user-message-meta-tail"] {
opacity: 1;
@@ -468,7 +468,7 @@
&:hover [data-slot="user-message-run-actions"],
&:focus-within [data-slot="user-message-run-actions"],
[data-slot="user-message-run-actions"]:has([data-expanded]) {
[data-slot="user-message-run-actions"]:has([data-expanded], [data-closing]) {
opacity: 1;
pointer-events: auto;
}
+12 -2
View File
@@ -1,5 +1,6 @@
import { DropdownMenu } from "@kobalte/core/dropdown-menu"
import { DropdownMenu, type DropdownMenuTriggerProps } from "@kobalte/core/dropdown-menu"
import { ContextMenu } from "@kobalte/core/context-menu"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import {
createComputed,
createContext,
@@ -13,6 +14,7 @@ import {
type ComponentProps,
type JSX,
type ParentProps,
type ValidComponent,
} from "solid-js"
import {
createMenuState,
@@ -316,6 +318,14 @@ function MenuSeparator(props: ComponentProps<typeof DropdownMenu.Separator>) {
)
}
// Kobalte drops `data-expanded` as the menu closes but returns focus to the trigger a task later, so for a frame the
// trigger is neither expanded nor focused. `data-closing` covers that gap for UI revealed by either.
function MenuTrigger<T extends ValidComponent = "button">(props: PolymorphicProps<T, DropdownMenuTriggerProps<T>>) {
const ctx = useMenuContext()
return <DropdownMenu.Trigger {...props} data-closing={ctx.state().status === "closing" ? "" : undefined} />
}
function MenuContent(props: ComponentProps<typeof DropdownMenu.Content>) {
const ctx = useMenuContext()
const [s, r] = splitProps(props, ["class", "classList", "onCloseAutoFocus", "onInteractOutside"])
@@ -439,7 +449,7 @@ const MenuContext = Object.assign(MenuContextRoot, {
})
export const Menu = Object.assign(MenuRoot, {
Trigger: DropdownMenu.Trigger,
Trigger: MenuTrigger,
Portal: DropdownMenu.Portal,
Content: MenuContent,
Item: MenuItem,