From d2a7e7e65064f5f4722d4d7ef3e332ee1a67b898 Mon Sep 17 00:00:00 2001 From: Artyom Guryanov Date: Thu, 22 Oct 2026 17:07:14 +0400 Subject: [PATCH 1/5] fix(web): branch labels keep the space before a middle-truncated name The new-worktree branch trigger rendered "Fromnew-design". MiddleTruncate splits the label into head and tail spans that are flex items, so a space at the cut was collapsed and never drawn. Both spans now use whitespace-pre so the boundary space renders. The head swaps `truncate` for its overflow-hidden/text-ellipsis parts, because `truncate` sets white-space: nowrap and would fight whitespace-pre. Fixes #23625 Co-Authored-By: Claude Opus 5.5 --- .../components/ui/middle-truncate.test.tsx | 26 +++++++++++++++++++ .../web/src/components/ui/middle-truncate.tsx | 5 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ui/middle-truncate.test.tsx diff --git a/apps/web/src/components/ui/middle-truncate.test.tsx b/apps/web/src/components/ui/middle-truncate.test.tsx new file mode 100644 index 000000000000..7529e73d4508 --- /dev/null +++ b/apps/web/src/components/ui/middle-truncate.test.tsx @@ -0,0 +1,26 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { MiddleTruncate, splitForMiddleTruncate } from "./middle-truncate"; + +describe("splitForMiddleTruncate", () => { + it("cuts a branch label exactly at the space before the name", () => { + expect(splitForMiddleTruncate("From new-design")).toEqual({ + head: "From ", + tail: "new-design", + }); + }); +}); + +describe("MiddleTruncate", () => { + it("renders head and tail with preserved whitespace so the boundary space survives", () => { + const html = renderToStaticMarkup(); + const spans = [...html.matchAll(/([^<]*)<\/span>/g)]; + expect(spans.map((match) => match[2])).toEqual(["From ", "new-design"]); + for (const match of spans) { + expect(match[1]).toContain("whitespace-pre"); + expect(match[1]).not.toContain("truncate"); + } + expect(spans[0]?.[1]).toContain("text-ellipsis"); + }); +}); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 3c7b9c85a63f..77a8e77d0f7c 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -29,8 +29,9 @@ export function MiddleTruncate({ > {split ? ( <> - {split.head} - {split.tail} + {/* whitespace-pre keeps a space at the cut; flex items would otherwise collapse it. */} + {split.head} + {split.tail} ) : ( {value} From ee5d318b1a5bef5b81effde36dc44c4e697a6cc3 Mon Sep 17 00:00:00 2001 From: Artyom Guryanov Date: Thu, 22 Oct 2026 17:20:24 +0400 Subject: [PATCH 2/5] fix(web): middle-truncated labels stay on one line The split head/tail spans use whitespace-pre to keep the space at the cut, but that would also keep \r and \n, so a label containing a line break (e.g. a file name) would render on several lines. Replace line breaks with spaces in the split parts; the title keeps the original. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ui/middle-truncate.test.tsx | 12 ++++++++++++ apps/web/src/components/ui/middle-truncate.tsx | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/middle-truncate.test.tsx b/apps/web/src/components/ui/middle-truncate.test.tsx index 7529e73d4508..999ea9dff293 100644 --- a/apps/web/src/components/ui/middle-truncate.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.test.tsx @@ -23,4 +23,16 @@ describe("MiddleTruncate", () => { } expect(spans[0]?.[1]).toContain("text-ellipsis"); }); + + it("keeps the split label on one line when the value contains line breaks", () => { + const value = "From new\r\ndesign-x\nlong-name"; + expect(splitForMiddleTruncate(value)).not.toBeNull(); + const html = renderToStaticMarkup(); + const spans = [...html.matchAll(/([^<]*)<\/span>/g)]; + expect(spans).toHaveLength(2); + for (const match of spans) { + expect(match[1]).not.toMatch(/[\r\n]/); + } + expect(spans.map((match) => match[1]).join("")).toBe("From new design-x long-name"); + }); }); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 77a8e77d0f7c..d99533908b88 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -30,8 +30,11 @@ export function MiddleTruncate({ {split ? ( <> {/* whitespace-pre keeps a space at the cut; flex items would otherwise collapse it. */} - {split.head} - {split.tail} + {/* whitespace-pre would also keep line breaks, so flatten them to stay on one line. */} + + {flattenLineBreaks(split.head)} + + {flattenLineBreaks(split.tail)} ) : ( {value} @@ -40,6 +43,10 @@ export function MiddleTruncate({ ); } +function flattenLineBreaks(text: string): string { + return text.replace(/[\r\n]/g, " "); +} + const DEFAULT_TAIL = 10; const MAX_SEGMENT_TAIL = 16; From 5e5323d95a4bae2265636bb6607d198d7267a64c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:53:34 +0000 Subject: [PATCH 3/5] fix(web): collapse label whitespace before the middle split Normalising before the split keeps runs of spaces and line breaks from rendering literally now that the segments use whitespace-pre, and adds a browser test for the space at the cut. AI-assisted; verified with the tests and tools listed above. --- .../components/ui/middle-truncate.browser.test.tsx | 14 ++++++++++++++ apps/web/src/components/ui/middle-truncate.tsx | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ui/middle-truncate.browser.test.tsx b/apps/web/src/components/ui/middle-truncate.browser.test.tsx index 3b9b114d2f9f..1b75a0b998b7 100644 --- a/apps/web/src/components/ui/middle-truncate.browser.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.browser.test.tsx @@ -23,4 +23,18 @@ describe("MiddleTruncate", () => { expect(label()).not.toHaveAttribute("data-overflow"); expect(label()).toHaveStyle({ clipPath: "none" }); }); + + it("keeps the space where the head meets the tail", async () => { + await using screen = await render( +
+ + + Fix the sidebar + +
, + ); + const width = (id: string) => screen.getByTestId(id).element().getBoundingClientRect().width; + + expect(width("split")).toBeCloseTo(width("whole"), 0); + }); }); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index c9d48cfd2f94..41c0264943c4 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -16,7 +16,8 @@ export function MiddleTruncate({ showTitle?: boolean; variant?: "default" | "cap-aligned"; }) { - const split = splitForMiddleTruncate(value, tail); + // Collapse whitespace up front: the segments render pre so the space at the cut survives. + const split = splitForMiddleTruncate(value.replace(/\s+/g, " ").trim(), tail); const headRef = useRef(null); const head = split?.head; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver @@ -43,10 +44,10 @@ export function MiddleTruncate({ <> {/* The tail carries the ellipsis so it sits flush; a CSS ellipsis leaves a ragged gap. */} - {split.head.replace(/[\r\n]/g, " ")} + {split.head} - {split.tail.replace(/[\r\n]/g, " ")} + {split.tail} ) : ( From de59cca66bbe97c058542407fa8c4331feff1f47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:03:58 +0000 Subject: [PATCH 4/5] fix(web): collapse only CSS whitespace in middle-truncated labels \s and trim() also match em and no-break spaces, which CSS preserves, so branch names and filenames containing them rendered differently from main. Collapse and trim only space, tab, and line-break characters. AI-assisted; verified with the tests and tools listed above. --- .../src/components/ui/middle-truncate.browser.test.tsx | 9 ++++++--- apps/web/src/components/ui/middle-truncate.tsx | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ui/middle-truncate.browser.test.tsx b/apps/web/src/components/ui/middle-truncate.browser.test.tsx index 1b75a0b998b7..bd788c7eb608 100644 --- a/apps/web/src/components/ui/middle-truncate.browser.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.browser.test.tsx @@ -24,12 +24,15 @@ describe("MiddleTruncate", () => { expect(label()).toHaveStyle({ clipPath: "none" }); }); - it("keeps the space where the head meets the tail", async () => { + it.each([ + ["Fix the sidebar", 7], + ["report\u2003\u2003draft.txt", 9], + ])("renders %j split as wide as whole", async (value, tail) => { await using screen = await render(
- + - Fix the sidebar + {value}
, ); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 41c0264943c4..23ac82380dac 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -16,8 +16,10 @@ export function MiddleTruncate({ showTitle?: boolean; variant?: "default" | "cap-aligned"; }) { - // Collapse whitespace up front: the segments render pre so the space at the cut survives. - const split = splitForMiddleTruncate(value.replace(/\s+/g, " ").trim(), tail); + // The segments render pre so the space at the cut survives; collapse only what CSS + // would (not \s, which also eats em and no-break spaces that CSS keeps). + const collapsed = value.replace(/[ \t\n\r\f]+/g, " ").replace(/^ | $/g, ""); + const split = splitForMiddleTruncate(collapsed, tail); const headRef = useRef(null); const head = split?.head; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver From 572777385297c331d8ba76ab7d07bba476982364 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:18:14 +0000 Subject: [PATCH 5/5] fix(web): split middle-truncated labels before collapsing spaces Collapsing whitespace before the split shifted the fixed tail, so a name like 'longprefix a b.txt' kept one character less of its ending than on main and could clip in narrow trees. Split the raw value as before and collapse whitespace in the rendered segments, dropping a duplicate space when the cut lands inside a run. AI-assisted; verified with the tests and tools listed above. --- .../ui/middle-truncate.browser.test.tsx | 8 ++++++++ apps/web/src/components/ui/middle-truncate.tsx | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ui/middle-truncate.browser.test.tsx b/apps/web/src/components/ui/middle-truncate.browser.test.tsx index bd788c7eb608..3fbf36523254 100644 --- a/apps/web/src/components/ui/middle-truncate.browser.test.tsx +++ b/apps/web/src/components/ui/middle-truncate.browser.test.tsx @@ -40,4 +40,12 @@ describe("MiddleTruncate", () => { expect(width("split")).toBeCloseTo(width("whole"), 0); }); + + it("keeps the raw value's tail when collapsing its spaces", async () => { + await using screen = await render(); + + expect(screen.container.querySelector("[title] > span:last-child")?.textContent).toBe( + "x a b.txt", + ); + }); }); diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index 23ac82380dac..faf8ab6b3f39 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -16,10 +16,8 @@ export function MiddleTruncate({ showTitle?: boolean; variant?: "default" | "cap-aligned"; }) { - // The segments render pre so the space at the cut survives; collapse only what CSS - // would (not \s, which also eats em and no-break spaces that CSS keeps). - const collapsed = value.replace(/[ \t\n\r\f]+/g, " ").replace(/^ | $/g, ""); - const split = splitForMiddleTruncate(collapsed, tail); + const raw = splitForMiddleTruncate(value, tail); + const split = raw && collapseSegments(raw); const headRef = useRef(null); const head = split?.head; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver @@ -94,6 +92,16 @@ function fitHead(head: HTMLElement) { if (tail instanceof HTMLElement) tail.style.translate = slack > 0 ? `-${slack}px 0` : ""; } +// The segments render pre so the space at the cut survives, so collapse here what CSS +// would have (not \s: em and no-break spaces are kept). Split first, so the tail length +// still counts the raw value. +function collapseSegments(split: { head: string; tail: string }) { + const collapse = (text: string) => text.replace(/[ \t\n\r\f]+/g, " "); + const head = collapse(split.head).replace(/^ /, ""); + const tail = collapse(split.tail).replace(/ $/, ""); + return { head, tail: head.endsWith(" ") ? tail.replace(/^ /, "") : tail }; +} + const DEFAULT_TAIL = 10; const MAX_SEGMENT_TAIL = 16; const MIN_WORD_TAIL = 4;