From e70f3e283740a8b2e6be03759036023b092357ae Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 22 Jul 2026 18:04:08 -0400 Subject: [PATCH 1/4] fix(channels): keep truncated-prompt toggle inline beside the ellipsis The channel feed's ExpandablePrompt rendered the more/less toggle as a sibling below the clamped body, so it always landed on its own line, and whitespace-pre-wrap left the -webkit-line-clamp ellipsis with a leading space (or on a blank line) for multi-line prompts. Move the toggle inside the clamped body, pinned to the bottom-right with a solid background so it sits beside the ellipsis on the last visible line, and switch whitespace to pre-line so trailing spaces collapse against the ellipsis while newlines still break. Generated-By: PostHog Code Task-Id: 21f31a04-8014-4b94-bc8d-2cbc012a70ef --- .../components/ChannelFeedView.test.tsx | 30 ++++++++++++++++--- .../canvas/components/ChannelFeedView.tsx | 30 +++++++++++-------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx index 6e43636df6..b4bbe905a6 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,45 @@ const task = { }, } satisfies Task; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("TaskFeedRow", () => { it("expands a truncated prompt", async () => { vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(60); vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); const user = userEvent.setup(); - render( + const { container } = render( , ); - const prompt = screen.getByText(task.description); + const prompt = container.querySelector( + "[data-slot=thread-item-body]", + ) as HTMLElement; expect(prompt).toHaveClass("line-clamp-2"); + const more = screen.getByRole("button", { name: "more" }); + // The toggle lives inside the clamped prompt, beside the ellipsis — not as a + // sibling pushed onto its own line. + expect(prompt).toContainElement(more); - await user.click(screen.getByRole("button", { name: "more" })); + await user.click(more); expect(prompt).not.toHaveClass("line-clamp-2"); expect(screen.getByRole("button", { name: "less" })).toBeInTheDocument(); }); + + it("renders no toggle when the prompt fits", () => { + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(20); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); + 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..8a5b7d0633 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -436,28 +436,32 @@ function ExpandablePrompt({ [expanded], ); + // pre-line (not pre-wrap) collapses the runs of spaces that -webkit-line-clamp + // would otherwise leave in front of its ellipsis, while still breaking on the + // prompt's newlines. The toggle is pinned inside the clamp box at the + // bottom-right so it sits beside that ellipsis instead of on its own line. + const clampClass = lines === 2 ? "line-clamp-2" : "line-clamp-4"; + return ( -
- - {children} - - {(truncated || expanded) && ( + + {children} + {truncated && ( )} -
+ ); } From f7751de47daa4199742efc1b08b13c7177ffb81e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 22 Jul 2026 18:27:21 -0400 Subject: [PATCH 2/4] fix(channels): place more toggle inline after the ellipsis The previous attempt pinned the toggle to the bottom-right corner of the clamped block, so it sat at the right edge even when the last line was short, not inline with the ellipsis. Truncate the prompt by hand (no -webkit-line-clamp): measure a hidden copy of the full text and binary-search the longest prefix that still fits in N lines once "...more" is appended, then render that prefix + an inline "more" toggle. The toggle now flows right after the ellipsis on the last visible line, like "...prompt...more". Generated-By: PostHog Code Task-Id: 21f31a04-8014-4b94-bc8d-2cbc012a70ef --- .../components/ChannelFeedView.test.tsx | 43 +++++-- .../canvas/components/ChannelFeedView.tsx | 107 +++++++++++++----- 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx index b4bbe905a6..284c79a152 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx @@ -27,10 +27,31 @@ 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(); const { container } = render( @@ -41,21 +62,25 @@ describe("TaskFeedRow", () => { const prompt = container.querySelector( "[data-slot=thread-item-body]", ) as HTMLElement; - expect(prompt).toHaveClass("line-clamp-2"); + // 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 lives inside the clamped prompt, beside the ellipsis — not as a - // sibling pushed onto its own line. - expect(prompt).toContainElement(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(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", () => { - vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(20); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40); + mockLayout(1000); render( diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 8a5b7d0633..1449fffb7a 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -418,49 +418,94 @@ 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 to "prefix…more" and + // reading scrollHeight (no per-line geometry), then restore the full text + // so the next resize re-measures against the uncut prompt. + const text = measure.firstChild as Text; + const original = text.nodeValue ?? ""; + const fits = (end: number) => { + text.nodeValue = `${original.slice(0, end).trimEnd()}…more`; + return measure.scrollHeight <= maxHeight + 0.5; + }; + let lo = 0; + let hi = original.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 = original; + setCut(best > 0 ? `${original.slice(0, best).trimEnd()}…` : null); + }; + + compute(); + const observer = new ResizeObserver(compute); + observer.observe(measure); observerRef.current = observer; }, - [expanded], + [expanded, lines], ); - // pre-line (not pre-wrap) collapses the runs of spaces that -webkit-line-clamp - // would otherwise leave in front of its ellipsis, while still breaking on the - // prompt's newlines. The toggle is pinned inside the clamp box at the - // bottom-right so it sits beside that ellipsis instead of on its own line. - const clampClass = lines === 2 ? "line-clamp-2" : "line-clamp-4"; + const truncated = cut !== null; + const displayText = expanded || !truncated ? children : cut; + + const clampClass = lines === 2 ? "max-h-[2lh]" : "max-h-[4lh]"; return ( - - {children} - {truncated && ( - - )} + +
+
+ {children} +
+
+
+ {displayText} + {truncated && ( + + )} +
); } From 7439aa513270c8583d6403a7e414f0974b37b8a0 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:50:33 +0000 Subject: [PATCH 3/4] chore(visual): update storybook baselines 2 updated Run: ccb06f1b-36a4-4dcb-9603-16b7bde1b17f Co-authored-by: adboio <23323033+adboio@users.noreply.github.com> --- apps/code/snapshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From 74b9711750e04e61ac56b86f835df4a6080353b2 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 22 Jul 2026 18:58:43 -0400 Subject: [PATCH 4/4] fix(channels): re-measure on prompt update and always show toggle when overflowing Address Greptile review comments on ExpandablePrompt: - "Prompt Updates Keep Stale Cut": use `children` (the prop) as the source of truth for the measure text and add it to the callback-ref deps, so a polled prompt update re-runs the measure even when the rendered size is unchanged (ResizeObserver alone would miss same-size text swaps). - "Zero-Fit State Hides Toggle": always cut when the prompt overflows, even when no full character fits alongside "...more" (extreme narrow widths), so the toggle still renders and the prompt stays expandable instead of being silently clipped with no way to expand. The third comment ("Probe Omits Real Button Width") is a false positive: the text-xs button renders narrower than the body-font probe text, so the probe conservatively over-reserves room and the button never wraps. Generated-By: PostHog Code Task-Id: 21f31a04-8014-4b94-bc8d-2cbc012a70ef --- .../canvas/components/ChannelFeedView.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 1449fffb7a..e795930ba1 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -443,17 +443,18 @@ function ExpandablePrompt({ } // 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 to "prefix…more" and - // reading scrollHeight (no per-line geometry), then restore the full text - // so the next resize re-measures against the uncut prompt. + // 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 original = text.nodeValue ?? ""; const fits = (end: number) => { - text.nodeValue = `${original.slice(0, end).trimEnd()}…more`; + text.nodeValue = `${children.slice(0, end).trimEnd()}…more`; return measure.scrollHeight <= maxHeight + 0.5; }; let lo = 0; - let hi = original.length; + let hi = children.length; let best = 0; while (lo <= hi) { const mid = (lo + hi) >> 1; @@ -464,8 +465,11 @@ function ExpandablePrompt({ hi = mid - 1; } } - text.nodeValue = original; - setCut(best > 0 ? `${original.slice(0, best).trimEnd()}…` : null); + 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(); @@ -473,7 +477,7 @@ function ExpandablePrompt({ observer.observe(measure); observerRef.current = observer; }, - [expanded, lines], + [children, expanded, lines], ); const truncated = cut !== null;