diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d2e9a649df10c56883e0db69fbe9a7664740e45d
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx
@@ -0,0 +1,103 @@
+import { cloneElement, type ReactElement, type ReactNode } from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, expect, it, vi } from "vite-plus/test";
+
+import { ComposerBannerStack } from "./ComposerBannerStack";
+
+vi.mock("../ui/popover", () => ({
+ Popover: "popover",
+ PopoverTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
+ cloneElement(render, {}, children),
+ PopoverPopup: "popup",
+}));
+vi.mock("../ui/button", () => ({ Button: "button" }));
+vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
+
+let renderer: ReactTestRenderer;
+afterEach(async () => {
+ if (renderer) await act(() => renderer.unmount());
+ vi.unstubAllGlobals();
+});
+
+it("only offers notice details when the description cannot fit", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ let resize = () => {};
+ let mutate = () => {};
+ vi.stubGlobal(
+ "MutationObserver",
+ class {
+ constructor(callback: () => void) {
+ mutate = callback;
+ }
+ observe() {}
+ disconnect() {}
+ },
+ );
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(callback: () => void) {
+ resize = callback;
+ }
+ observe() {}
+ disconnect() {}
+ },
+ );
+ let position = "static";
+ vi.stubGlobal("getComputedStyle", () => ({ position }));
+ let availableWidth = 200;
+ const nested = { clientWidth: 100, scrollWidth: 80 };
+ const text = {
+ querySelectorAll: () => [nested],
+ get clientWidth() {
+ return (
+ availableWidth -
+ (renderer?.root.findAllByProps({ "aria-label": "Show notice details" }).length ? 28 : 0)
+ );
+ },
+ scrollWidth: 80,
+ };
+ await act(() => {
+ renderer = create(
+ ,
+ {
+ createNodeMock: (element) =>
+ element.type === "span" ? text : element.type === "button" ? { offsetWidth: 24 } : null,
+ },
+ );
+ });
+ const details = () => renderer.root.findAllByProps({ "aria-label": "Show notice details" });
+ expect(details()).toHaveLength(0);
+ text.scrollWidth = 300;
+ await act(() => resize());
+ expect(details()).toHaveLength(1);
+ // It fits without the icon: the icon must not keep its own overflow alive.
+ availableWidth = 308;
+ await act(() => resize());
+ expect(details()).toHaveLength(0);
+ text.scrollWidth = 80;
+ await act(() => resize());
+ expect(details()).toHaveLength(0);
+ nested.scrollWidth = 500;
+ await act(() => mutate());
+ expect(details()).toHaveLength(1);
+ nested.scrollWidth = 80;
+ await act(() => mutate());
+ expect(details()).toHaveLength(0);
+ position = "absolute";
+ await act(() => resize());
+ expect(details()).toHaveLength(1);
+ position = "static";
+ await act(() => resize());
+ expect(details()).toHaveLength(0);
+});
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index b47790b960390cefd3b199de14b4e3cfea04da29..ec4b442593211ce2f22eb685df84bb479cf1190e 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -242,6 +242,82 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
);
}
+/** Keep full descriptions reachable only when their inline copy is clipped. */
+function NoticeDescription({ children, compact }: { children: ReactNode; compact?: boolean }) {
+ const descriptionRef = useRef(null);
+ const detailsRef = useRef(null);
+ const [showDetails, setShowDetails] = useState(false);
+
+ useLayoutEffect(() => {
+ const description = descriptionRef.current;
+ if (!description) return;
+ const measure = () => {
+ // Ignore the space taken by the details button itself so it cannot
+ // sustain its own overflow after the description would otherwise fit.
+ const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
+ const hidden = getComputedStyle(description).position === "absolute";
+ setShowDetails(
+ hidden ||
+ [description, ...description.querySelectorAll("*")].some(
+ (element) => element.scrollWidth > element.clientWidth + recoveredWidth,
+ ),
+ );
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(description);
+ // A child can reveal new text without resizing its clipped box.
+ const mutations = new MutationObserver(measure);
+ mutations.observe(description, { childList: true, subtree: true, characterData: true });
+ return () => {
+ observer.disconnect();
+ mutations.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+ {children}
+
+ {showDetails ? (
+
+
+ }
+ >
+
+
+
+
+ {children}
+
+
+
+ ) : null}
+
+ );
+}
+
function ComposerBannerStackAlert({
item,
attached,
@@ -278,44 +354,9 @@ function ComposerBannerStackAlert({
{item.title}
{item.description ? (
-
-
- {item.description}
-
-
-
- }
- >
-
-
-
-
- {item.description}
-
-
-
-
+
+ {item.description}
+
) : null}
{item.actions || item.onDismiss ? (