Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ it("does not flash a wrapped action row when the details button appears", async
}
});

it("keeps the usage-limit reset time readable beside the actions", async () => {
await page.viewport(1040, 800);
container.style.width = "1000px";
it("keeps the usage-limit reset time on the details hover", async () => {
await page.viewport(760, 800);
container.style.width = "720px";
const runId = RunId.make("run");
const resetAt = "2026-10-20T22:30:00.000Z";
await act(async () =>
Expand All @@ -153,18 +153,43 @@ it("keeps the usage-limit reset time readable beside the actions", async () => {
);
await nextFrame();

const sentence = page.getByText(/Resumes automatically about a minute after/);
await expect.element(sentence).toBeVisible();
const sentenceBounds = sentence.element().getBoundingClientRect();
const banner = container.querySelector<HTMLElement>('[data-slot="composer-banner"]')!;
expect(sentence.element().scrollWidth).toBeLessThanOrEqual(sentence.element().clientWidth + 1);
expect(sentenceBounds.right).toBeLessThanOrEqual(banner.getBoundingClientRect().right);
const title = page.getByText("Usage limit reached", { exact: true });
const resume = page.getByRole("button", { name: "Resume now", exact: true });
const details = page.getByRole("button", { name: "Show notice details", exact: true });
await expect.element(details).toBeVisible();
await expect
.element(page.getByText(/Resumes automatically about a minute after/))
.not.toBeInTheDocument();
expect(resume.element().getBoundingClientRect().top).toBe(
title.element().getBoundingClientRect().top,
);
expect(details.element().getBoundingClientRect().top).toBeGreaterThanOrEqual(
title.element().getBoundingClientRect().top - 1,
);
expect(details.element().getBoundingClientRect().bottom).toBeLessThanOrEqual(
title.element().getBoundingClientRect().bottom + 1,
);

const height = banner.getBoundingClientRect().height;
for (let frame = 0; frame < 6; frame += 1) {
await nextFrame();
expect(banner.getBoundingClientRect().height).toBe(height);
expect(sentence.element().scrollWidth).toBeLessThanOrEqual(sentence.element().clientWidth + 1);
}

await userEvent.hover(details);
const popup = page.getByRole("dialog", { name: "Notice details", exact: true });
await expect.element(popup).toBeVisible();
expect(popup.element().textContent ?? "").toContain("Resumes automatically about a minute after");
const sentence = popup
.element()
.querySelector<HTMLElement>("[data-slot=scroll-area-viewport] > div");
expect(sentence).not.toBeNull();
const lineHeight = Number.parseFloat(getComputedStyle(sentence!).lineHeight);
expect(sentence!.getBoundingClientRect().height).toBeLessThanOrEqual(lineHeight * 2 + 1);
expect(sentence!.getBoundingClientRect().width).toBeGreaterThan(
title.element().getBoundingClientRect().width,
);
});

it.each([320, 800])(
Expand Down
66 changes: 44 additions & 22 deletions apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface ComposerBannerStackItem {
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly descriptionPlacement?: "inline" | "below";
/** `details` keeps the description on the info hover instead of in the row. */
readonly descriptionPlacement?: "inline" | "below" | "details";
readonly children?: ReactNode;
readonly actions?: ReactNode;
readonly dismissLabel?: string;
Expand Down Expand Up @@ -251,6 +252,35 @@ export function ComposerBannerStack({ className, items, attachedAbove }: Compose
);
}

function NoticeDetails({
children,
label,
}: {
readonly children: ReactNode;
readonly label: string;
}) {
return (
<Popover>
<PopoverTrigger
openOnHover
render={<Button size="icon-xs" variant="ghost-muted" aria-label={label} />}
>
<InfoIcon />
</PopoverTrigger>
<PopoverPopup
aria-label="Notice details"
tooltipStyle
side="top"
className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full"
>
<ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
{children}
</ComposerBanner.Scroll>
</PopoverPopup>
</Popover>
);
}

function NoticeText({
children,
className,
Expand Down Expand Up @@ -305,26 +335,9 @@ function NoticeText({
{children}
</span>
{showDetails ? (
<Popover>
<span className="absolute end-0 top-1/2 z-10 -translate-y-1/2">
<PopoverTrigger
openOnHover
render={<Button size="icon-xs" variant="ghost-muted" aria-label={detailsLabel} />}
>
<InfoIcon />
</PopoverTrigger>
</span>
<PopoverPopup
aria-label="Notice details"
tooltipStyle
side="top"
className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full"
>
<ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
{children}
</ComposerBanner.Scroll>
</PopoverPopup>
</Popover>
<span className="absolute end-0 top-1/2 z-10 -translate-y-1/2">
<NoticeDetails label={detailsLabel}>{children}</NoticeDetails>
</span>
) : null}
</span>
);
Expand Down Expand Up @@ -353,6 +366,7 @@ function ComposerBannerStackAlert({
);
}
const descriptionBelow = item.descriptionPlacement === "below";
const descriptionDetails = item.descriptionPlacement === "details";
return (
<ComposerBanner.Root
role="alert"
Expand All @@ -361,7 +375,11 @@ function ComposerBannerStackAlert({
density="comfortable"
>
<ComposerBanner.Row
layout={descriptionBelow || item.priority === "activity" ? "inline" : "wrap-actions"}
layout={
descriptionBelow || descriptionDetails || item.priority === "activity"
? "inline"
: "wrap-actions"
}
>
<ComposerBanner.Icon className="h-(--composer-banner-icon-column) self-start">
{item.icon}
Expand All @@ -380,6 +398,10 @@ function ComposerBannerStackAlert({
<ComposerBanner.Scroll className="text-muted-foreground whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full">
<span className="block pb-1">{item.description}</span>
</ComposerBanner.Scroll>
) : descriptionDetails ? (
<span className="flex-none">
<NoticeDetails label="Show notice details">{item.description}</NoticeDetails>
</span>
) : (
<NoticeText
detailsLabel="Show notice details"
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann
priority: "urgent",
icon: <GaugeIcon />,
title: "Usage limit reached",
// Inline, the reset time truncates against the action buttons.
descriptionPlacement: "below",
// The action buttons take the rest of the row. Stacking the reset time
// under the title wraps it inside that leftover column.
descriptionPlacement: "details",
description: resetAt
? scheduled
? `Resumes automatically about a minute after ${new Date(resetAt).toLocaleString()}`
Expand Down