1import { InfoIcon } from "lucide-react";
2import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
3
4import { Button } from "../ui/button";
5import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
6import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
7import { observeResize } from "~/lib/observeResize";
8
9/**
10 * Where a notice sits in the stack. The machine comes first, then what needs you; the
11 * order is fixed so rows never trade places as notices come and go.
12 */
13export type ComposerBannerGroup = "environment" | "attention";
14
15export interface ComposerBannerStackItem {
16 readonly id: string;
17 readonly variant: ComposerBannerVariant;
18 readonly group?: ComposerBannerGroup;
19 readonly icon: ReactNode;
20 readonly title: ReactNode;
21 readonly description?: ReactNode;
22 readonly children?: ReactNode;
23 readonly actions?: ReactNode;
24 readonly dismissLabel?: string;
25 readonly onDismiss?: () => void;
26}
27
28const GROUPS = ["environment", "attention"] as const;
29
30const FADE_IN =
31 "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none";
32
33/**
34 * Notices about sending from this composer, each its own attached card. Consecutive cards join
35 * edge to edge, and the last joins the composer or the drawer below it. Detached cards stand
36 * apart instead, for layouts where the composer must not move when a notice appears.
37 */
38export function ComposerBannerStack({
39 items,
40 detached = false,
41}: {
42 readonly items: ReadonlyArray<ComposerBannerStackItem>;
43 readonly detached?: boolean;
44}) {
45 const ordered = GROUPS.flatMap((group) =>
46 items.filter((item) => (item.group ?? "attention") === group),
47 );
48 if (detached) {
49 return ordered.map((item) => (
50 <div key={item.id} data-composer-banner-stack="true" className={FADE_IN}>
51 <NoticeCard item={item} detached />
52 </div>
53 ));
54 }
55 return ordered.map((item) => (
56 <ComposerBanner.Attachment key={item.id} data-composer-banner-stack="true" className={FADE_IN}>
57 <NoticeCard item={item} />
58 </ComposerBanner.Attachment>
59 ));
60}
61
62const SUBPIXEL_ROUNDING = 0.001;
63
64export function NoticeText({
65 children,
66 className,
67 detailsLabel,
68}: {
69 readonly children: ReactNode;
70 readonly className: string;
71 readonly detailsLabel: string;
72}) {
73 const textRef = useRef<HTMLSpanElement>(null);
74 const detailsRef = useRef<HTMLButtonElement>(null);
75 const [detailsAtWidth, setDetailsAtWidth] = useState<number | null>(null);
76 const showDetails = detailsAtWidth !== null;
77
78 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button
79 useLayoutEffect(() => {
80 const text = textRef.current;
81 if (!text) return;
82 const banner = text.closest("[data-slot=composer-banner]");
83 const range = document.createRange();
84 const measure = () => {
85 // Ignore the space taken by the details button itself so it cannot
86 // sustain its own overflow after the text would otherwise fit.
87 const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
88 const hidden = getComputedStyle(text).position === "absolute";
89 const overflowing =
90 hidden ||
91 [text, ...text.querySelectorAll("*")].some((element) => {
92 // A range spans laid-out content only; scrollWidth also counts positioned layers
93 // such as a link's touch hit area, which overflow without cutting any text.
94 // The two rects can disagree by float rounding (~0.00001px on phones); real
95 // overflow is at least one layout unit, 1/64 of a device pixel.
96 range.selectNodeContents(element);
97 return (
98 range.getBoundingClientRect().width >
99 element.getBoundingClientRect().width + recoveredWidth + SUBPIXEL_ROUNDING
100 );
101 });
102 // The button's width can wrap the banner's actions onto a second row, which gives
103 // the text room and removes the button, which unwraps the row again. Once shown,
104 // the button stays until the banner itself gets wider than where it appeared.
105 const width = banner?.clientWidth ?? text.clientWidth;
106 setDetailsAtWidth((shownAt) =>
107 overflowing
108 ? (shownAt ?? width)
109 : banner && shownAt !== null && width <= shownAt
110 ? shownAt
111 : null,
112 );
113 };
114 measure();
115 const stopObserving = observeResize([text, banner ?? text], measure);
116 let content = text.textContent;
117 const mutations = new MutationObserver(() => {
118 if (text.textContent !== content) {
119 content = text.textContent;
120 setDetailsAtWidth(null);
121 }
122 measure();
123 });
124 mutations.observe(text, { childList: true, subtree: true, characterData: true });
125 return () => {
126 stopObserving();
127 mutations.disconnect();
128 };
129 }, []);
130
131 return (
132 <span className="contents">
133 <span ref={textRef} className={className}>
134 {children}
135 </span>
136 {showDetails ? (
137 <Popover>
138 <PopoverTrigger
139 openOnHover
140 render={
141 <Button
142 ref={detailsRef}
143 size="icon-xs"
144 variant="ghost-muted"
145 aria-label={detailsLabel}
146 className="flex-none"
147 />
148 }
149 >
150 <InfoIcon />
151 </PopoverTrigger>
152 <PopoverPopup
153 aria-label="Notice details"
154 tooltipStyle
155 side="top"
156 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"
157 >
158 <ComposerBanner.Scroll>{children}</ComposerBanner.Scroll>
159 </PopoverPopup>
160 </Popover>
161 ) : null}
162 </span>
163 );
164}
165
166function NoticeCard({
167 item,
168 detached = false,
169}: {
170 readonly item: ComposerBannerStackItem;
171 readonly detached?: boolean;
172}) {
173 return (
174 <ComposerBanner.Root
175 role={item.variant === "error" || item.variant === "warning" ? "alert" : undefined}
176 variant={item.variant}
177 detached={detached}
178 density="comfortable"
179 data-chat-composer-collapsed-controls="true"
180 >
181 <ComposerBanner.Row layout="wrap-actions">
182 <ComposerBanner.Icon className="h-(--composer-banner-icon-column) self-start">
183 {item.icon}
184 </ComposerBanner.Icon>
185 <ComposerBanner.Content className="whitespace-nowrap">
186 <NoticeText
187 detailsLabel={
188 typeof item.title === "string"
189 ? `Show full title: ${item.title}`
190 : "Show title details"
191 }
192 className="min-w-0 truncate font-medium leading-7 sm:leading-6"
193 >
194 {item.title}
195 </NoticeText>
196 {item.description ? (
197 <NoticeText
198 detailsLabel={
199 typeof item.title === "string"
200 ? `Show details: ${item.title}`
201 : "Show notice details"
202 }
203 className="w-0 min-w-0 grow truncate text-muted-foreground @max-[400px]:sr-only"
204 >
205 {item.description}
206 </NoticeText>
207 ) : null}
208 </ComposerBanner.Content>
209 {item.actions || item.onDismiss ? (
210 <ComposerBanner.Actions className="min-w-14 sm:min-w-12">
211 {item.actions}
212 {item.onDismiss ? (
213 <ComposerBanner.Dismiss
214 aria-label={item.dismissLabel ?? "Dismiss warning"}
215 onClick={item.onDismiss}
216 />
217 ) : null}
218 </ComposerBanner.Actions>
219 ) : null}
220 </ComposerBanner.Row>
221 {item.children ? <ComposerBanner.Children>{item.children}</ComposerBanner.Children> : null}
222 </ComposerBanner.Root>
223 );
224}