From 1df39be779a3889c0a0e6d472f107ef96336cfae Mon Sep 17 00:00:00 2001 From: edg-r <201840767+edg-r@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:58:43 -0800 Subject: [PATCH 1/2] feat(web): sidebar thread titles truncate in the middle Co-Authored-By: Claude Opus 5.5 --- .../components/sidebar/SidebarThreadRow.tsx | 18 +++++++++++++++--- .../src/components/ui/middle-truncate.test.ts | 11 +++++++++++ apps/web/src/components/ui/middle-truncate.tsx | 10 ++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index fa87252de1457..20f6119011df2 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,12 @@ 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..cb6ca203fb7d6 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -42,10 +42,12 @@ 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} + ) : ( // Keyed so React does not reuse the head node and its imperative clip. @@ -92,14 +94,14 @@ function fitHead(head: HTMLElement) { 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("/"); From c8c3c7421a547d5f95478373285d1514e9b87982 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 03:50:29 +0000 Subject: [PATCH 2/2] fix(web): keep narrow thread titles readable --- .../components/sidebar/SidebarThreadRow.tsx | 1 + .../web/src/components/ui/middle-truncate.tsx | 28 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 20f6119011df2..ee9502c73e0a2 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1746,6 +1746,7 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index cb6ca203fb7d6..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,17 @@ 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.head} {split.tail} @@ -64,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(); @@ -87,7 +102,6 @@ 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` : ""; }