Skip to content

Commit 744d66a

Browse files
adboioposthog[bot]
andauthored
fix(channels): keep truncated-prompt toggle inline beside the ellipsis (#3731)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: adboio <23323033+adboio@users.noreply.github.com>
1 parent f377ae9 commit 744d66a

3 files changed

Lines changed: 138 additions & 38 deletions

File tree

apps/code/snapshots.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ snapshots:
8585
channels-taskfeedrow--human-started--light:
8686
hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM
8787
channels-taskfeedrow--long-prompt--dark:
88-
hash: v1.k4693efd2.b79b9d4cc5eeeb06f77766f05d21fca4997a155912d6d71f23105a6e58cf5fe6.bqN_eRKXFN0qEz1lD1h_3qWDTUpDDUsIjpvtEL8zdko
88+
hash: v1.k4693efd2.876d34660bc267af79d39a971a681ba279a870061dba10b905412112c2a5e4dd.rSw1X068udMs8Arglu_WgX5RL8WZlpAc8_kVONPQhF4
8989
channels-taskfeedrow--long-prompt--light:
90-
hash: v1.k4693efd2.d07cc4df0bf67388e83ff917dc8bbe73d862f10044c2ef201a2f62042f621271.HauxzRuUxumHY1wAtYSqVPdh3nFT8teGBWpYEOGC_FE
90+
hash: v1.k4693efd2.d0d6e4b6bfa257c3f46d991777f72c345437b0be2ee16a182fa925d3ece7dc9e.6PGeOlMxVauJSQia0FAIirzaYio79-I7H4x-9LvEUbw
9191
channels-taskfeedrow--no-prompt--dark:
9292
hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU
9393
channels-taskfeedrow--no-prompt--light:

packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Task } from "@posthog/shared/domain-types";
22
import { Theme } from "@radix-ui/themes";
33
import { render, screen } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
5-
import { describe, expect, it, vi } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { TaskFeedRow } from "./ChannelFeedView";
77

88
const task = {
@@ -23,23 +23,70 @@ const task = {
2323
},
2424
} satisfies Task;
2525

26+
afterEach(() => {
27+
vi.restoreAllMocks();
28+
});
29+
30+
// ExpandablePrompt measures how the prompt wraps to decide where to cut and
31+
// whether to show "more". jsdom does no layout, so simulate a 21px line height
32+
// and a scrollHeight that grows with text length (≈20 chars/line).
33+
function mockLayout(charsPerLine: number) {
34+
const realGetComputedStyle = window.getComputedStyle;
35+
vi.spyOn(window, "getComputedStyle").mockImplementation((el, ...rest) => {
36+
const style = realGetComputedStyle(el, ...rest);
37+
return new Proxy(style, {
38+
get(target, prop) {
39+
if (prop === "lineHeight") return "21px";
40+
const value = Reflect.get(target, prop);
41+
return typeof value === "function" ? value.bind(target) : value;
42+
},
43+
});
44+
});
45+
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
46+
function (this: HTMLElement) {
47+
return Math.ceil((this.textContent ?? "").length / charsPerLine) * 21;
48+
},
49+
);
50+
}
51+
2652
describe("TaskFeedRow", () => {
2753
it("expands a truncated prompt", async () => {
28-
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(60);
29-
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(40);
54+
mockLayout(20);
3055
const user = userEvent.setup();
31-
render(
56+
const { container } = render(
3257
<Theme>
3358
<TaskFeedRow task={task} />
3459
</Theme>,
3560
);
3661

37-
const prompt = screen.getByText(task.description);
38-
expect(prompt).toHaveClass("line-clamp-2");
62+
const prompt = container.querySelector(
63+
"[data-slot=thread-item-body]",
64+
) as HTMLElement;
65+
// The visible text is the non-measure child (the measure copy is aria-hidden).
66+
const visible = Array.from(prompt.children).find(
67+
(c) => !c.hasAttribute("aria-hidden"),
68+
) as HTMLElement;
69+
const more = screen.getByRole("button", { name: "more" });
70+
// The toggle sits inside the visible prompt text, inline after the ellipsis —
71+
// not on a separate line below.
72+
expect(visible).toContainElement(more);
73+
expect(visible.textContent).toContain("…");
74+
expect(visible.textContent).not.toContain(task.description);
3975

40-
await user.click(screen.getByRole("button", { name: "more" }));
76+
await user.click(more);
4177

42-
expect(prompt).not.toHaveClass("line-clamp-2");
78+
expect(visible.textContent).toContain(task.description);
4379
expect(screen.getByRole("button", { name: "less" })).toBeInTheDocument();
4480
});
81+
82+
it("renders no toggle when the prompt fits", () => {
83+
mockLayout(1000);
84+
render(
85+
<Theme>
86+
<TaskFeedRow task={task} />
87+
</Theme>,
88+
);
89+
90+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
91+
});
4592
});

packages/ui/src/features/canvas/components/ChannelFeedView.tsx

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -420,46 +420,99 @@ function ExpandablePrompt({
420420
children: string;
421421
lines: 2 | 4;
422422
}) {
423+
// The prompt is truncated by hand — not with -webkit-line-clamp — so the
424+
// "more" toggle can sit inline right after the ellipsis on the last visible
425+
// line, like "...prompt…more". A hidden copy of the full text is measured to
426+
// find how much fits, leaving room for the toggle; the visible body renders
427+
// the cut. Measuring the full text (not the visible, already-cut text) keeps
428+
// the ResizeObserver stable instead of oscillating as content swaps.
423429
const observerRef = useRef<ResizeObserver | null>(null);
424430
const [expanded, setExpanded] = useState(false);
425-
const [truncated, setTruncated] = useState(false);
431+
const [cut, setCut] = useState<string | null>(null);
426432

427433
const measureRef = useCallback(
428-
(body: HTMLDivElement | null) => {
434+
(measure: HTMLDivElement | null) => {
429435
observerRef.current?.disconnect();
430436
observerRef.current = null;
431-
if (!body || expanded) return;
432-
const measure = () => setTruncated(body.scrollHeight > body.clientHeight);
433-
measure();
434-
const observer = new ResizeObserver(measure);
435-
observer.observe(body);
437+
if (!measure || expanded) return;
438+
439+
const compute = () => {
440+
const lineHeight = parseFloat(getComputedStyle(measure).lineHeight);
441+
const maxHeight = lineHeight * lines;
442+
if (measure.scrollHeight <= maxHeight + 0.5) {
443+
setCut(null);
444+
return;
445+
}
446+
// Find the longest prefix that still fits in `lines` once "…more" is
447+
// appended — so the toggle can sit inline right after the ellipsis on the
448+
// last line. We probe by swapping the measure's text node to "prefix…more"
449+
// and reading scrollHeight (no per-line geometry), then restore it so the
450+
// next resize re-measures against the uncut prompt. `children` is the
451+
// source of truth (and a dep below) so a polled prompt update re-measures
452+
// even when its rendered size is unchanged.
453+
const text = measure.firstChild as Text;
454+
const fits = (end: number) => {
455+
text.nodeValue = `${children.slice(0, end).trimEnd()}…more`;
456+
return measure.scrollHeight <= maxHeight + 0.5;
457+
};
458+
let lo = 0;
459+
let hi = children.length;
460+
let best = 0;
461+
while (lo <= hi) {
462+
const mid = (lo + hi) >> 1;
463+
if (fits(mid)) {
464+
best = mid;
465+
lo = mid + 1;
466+
} else {
467+
hi = mid - 1;
468+
}
469+
}
470+
text.nodeValue = children;
471+
// Even when no full character fits alongside "…more" (best === 0, only at
472+
// extreme narrow widths), still cut so the toggle shows and the prompt
473+
// stays expandable instead of silently clipped.
474+
setCut(`${children.slice(0, best).trimEnd()}…`);
475+
};
476+
477+
compute();
478+
const observer = new ResizeObserver(compute);
479+
observer.observe(measure);
436480
observerRef.current = observer;
437481
},
438-
[expanded],
482+
[children, expanded, lines],
439483
);
440484

485+
const truncated = cut !== null;
486+
const displayText = expanded || !truncated ? children : cut;
487+
488+
const clampClass = lines === 2 ? "max-h-[2lh]" : "max-h-[4lh]";
489+
441490
return (
442-
<div>
443-
<ThreadItemBody
444-
ref={measureRef}
445-
className={cn(
446-
"wrap-break-word whitespace-pre-wrap",
447-
!expanded && (lines === 2 ? "line-clamp-2" : "line-clamp-4"),
448-
)}
491+
<ThreadItemBody className="wrap-break-word relative overflow-hidden whitespace-pre-line">
492+
<div
493+
aria-hidden
494+
className="pointer-events-none invisible absolute top-0 right-0 left-0"
449495
>
450-
{children}
451-
</ThreadItemBody>
452-
{(truncated || expanded) && (
453-
<button
454-
type="button"
455-
aria-expanded={expanded}
456-
className="text-muted-foreground text-xs underline underline-offset-2 hover:text-foreground"
457-
onClick={() => setExpanded((value) => !value)}
458-
>
459-
{expanded ? "less" : "more"}
460-
</button>
461-
)}
462-
</div>
496+
<div ref={measureRef} className="wrap-break-word whitespace-pre-line">
497+
{children}
498+
</div>
499+
</div>
500+
<div
501+
className={cn(!expanded && clampClass, !expanded && "overflow-hidden")}
502+
>
503+
{displayText}
504+
{truncated && (
505+
<button
506+
type="button"
507+
aria-expanded={expanded}
508+
className="pl-1 text-muted-foreground text-xs underline underline-offset-2 hover:text-foreground"
509+
onClick={() => setExpanded((value) => !value)}
510+
>
511+
{expanded ? "less" : "more"}
512+
</button>
513+
)}
514+
</div>
515+
</ThreadItemBody>
463516
);
464517
}
465518

0 commit comments

Comments
 (0)