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
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ it("shows the details button while text or nested text is cut, until it fits aga
const details = screen.getByRole("button", { name: "Show details" });
await expect.element(details).toBeInTheDocument();

await screen.rerender(noticeRow("4rem", "Ready"));
await expect.element(details).not.toBeInTheDocument();

await screen.rerender(noticeRow("4rem", "Waiting on a subagent with a long name"));
await expect.element(details).toBeInTheDocument();

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

Expand All @@ -107,4 +113,13 @@ it("shows the details button while text or nested text is cut, until it fits aga
),
);
await expect.element(details).toBeInTheDocument();

await screen.rerender(
<ComposerBanner.Root>
{noticeRow("4rem", "Waiting on a subagent with a long name")}
</ComposerBanner.Root>,
);
await expect.element(details).toBeInTheDocument();
await screen.rerender(<ComposerBanner.Root>{noticeRow("4rem", "Ready")}</ComposerBanner.Root>);
await expect.element(details).not.toBeInTheDocument();
});
49 changes: 34 additions & 15 deletions apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,36 +72,55 @@ export function NoticeText({
}) {
const textRef = useRef<HTMLSpanElement>(null);
const detailsRef = useRef<HTMLButtonElement>(null);
const [showDetails, setShowDetails] = useState(false);
const [detailsAtWidth, setDetailsAtWidth] = useState<number | null>(null);
const showDetails = detailsAtWidth !== null;

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const banner = text.closest("[data-slot=composer-banner]");
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(
const overflowing =
hidden ||
[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
);
}),
[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
);
});
// The button's width can wrap the banner's actions onto a second row, which gives
// the text room and removes the button, which unwraps the row again. Once shown,
// the button stays until the banner itself gets wider than where it appeared.
const width = banner?.clientWidth ?? text.clientWidth;
setDetailsAtWidth((shownAt) =>
overflowing
? (shownAt ?? width)
: banner && shownAt !== null && width <= shownAt
? shownAt
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: null,
);
};
measure();
const stopObserving = observeResize(text, measure);
const mutations = new MutationObserver(measure);
const stopObserving = observeResize([text, banner ?? text], measure);
let content = text.textContent;
const mutations = new MutationObserver(() => {
if (text.textContent !== content) {
content = text.textContent;
setDetailsAtWidth(null);
}
measure();
});
mutations.observe(text, { childList: true, subtree: true, characterData: true });
return () => {
stopObserving();
Expand Down
Loading