Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,35 @@ it("keeps only the usage limit attached while an active goal waits in the collap
await expect.element(limit).toBeVisible();
expect(visibleBanners()).toHaveLength(1);
});

it("reveals the full running command only while its title is clipped", async () => {
const command =
"Running: /usr/bin/zsh -lc 'sleep 600 > /tmp/t3-background-command-with-a-long-log-filename-for-truncation.log 2>&1'";
container.style.width = "240px";
await act(async () =>
root.render(
<ComposerBannerStack
items={[
{
id: "background-work",
variant: "default",
priority: "activity",
icon: null,
title: command,
},
]}
/>,
),
);

const details = page.getByRole("button", { name: "Show title details", exact: true });
await expect.element(details).toBeVisible();
await userEvent.hover(details);
const popup = page.getByRole("dialog", { name: "Notice details", exact: true });
await expect.element(popup).toBeVisible();
await expect.element(popup).toHaveTextContent(command);

container.style.width = "4000px";
await expect.element(details).not.toBeInTheDocument();
await expect.element(popup).not.toBeInTheDocument();
});
49 changes: 33 additions & 16 deletions apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,30 +249,38 @@ export function ComposerBannerStack({ className, items, attachedAbove }: Compose
);
}

function NoticeDescription({ children }: { children: ReactNode }) {
const descriptionRef = useRef<HTMLSpanElement>(null);
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);

useLayoutEffect(() => {
const description = descriptionRef.current;
if (!description) return;
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 description would otherwise fit.
// sustain its own overflow after the text would otherwise fit.
const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
const hidden = getComputedStyle(description).position === "absolute";
const hidden = getComputedStyle(text).position === "absolute";
setShowDetails(
hidden ||
[description, ...description.querySelectorAll("*")].some(
[text, ...text.querySelectorAll("*")].some(
(element) => element.scrollWidth > element.clientWidth + recoveredWidth,
),
);
};
measure();
const stopObserving = observeResize(description, measure);
const stopObserving = observeResize(text, measure);
const mutations = new MutationObserver(measure);
mutations.observe(description, { childList: true, subtree: true, characterData: true });
mutations.observe(text, { childList: true, subtree: true, characterData: true });
return () => {
stopObserving();
mutations.disconnect();
Expand All @@ -281,10 +289,7 @@ function NoticeDescription({ children }: { children: ReactNode }) {

return (
<span className="contents">
<span
ref={descriptionRef}
className="w-0 min-w-0 grow truncate text-muted-foreground @max-[400px]:sr-only"
>
<span ref={textRef} className={className}>
{children}
</span>
{showDetails ? (
Expand All @@ -296,7 +301,7 @@ function NoticeDescription({ children }: { children: ReactNode }) {
ref={detailsRef}
size="icon-xs"
variant="ghost-muted"
aria-label="Show notice details"
aria-label={detailsLabel}
className="flex-none"
/>
}
Expand Down Expand Up @@ -353,8 +358,20 @@ function ComposerBannerStackAlert({
{item.icon}
</ComposerBanner.Icon>
<ComposerBanner.Content className="whitespace-nowrap">
<span className="min-w-0 truncate font-medium leading-7 sm:leading-6">{item.title}</span>
{item.description ? <NoticeDescription>{item.description}</NoticeDescription> : null}
<NoticeText
detailsLabel="Show title details"
className="min-w-0 truncate font-medium leading-7 sm:leading-6"
>
{item.title}
</NoticeText>
{item.description ? (
<NoticeText
detailsLabel="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>
Expand Down
Loading