diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index a439cc2aac..ddc9527bed 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -85,9 +85,9 @@ snapshots: channels-taskfeedrow--human-started--light: hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM channels-taskfeedrow--long-prompt--dark: - hash: v1.k4693efd2.b79b9d4cc5eeeb06f77766f05d21fca4997a155912d6d71f23105a6e58cf5fe6.bqN_eRKXFN0qEz1lD1h_3qWDTUpDDUsIjpvtEL8zdko + hash: v1.k4693efd2.876d34660bc267af79d39a971a681ba279a870061dba10b905412112c2a5e4dd.rSw1X068udMs8Arglu_WgX5RL8WZlpAc8_kVONPQhF4 channels-taskfeedrow--long-prompt--light: - hash: v1.k4693efd2.d07cc4df0bf67388e83ff917dc8bbe73d862f10044c2ef201a2f62042f621271.HauxzRuUxumHY1wAtYSqVPdh3nFT8teGBWpYEOGC_FE + hash: v1.k4693efd2.d0d6e4b6bfa257c3f46d991777f72c345437b0be2ee16a182fa925d3ece7dc9e.6PGeOlMxVauJSQia0FAIirzaYio79-I7H4x-9LvEUbw channels-taskfeedrow--no-prompt--dark: hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU channels-taskfeedrow--no-prompt--light: diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx index 6e43636df6..284c79a152 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 = { @@ -23,23 +23,70 @@ const task = { }, } satisfies Task; +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ExpandablePrompt measures how the prompt wraps to decide where to cut and +// whether to show "more". jsdom does no layout, so simulate a 21px line height +// and a scrollHeight that grows with text length (≈20 chars/line). +function mockLayout(charsPerLine: number) { + const realGetComputedStyle = window.getComputedStyle; + vi.spyOn(window, "getComputedStyle").mockImplementation((el, ...rest) => { + const style = realGetComputedStyle(el, ...rest); + return new Proxy(style, { + get(target, prop) { + if (prop === "lineHeight") return "21px"; + const value = Reflect.get(target, prop); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + }); + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( + function (this: HTMLElement) { + return Math.ceil((this.textContent ?? "").length / charsPerLine) * 21; + }, + ); +} + describe("TaskFeedRow", () => { it("expands a truncated prompt", async () => { - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(60); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); + mockLayout(20); 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]", + ) as HTMLElement; + // The visible text is the non-measure child (the measure copy is aria-hidden). + const visible = Array.from(prompt.children).find( + (c) => !c.hasAttribute("aria-hidden"), + ) as HTMLElement; + const more = screen.getByRole("button", { name: "more" }); + // The toggle sits inside the visible prompt text, inline after the ellipsis — + // not on a separate line below. + expect(visible).toContainElement(more); + expect(visible.textContent).toContain("…"); + expect(visible.textContent).not.toContain(task.description); - await user.click(screen.getByRole("button", { name: "more" })); + await user.click(more); - expect(prompt).not.toHaveClass("line-clamp-2"); + expect(visible.textContent).toContain(task.description); expect(screen.getByRole("button", { name: "less" })).toBeInTheDocument(); }); + + it("renders no toggle when the prompt fits", () => { + mockLayout(1000); + render( + + + , + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 0acad53649..e795930ba1 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -418,46 +418,99 @@ function ExpandablePrompt({ children: string; lines: 2 | 4; }) { + // The prompt is truncated by hand — not with -webkit-line-clamp — so the + // "more" toggle can sit inline right after the ellipsis on the last visible + // line, like "...prompt…more". A hidden copy of the full text is measured to + // find how much fits, leaving room for the toggle; the visible body renders + // the cut. Measuring the full text (not the visible, already-cut text) keeps + // the ResizeObserver stable instead of oscillating as content swaps. const observerRef = useRef(null); const [expanded, setExpanded] = useState(false); - const [truncated, setTruncated] = useState(false); + const [cut, setCut] = useState(null); const measureRef = useCallback( - (body: HTMLDivElement | null) => { + (measure: HTMLDivElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; - if (!body || expanded) return; - const measure = () => setTruncated(body.scrollHeight > body.clientHeight); - measure(); - const observer = new ResizeObserver(measure); - observer.observe(body); + if (!measure || expanded) return; + + const compute = () => { + const lineHeight = parseFloat(getComputedStyle(measure).lineHeight); + const maxHeight = lineHeight * lines; + if (measure.scrollHeight <= maxHeight + 0.5) { + setCut(null); + return; + } + // Find the longest prefix that still fits in `lines` once "…more" is + // appended — so the toggle can sit inline right after the ellipsis on the + // last line. We probe by swapping the measure's text node to "prefix…more" + // and reading scrollHeight (no per-line geometry), then restore it so the + // next resize re-measures against the uncut prompt. `children` is the + // source of truth (and a dep below) so a polled prompt update re-measures + // even when its rendered size is unchanged. + const text = measure.firstChild as Text; + const fits = (end: number) => { + text.nodeValue = `${children.slice(0, end).trimEnd()}…more`; + return measure.scrollHeight <= maxHeight + 0.5; + }; + let lo = 0; + let hi = children.length; + let best = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + text.nodeValue = children; + // Even when no full character fits alongside "…more" (best === 0, only at + // extreme narrow widths), still cut so the toggle shows and the prompt + // stays expandable instead of silently clipped. + setCut(`${children.slice(0, best).trimEnd()}…`); + }; + + compute(); + const observer = new ResizeObserver(compute); + observer.observe(measure); observerRef.current = observer; }, - [expanded], + [children, expanded, lines], ); + const truncated = cut !== null; + const displayText = expanded || !truncated ? children : cut; + + const clampClass = lines === 2 ? "max-h-[2lh]" : "max-h-[4lh]"; + return ( - - + - {children} - - {(truncated || expanded) && ( - setExpanded((value) => !value)} - > - {expanded ? "less" : "more"} - - )} - + + {children} + + + + {displayText} + {truncated && ( + setExpanded((value) => !value)} + > + {expanded ? "less" : "more"} + + )} + + ); }