1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { ChevronDownIcon, XIcon } from "lucide-react";
4import type { ComponentProps } from "react";
5
6import { cn } from "~/lib/utils";
7import { Button } from "../ui/button";
8import { ScrollArea } from "../ui/scroll-area";
9
10export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
11
12const surfaceColors = cn(
13 "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]",
14 "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--surface-raised))]",
15 "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]",
16);
17
18const neutralOutline = cn(
19 "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]",
20 "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]",
21 "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]",
22 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]",
23 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]",
24);
25
26const variantColors: Record<ComposerBannerVariant, string> = {
27 default: neutralOutline,
28 error:
29 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]",
30 info: neutralOutline,
31 success: neutralOutline,
32 warning:
33 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]",
34};
35
36/** Shared glass and attachment seam, also used by the command menu without banner row padding. */
37function Surface({
38 placement = "attached",
39 variant = "default",
40 className,
41 ...props
42}: ComponentProps<"div"> & {
43 placement?: "attached" | "floating";
44 variant?: ComposerBannerVariant;
45}) {
46 return (
47 <div
48 data-composer-banner-surface={placement}
49 data-variant={variant}
50 className={cn(
51 surfaceColors,
52 "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",
53 variantColors[variant],
54 // The mask cut-off (1rem) bleeds one pixel past the seam (1rem + 1px): Chromium
55 // drops the last device-pixel row of a filtered backdrop when the cut-off lands
56 // off the device-pixel grid, and the composer's surface starts exactly there.
57 // The composer's own glass covers the extra row, so the overlap never shows.
58 placement === "attached"
59 ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-2xl before:mask-t-from-transparent before:mask-t-from-4 before:mask-t-to-black before:mask-t-to-4"
60 : "[--chat-composer-attachment-overlap:0px] before:rounded-2xl",
61 "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)",
62 "before:bg-(--chat-composer-attached-surface)/(--glass-opacity) before:bg-linear-to-b before:from-(--chat-composer-attached-tint) before:to-(--chat-composer-attached-tint) before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
63 "before:shadow-composer dark:before:shadow-composer-dark",
64 "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-composer-seam-above",
65 "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)",
66 className,
67 )}
68 {...props}
69 />
70 );
71}
72
73// A peeking notice uses the first hidden notice's severity, never the attached row's.
74const peekBorder: Record<ComposerBannerVariant, string> = {
75 default: "border-(--chat-composer-attached-outline)",
76 error: "border-destructive/24",
77 info: "border-(--chat-composer-attached-outline)",
78 success: "border-(--chat-composer-attached-outline)",
79 warning: "border-warning/24",
80};
81
82function Peek({
83 className,
84 variant = "default",
85 ...props
86}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) {
87 return (
88 <button
89 type="button"
90 data-slot="composer-banner-peek"
91 className={cn(
92 surfaceColors,
93 neutralOutline,
94 "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-md",
95 "bg-(--chat-composer-attached-surface)/(--glass-opacity) backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)",
96 "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:bg-(--chat-composer-attached-surface)",
97 "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring",
98 peekBorder[variant],
99 className,
100 )}
101 {...props}
102 />
103 );
104}
105
106function Attachment({ className, ...props }: ComponentProps<"div">) {
107 return (
108 <div
109 data-slot="composer-banner-attachment"
110 className={cn(
111 "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]",
112 // Adjacent attachments share their outline, including notices outside the form.
113 "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0",
114 "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0",
115 className,
116 )}
117 {...props}
118 />
119 );
120}
121
122function Dock({ className, ...props }: ComponentProps<"div">) {
123 return (
124 <Attachment
125 className={cn(
126 "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden",
127 className,
128 )}
129 {...props}
130 />
131 );
132}
133
134/** Attachments share a column while neighboring tabs keep their own surface. */
135function Column({ className, ...props }: ComponentProps<"div">) {
136 return (
137 <div
138 className={cn(
139 "flex min-w-0 flex-1 flex-col empty:hidden",
140 "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0",
141 className,
142 )}
143 {...props}
144 />
145 );
146}
147
148function Root({
149 className,
150 density = "default",
151 placement = "attached",
152 variant = "default",
153 width = "fill",
154 ...props
155}: ComponentProps<"div"> & {
156 density?: "default" | "comfortable" | "spacious";
157 placement?: "attached" | "floating";
158 variant?: ComposerBannerVariant;
159 width?: "fill" | "content";
160}) {
161 return (
162 <Surface
163 className={cn(
164 "min-w-0 px-1 py-(--composer-banner-padding-block) after:block after:h-(--chat-composer-attachment-overlap) text-xs/4 [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]",
165 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",
166 density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]",
167 width === "content" ? "w-fit max-w-full flex-none" : "@container",
168 // The first row's last action sits in the banner's corner: its outer corner follows the
169 // banner's curve (2xl minus the 1 spacing inset), and floating banners curve below too.
170 // Actions may also arrive grouped in one wrapper element (usage-limit recovery).
171 "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl",
172 "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-se-xl",
173 "[&>:first-child>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl",
174 placement === "floating" && [
175 "[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-ee-xl",
176 "[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-ee-xl",
177 ],
178 className,
179 )}
180 data-slot="composer-banner"
181 placement={placement}
182 data-composer-banner-width={width}
183 variant={variant}
184 {...props}
185 />
186 );
187}
188
189/** The same row can be a status, a list item, or an entire disclosure button. */
190function Row({
191 className,
192 render,
193 layout = "inline",
194 ...props
195}: useRender.ComponentProps<"div"> & {
196 layout?: "inline" | "wrap-actions" | "approval";
197}) {
198 const rowProps = {
199 className: cn(
200 "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start",
201 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
202 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-md [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
203 // Actions share the title row while the title fits and move below it otherwise.
204 // The content's max width keeps it beside the icon, so a long title truncates instead of wrapping.
205 layout === "wrap-actions" &&
206 "flex flex-wrap *:data-[slot=composer-banner-content]:max-w-[calc(100%-var(--composer-banner-icon-column)-var(--spacing))] *:data-[slot=composer-banner-content]:grow",
207 layout === "approval" && "items-start gap-x-2 gap-y-3",
208 className,
209 ),
210 "data-composer-banner-row": "true",
211 "data-composer-banner-layout": layout,
212 };
213 return useRender({
214 defaultTagName: "div",
215 render,
216 props: mergeProps<"div">(rowProps, props),
217 });
218}
219
220function Icon({ className, ...props }: ComponentProps<"span">) {
221 return (
222 <span
223 aria-hidden
224 data-slot="composer-banner-icon"
225 className={cn(
226 "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3",
227 "group-data-[composer-banner-layout=approval]/banner-row:pt-0.5 group-data-[composer-banner-layout=approval]/banner-row:text-warning group-data-[composer-banner-layout=approval]/banner-row:[&>svg]:size-4",
228 className,
229 )}
230 {...props}
231 />
232 );
233}
234
235function Content({ className, ...props }: ComponentProps<"span">) {
236 return (
237 <span
238 data-slot="composer-banner-content"
239 className={cn(
240 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
241 "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4",
242 "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5",
243 "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5",
244 className,
245 )}
246 {...props}
247 />
248 );
249}
250
251function Separator() {
252 return (
253 <span
254 aria-hidden
255 data-slot="composer-banner-separator"
256 className="mx-1 inline-block flex-none text-muted-foreground/40"
257 >
258 ·
259 </span>
260 );
261}
262
263function Actions({ className, ...props }: ComponentProps<"span">) {
264 return (
265 <span
266 data-slot="composer-banner-actions"
267 className={cn(
268 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
269 "group-data-[composer-banner-layout=approval]/banner-row:self-center group-data-[composer-banner-layout=approval]/banner-row:gap-1.5 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-start-2 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:row-start-2",
270 "group-data-[composer-banner-layout=wrap-actions]/banner-row:ms-auto",
271 className,
272 )}
273 {...props}
274 />
275 );
276}
277
278/** Child rows keep their parent's columns and begin immediately after its header. */
279function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
280 return useRender({
281 defaultTagName: "div",
282 render,
283 props: mergeProps<"div">(
284 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
285 props,
286 ),
287 });
288}
289
290/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
291function Scroll({ className, children, ...props }: ComponentProps<typeof ScrollArea>) {
292 return (
293 <ScrollArea
294 radius="none"
295 scrollFade
296 className={cn("h-auto max-h-[min(24rem,40dvh)]", className)}
297 {...props}
298 >
299 {/* Clears the overlay scrollbar only once there is something to scroll. */}
300 <div className="[[data-has-overflow-y]>&]:pe-2">{children}</div>
301 </ScrollArea>
302 );
303}
304
305function Count({ className, ...props }: ComponentProps<"span">) {
306 return (
307 <span
308 className={cn(
309 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
310 className,
311 )}
312 {...props}
313 />
314 );
315}
316
317function Body({ className, ...props }: ComponentProps<"div">) {
318 return <div className={cn("min-w-0 ps-8 sm:ps-7", className)} {...props} />;
319}
320
321function Dot({ className, ...props }: ComponentProps<"span">) {
322 return (
323 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
324 );
325}
326
327// Decorative: the row itself is the control, so this only matches Dismiss's box.
328function ToggleIcon({ expanded }: { expanded: boolean }) {
329 return (
330 <Button
331 render={<span aria-hidden />}
332 size="icon-xs"
333 variant="ghost"
334 tabIndex={-1}
335 className="pointer-events-none"
336 >
337 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
338 </Button>
339 );
340}
341
342function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
343 return (
344 <Button size="icon-xs" variant="ghost" className={className} {...props}>
345 {children ?? <XIcon className="size-3.5" />}
346 </Button>
347 );
348}
349
350export const ComposerBanner = {
351 Surface,
352 Peek,
353 Attachment,
354 Dock,
355 Column,
356 Root,
357 Row,
358 Icon,
359 Content,
360 Separator,
361 Actions,
362 Children,
363 Scroll,
364 Count,
365 Body,
366 Dot,
367 ToggleIcon,
368 Dismiss,
369};