authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-09-20 20:35:02-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 20:35:02-03:00
logf4d979a6c6139ab10aa1013335bd94a313a5b56e
tree33d1084ddef1b1279218f5913c55fa8011215749
parentc789cd174ae50f50ecb0a34b96a81e00b0f5c823
signature Signed by PGP key B5690EEEBB952194

fix(web): only show notice details when text is clipped (#12760)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com>

2 files changed, 182 insertions(+), 38 deletions(-)

apps/web/src/components/chat/ComposerBannerStack.test.tsx created+103
...@@ -0,0 +1,103 @@
1import { cloneElement, type ReactElement, type ReactNode } from "react";
2import { act, create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, expect, it, vi } from "vite-plus/test";
4
5import { ComposerBannerStack } from "./ComposerBannerStack";
6
7vi.mock("../ui/popover", () => ({
8 Popover: "popover",
9 PopoverTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
10 cloneElement(render, {}, children),
11 PopoverPopup: "popup",
12}));
13vi.mock("../ui/button", () => ({ Button: "button" }));
14vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
15
16let renderer: ReactTestRenderer;
17afterEach(async () => {
18 if (renderer) await act(() => renderer.unmount());
19 vi.unstubAllGlobals();
20});
21
22it("only offers notice details when the description cannot fit", async () => {
23 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
24 let resize = () => {};
25 let mutate = () => {};
26 vi.stubGlobal(
27 "MutationObserver",
28 class {
29 constructor(callback: () => void) {
30 mutate = callback;
31 }
32 observe() {}
33 disconnect() {}
34 },
35 );
36 vi.stubGlobal(
37 "ResizeObserver",
38 class {
39 constructor(callback: () => void) {
40 resize = callback;
41 }
42 observe() {}
43 disconnect() {}
44 },
45 );
46 let position = "static";
47 vi.stubGlobal("getComputedStyle", () => ({ position }));
48 let availableWidth = 200;
49 const nested = { clientWidth: 100, scrollWidth: 80 };
50 const text = {
51 querySelectorAll: () => [nested],
52 get clientWidth() {
53 return (
54 availableWidth -
55 (renderer?.root.findAllByProps({ "aria-label": "Show notice details" }).length ? 28 : 0)
56 );
57 },
58 scrollWidth: 80,
59 };
60 await act(() => {
61 renderer = create(
62 <ComposerBannerStack
63 items={[
64 {
65 id: "usage",
66 variant: "info",
67 icon: null,
68 title: "Usage limits",
69 description: "OpenCode",
70 },
71 ]}
72 />,
73 {
74 createNodeMock: (element) =>
75 element.type === "span" ? text : element.type === "button" ? { offsetWidth: 24 } : null,
76 },
77 );
78 });
79 const details = () => renderer.root.findAllByProps({ "aria-label": "Show notice details" });
80 expect(details()).toHaveLength(0);
81 text.scrollWidth = 300;
82 await act(() => resize());
83 expect(details()).toHaveLength(1);
84 // It fits without the icon: the icon must not keep its own overflow alive.
85 availableWidth = 308;
86 await act(() => resize());
87 expect(details()).toHaveLength(0);
88 text.scrollWidth = 80;
89 await act(() => resize());
90 expect(details()).toHaveLength(0);
91 nested.scrollWidth = 500;
92 await act(() => mutate());
93 expect(details()).toHaveLength(1);
94 nested.scrollWidth = 80;
95 await act(() => mutate());
96 expect(details()).toHaveLength(0);
97 position = "absolute";
98 await act(() => resize());
99 expect(details()).toHaveLength(1);
100 position = "static";
101 await act(() => resize());
102 expect(details()).toHaveLength(0);
103});
apps/web/src/components/chat/ComposerBannerStack.tsx+79-38
...@@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
242 );242 );
243}243}
244244
245/** Keep full descriptions reachable only when their inline copy is clipped. */
246function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) {
247 const descriptionRef = useRef<HTMLSpanElement>(null);
248 const detailsRef = useRef<HTMLButtonElement>(null);
249 const [showDetails, setShowDetails] = useState(false);
250
251 useLayoutEffect(() => {
252 const description = descriptionRef.current;
253 if (!description) return;
254 const measure = () => {
255 // Ignore the space taken by the details button itself so it cannot
256 // sustain its own overflow after the description would otherwise fit.
257 const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
258 const hidden = getComputedStyle(description).position === "absolute";
259 setShowDetails(
260 hidden ||
261 [description, ...description.querySelectorAll("*")].some(
262 (element) => element.scrollWidth > element.clientWidth + recoveredWidth,
263 ),
264 );
265 };
266 measure();
267 const observer = new ResizeObserver(measure);
268 observer.observe(description);
269 // A child can reveal new text without resizing its clipped box.
270 const mutations = new MutationObserver(measure);
271 mutations.observe(description, { childList: true, subtree: true, characterData: true });
272 return () => {
273 observer.disconnect();
274 mutations.disconnect();
275 };
276 }, []);
277
278 return (
279 <span className={compact ? "contents" : "flex min-w-8 flex-1 items-center gap-1"}>
280 <span
281 ref={descriptionRef}
282 className={cn(
283 "min-w-0 truncate text-muted-foreground",
284 compact && "shrink-[9999] @max-[400px]:sr-only",
285 )}
286 >
287 {children}
288 </span>
289 {showDetails ? (
290 <Popover>
291 <PopoverTrigger
292 openOnHover
293 render={
294 <Button
295 ref={detailsRef}
296 size="icon-xs"
297 variant="ghost-muted"
298 aria-label="Show notice details"
299 className="flex-none"
300 />
301 }
302 >
303 <InfoIcon />
304 </PopoverTrigger>
305 <PopoverPopup
306 aria-label="Notice details"
307 tooltipStyle
308 side="top"
309 className="max-w-80 whitespace-normal text-pretty wrap-anywhere"
310 >
311 <ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
312 {children}
313 </ComposerBanner.Scroll>
314 </PopoverPopup>
315 </Popover>
316 ) : null}
317 </span>
318 );
319}
320
245function ComposerBannerStackAlert({321function ComposerBannerStackAlert({
246 item,322 item,
247 attached,323 attached,
...@@ -278,44 +354,9 @@ function ComposerBannerStackAlert({...@@ -278,44 +354,9 @@ function ComposerBannerStackAlert({
278 <ComposerBanner.Content className="whitespace-nowrap">354 <ComposerBanner.Content className="whitespace-nowrap">
279 <span className="min-w-0 truncate font-medium leading-7 sm:leading-6">{item.title}</span>355 <span className="min-w-0 truncate font-medium leading-7 sm:leading-6">{item.title}</span>
280 {item.description ? (356 {item.description ? (
281 <span className={item.compact ? "contents" : "flex min-w-8 flex-1 items-center gap-1"}>357 <NoticeDescription compact={item.compact ?? false}>
282 <span358 {item.description}
283 className={cn(359 </NoticeDescription>
284 "min-w-0 truncate text-muted-foreground",
285 item.compact && "shrink-[9999] @max-[400px]:sr-only",
286 )}
287 >
288 {item.description}
289 </span>
290 <Popover>
291 <PopoverTrigger
292 openOnHover
293 render={
294 <Button
295 size="icon-xs"
296 variant="ghost"
297 aria-label="Show notice details"
298 className={cn(
299 "flex-none text-muted-foreground hover:text-foreground",
300 item.compact && "hidden @max-[400px]:inline-flex",
301 )}
302 />
303 }
304 >
305 <InfoIcon className="size-3.5" />
306 </PopoverTrigger>
307 <PopoverPopup
308 aria-label="Notice details"
309 tooltipStyle
310 side="top"
311 className="max-w-80 whitespace-normal text-pretty wrap-anywhere"
312 >
313 <ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
314 {item.description}
315 </ComposerBanner.Scroll>
316 </PopoverPopup>
317 </Popover>
318 </span>
319 ) : null}360 ) : null}
320 </ComposerBanner.Content>361 </ComposerBanner.Content>
321 {item.actions || item.onDismiss ? (362 {item.actions || item.onDismiss ? (