apps/web/src/components/chat/ComposerBannerStack.tsx
import { InfoIcon } from "lucide-react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
import { observeResize } from "~/lib/observeResize";
/**
* Where a notice sits above the composer. The machine comes first, then what needs you; the
* order is fixed so rows never trade places as notices come and go.
*/
export type ComposerBannerGroup = "environment" | "attention";
export interface ComposerBannerStackItem {
readonly id: string;
readonly variant: ComposerBannerVariant;
readonly group?: ComposerBannerGroup;
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly children?: ReactNode;
readonly actions?: ReactNode;
readonly dismissLabel?: string;
readonly onDismiss?: () => void;
}
const GROUPS = ["environment", "attention"] as const;
/**
* Notices about sending from this composer, each its own attached card. Consecutive cards join
* edge to edge, and the last joins the composer or the drawer below it.
*/
export function ComposerBannerStack({
items,
}: {
readonly items: ReadonlyArray<ComposerBannerStackItem>;
}) {
return GROUPS.flatMap((group) =>
items.filter((item) => (item.group ?? "attention") === group),
).map((item) => (
<ComposerBanner.Attachment
key={item.id}
data-composer-banner-stack="true"
className="transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"
>
<NoticeCard item={item} />
</ComposerBanner.Attachment>
));
}
export function NoticeText({
children,
className,
detailsLabel,
}: {
readonly children: ReactNode;
readonly className: string;
readonly detailsLabel: string;
}) {
const textRef = useRef<HTMLSpanElement>(null);
const detailsRef = useRef<HTMLButtonElement>(null);
const [showDetails, setShowDetails] = useState(false);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const measure = () => {
// Ignore the space taken by the details button itself so it cannot
// sustain its own overflow after the text would otherwise fit.
const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
const hidden = getComputedStyle(text).position === "absolute";
setShowDetails(
hidden ||
[text, ...text.querySelectorAll("*")].some(
(element) => element.scrollWidth > element.clientWidth + recoveredWidth,
),
);
};
measure();
const stopObserving = observeResize(text, measure);
const mutations = new MutationObserver(measure);
mutations.observe(text, { childList: true, subtree: true, characterData: true });
return () => {
stopObserving();
mutations.disconnect();
};
}, []);
return (
<span className="contents">
<span ref={textRef} className={className}>
{children}
</span>
{showDetails ? (
<Popover>
<PopoverTrigger
openOnHover
render={
<Button
ref={detailsRef}
size="icon-xs"
variant="ghost-muted"
aria-label={detailsLabel}
className="flex-none"
/>
}
>
<InfoIcon />
</PopoverTrigger>
<PopoverPopup
aria-label="Notice details"
tooltipStyle
side="top"
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"
>
<ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
{children}
</ComposerBanner.Scroll>
</PopoverPopup>
</Popover>
) : null}
</span>
);
}
function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) {
return (
<ComposerBanner.Root
role={item.variant === "error" || item.variant === "warning" ? "alert" : undefined}
variant={item.variant}
density="comfortable"
data-chat-composer-collapsed-controls="true"
>
<ComposerBanner.Row layout="wrap-actions">
<ComposerBanner.Icon className="h-(--composer-banner-icon-column) self-start">
{item.icon}
</ComposerBanner.Icon>
<ComposerBanner.Content className="whitespace-nowrap">
<NoticeText
detailsLabel={
typeof item.title === "string"
? `Show full title: ${item.title}`
: "Show title details"
}
className="min-w-0 truncate font-medium leading-7 sm:leading-6"
>
{item.title}
</NoticeText>
{item.description ? (
<NoticeText
detailsLabel={
typeof item.title === "string"
? `Show details: ${item.title}`
: "Show notice details"
}
className="w-0 min-w-0 grow truncate text-muted-foreground @max-[400px]:sr-only"
>
{item.description}
</NoticeText>
) : null}
</ComposerBanner.Content>
{item.actions || item.onDismiss ? (
<ComposerBanner.Actions>
{item.actions}
{item.onDismiss ? (
<ComposerBanner.Dismiss
aria-label={item.dismissLabel ?? "Dismiss warning"}
onClick={item.onDismiss}
/>
) : null}
</ComposerBanner.Actions>
) : null}
</ComposerBanner.Row>
{item.children ? <ComposerBanner.Children>{item.children}</ComposerBanner.Children> : null}
</ComposerBanner.Root>
);
}