diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx index 6e43636df6..fd7842441b 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx @@ -2,7 +2,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { TaskFeedRow } from "./ChannelFeedView"; const task = { @@ -24,22 +24,60 @@ const task = { } satisfies Task; describe("TaskFeedRow", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("expands a truncated prompt", async () => { - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(60); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( + function (this: HTMLElement) { + return (this.textContent?.length ?? 0) > 35 ? 60 : 20; + }, + ); vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); const user = userEvent.setup(); - render( + const { container } = render( , ); - const prompt = screen.getByText(task.description); - expect(prompt).toHaveClass("line-clamp-2"); + const prompt = container.querySelector( + '[data-slot="thread-item-body"]:not([aria-hidden])', + ); + const more = screen.getByRole("button", { name: "more" }); + expect(prompt).toHaveTextContent(/\.\.\. more$/); + expect(prompt).toContainElement(more); - await user.click(screen.getByRole("button", { name: "more" })); + await user.click(more); - expect(prompt).not.toHaveClass("line-clamp-2"); + expect(prompt).toHaveTextContent(task.description); expect(screen.getByRole("button", { name: "less" })).toBeInTheDocument(); }); + + it("collapses newlines and whitespace before truncating", () => { + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( + function (this: HTMLElement) { + return (this.textContent?.length ?? 0) > 35 ? 60 : 20; + }, + ); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); + const multilineTask = { + ...task, + description: + "truncation was recently added to message prompts, but\n\nthere is more text after it", + }; + + const { container } = render( + + + , + ); + + const prompt = container.querySelector( + '[data-slot="thread-item-body"]:not([aria-hidden])', + ); + expect(prompt?.textContent).not.toContain("\n"); + expect(prompt).toHaveTextContent(/[^ ]\.\.\. more$/); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 026026b0a9..7a2662024f 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -422,42 +422,80 @@ function ExpandablePrompt({ }) { const observerRef = useRef(null); const [expanded, setExpanded] = useState(false); - const [truncated, setTruncated] = useState(false); + const [truncatedText, setTruncatedText] = useState(null); + const measureTextRef = useRef(null); + const measureMoreRef = useRef(null); + const collapsedText = children.replace(/\s+/g, " ").trim(); const measureRef = useCallback( (body: HTMLDivElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; if (!body || expanded) return; - const measure = () => setTruncated(body.scrollHeight > body.clientHeight); + const measure = () => { + const text = measureTextRef.current; + const more = measureMoreRef.current; + if (!text || !more) return; + + more.hidden = true; + text.textContent = collapsedText; + if (body.scrollHeight <= body.clientHeight) { + setTruncatedText(null); + return; + } + + more.hidden = false; + let low = 0; + let high = collapsedText.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + text.textContent = `${collapsedText.slice(0, middle).trimEnd()}... `; + if (body.scrollHeight <= body.clientHeight) { + low = middle; + } else { + high = middle - 1; + } + } + setTruncatedText(collapsedText.slice(0, low).trimEnd()); + }; measure(); const observer = new ResizeObserver(measure); - observer.observe(body); + observer.observe(body.parentElement ?? body); observerRef.current = observer; }, - [expanded], + [collapsedText, expanded], ); return ( -
- + + {expanded || truncatedText === null ? children : truncatedText} + {truncatedText !== null && !expanded && "... "} + {truncatedText !== null && ( + )} - > - {children} - {(truncated || expanded) && ( - + {collapsedText} + + more + + )}
);