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
79 changes: 77 additions & 2 deletions apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { InfoIcon } from "lucide-react";
import { expect, it } from "vite-plus/test";
import type { ReactNode } from "react";
import { expect, it, vi } from "vite-plus/test";

import "~/index.css";

import { render } from "../../../test/render";
import { ComposerBanner } from "./ComposerBanner";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
import {
ComposerBannerStack,
NoticeText,
type ComposerBannerStackItem,
} from "./ComposerBannerStack";

function notice(title: string, group?: ComposerBannerStackItem["group"]): ComposerBannerStackItem {
return { id: title, variant: "default", icon: <InfoIcon />, title, ...(group ? { group } : {}) };
Expand Down Expand Up @@ -33,3 +38,73 @@ it("leaves the next attachment its own top edge when there are no notices", asyn
expect(surface.borderTopWidth).toBe("1px");
expect(surface.borderTopLeftRadius).not.toBe("0px");
});

function noticeRow(width: string, children: ReactNode) {
return (
<div data-testid="row" className="flex items-center gap-1.5" style={{ width }}>
<NoticeText detailsLabel="Show details" className="min-w-0 truncate">
{children}
</NoticeText>
</div>
);
}

it("leaves out the details button when only a link's hit area reaches past the text", async () => {
// Phones lay out in device pixels, so a range can measure one float32 step (2^-17 px at
// this size) wider than its element. Headless Chromium's rects agree exactly.
const rangeRect = Range.prototype.getBoundingClientRect;
using _rangeRect = vi
.spyOn(Range.prototype, "getBoundingClientRect")
.mockImplementation(function (this: Range) {
const rect = rangeRect.call(this);
return new DOMRect(rect.x, rect.y, rect.width + 2 ** -17, rect.height);
});
// The flicker is a details button that mounts and then unmounts, so record every insertion.
const inserted: Node[] = [];
using observing = new DisposableStack();
const mutations = new MutationObserver((records) =>
inserted.push(...records.flatMap((record) => [...record.addedNodes])),
);
mutations.observe(document.body, { childList: true, subtree: true });
observing.defer(() => mutations.disconnect());
await using screen = await render(
noticeRow(
"20rem",
<>
Needs input{" "}
<button type="button" className="relative after:absolute after:-inset-x-10 after:inset-y-0">
Review
</button>
</>,
),
);

await expect.element(screen.getByText("Review")).toBeVisible();
const details = [
...inserted,
...mutations.takeRecords().flatMap((record) => [...record.addedNodes]),
].filter(
(node) =>
node instanceof Element &&
(node.matches('[aria-label="Show details"]') ||
node.querySelector('[aria-label="Show details"]')),
);
expect(details).toEqual([]);
});

it("shows the details button while text or nested text is cut, until it fits again", async () => {
await using screen = await render(noticeRow("4rem", "Waiting on a subagent with a long name"));
const details = screen.getByRole("button", { name: "Show details" });
await expect.element(details).toBeInTheDocument();

screen.getByTestId("row").element().style.width = "40rem";
await expect.element(details).not.toBeInTheDocument();

await screen.rerender(
noticeRow(
"40rem",
<span className="inline-block max-w-16 truncate align-bottom">A long nested name</span>,
),
);
await expect.element(details).toBeInTheDocument();
});
17 changes: 14 additions & 3 deletions apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function ComposerBannerStack({
));
}

const SUBPIXEL_ROUNDING = 0.001;

export function NoticeText({
children,
className,
Expand All @@ -76,16 +78,25 @@ export function NoticeText({
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const range = document.createRange();
const measure = () => {
// Ignore the space taken by the details button itself so it cannot
// sustain its own overflow after the text would otherwise fit.
const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
const hidden = getComputedStyle(text).position === "absolute";
setShowDetails(
hidden ||
[text, ...text.querySelectorAll("*")].some(
(element) => element.scrollWidth > element.clientWidth + recoveredWidth,
),
[text, ...text.querySelectorAll("*")].some((element) => {
// A range spans laid-out content only; scrollWidth also counts positioned layers
// such as a link's touch hit area, which overflow without cutting any text.
// The two rects can disagree by float rounding (~0.00001px on phones); real
// overflow is at least one layout unit, 1/64 of a device pixel.
range.selectNodeContents(element);
return (
range.getBoundingClientRect().width >
element.getBoundingClientRect().width + recoveredWidth + SUBPIXEL_ROUNDING
);
}),
);
};
measure();
Expand Down
Loading