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