diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index fa87252de1457..ee9502c73e0a2 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1004,6 +1004,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, }); + const titleDirection = textDirection(thread.title); const title = isRenaming ? ( - {thread.title} + {/* The measured cut assumes left-to-right text, so RTL titles keep their end ellipsis. */} + ); const accessibleTitle = isRenaming ? null : {thread.title}; @@ -1736,7 +1743,13 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props thread.titleRegeneration != null && "opacity-55", )} > - {thread.title} + { expect(splitForMiddleTruncate("t3/abcdefghijklmnopqrstuvwxyz")?.tail).toBe("qrstuvwxyz"); }); + it("starts a title's tail on a word", () => { + expect(splitForMiddleTruncate("Debate Weaknesses in Bilateral Agreements")).toEqual({ + head: "Debate Weaknesses in Bilateral ", + tail: "Agreements", + }); + }); + + it("flattens line breaks so a value stays on one line", () => { + expect(splitForMiddleTruncate("first\nsecond line of a label")?.head).not.toContain("\n"); + }); + it("respects an explicit tail length", () => { expect(splitForMiddleTruncate("t3/clone-repo-to-other-machine", 6)?.tail).toBe("achine"); }); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 62dd3c58ecce3..1315e4ac27f5c 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -19,19 +19,20 @@ export function MiddleTruncate({ const split = splitForMiddleTruncate(value, tail); const headRef = useRef(null); const head = split?.head; + const suffix = split?.tail; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver useLayoutEffect(() => { const element = headRef.current; // A new head can keep the same box size, so refit here, not only on resize. - if (!element || head === undefined) return; + if (!element || !element.parentElement || head === undefined || suffix === undefined) return; fitHead(element); - return observeResize(element, () => fitHead(element)); - }, [head]); + return observeResize([element, element.parentElement], () => fitHead(element)); + }, [head, suffix]); return ( span]:[text-box:trim-both_cap_alphabetic] [&>span]:-mb-1 [&>span]:pb-1 [&>span]:overflow-x-clip [&>span]:overflow-y-visible", @@ -42,10 +43,19 @@ export function MiddleTruncate({ {split ? ( <> {/* The tail carries the ellipsis so it sits flush; a CSS ellipsis leaves a ragged gap. */} - + {split.head} - {split.tail} + + {split.tail} + + + {split.head} + {split.tail} + ) : ( // Keyed so React does not reuse the head node and its imperative clip. @@ -62,9 +72,16 @@ export function MiddleTruncate({ // size stays the true available width. function fitHead(head: HTMLElement) { + const container = head.parentElement; + const tail = head.nextElementSibling; + container?.removeAttribute("data-tail-overflow"); const text = head.firstChild; const overflowing = head.scrollWidth > head.clientWidth && text instanceof Text; head.toggleAttribute("data-overflow", overflowing); + if (container && tail instanceof HTMLElement && tail.offsetWidth > container.clientWidth) { + container.setAttribute("data-tail-overflow", ""); + return; + } let slack = 0; if (overflowing) { const range = document.createRange(); @@ -85,21 +102,20 @@ function fitHead(head: HTMLElement) { slack = Math.max(0, head.clientWidth - widthTo(fits)); } head.style.clipPath = slack > 0 ? `inset(-0.25em ${slack}px -0.25em 0)` : ""; - const tail = head.nextElementSibling; if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : ""; } const DEFAULT_TAIL = 10; const MAX_SEGMENT_TAIL = 16; const MIN_WORD_TAIL = 4; -const WORD_BREAK = /[-_./]/; +const WORD_BREAK = /[-_./\s]/; export function splitForMiddleTruncate( value: string, tail?: number, ): { head: string; tail: string } | null { // Split by code point to avoid cutting surrogate pairs into broken glyphs. - const chars = Array.from(value); + const chars = Array.from(value.replace(/[\r\n]+/g, " ")); let keep = tail ?? DEFAULT_TAIL; if (tail === undefined) { const slash = chars.lastIndexOf("/");