From 132233cd30c38203d855cdd49421ccf85fe2da0d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 23:07:44 +0000 Subject: [PATCH 1/4] feat(web): truncate branch names and paths in the middle Branch names, worktree paths and file names carry meaning at both ends, and a tail cut drops the part that tells two apart. A shared MiddleTruncate splits the string into a head that truncates and a fixed tail, with no measuring, so it costs what `truncate` costs in a long list. Used for the sidebar thread branch, the branch picker trigger and rows, the worktree labels in the branch toolbar, the worktree setup card, the changed files tree and the pull request header branches. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/BranchToolbar.tsx | 7 +- .../BranchToolbarBranchSelector.tsx | 12 ++-- apps/web/src/components/Sidebar.tsx | 10 +-- .../src/components/ThreadCommandSubtitle.tsx | 4 +- .../src/components/chat/ChangedFilesTree.tsx | 8 ++- .../src/components/chat/WorktreeSetupCard.tsx | 13 +++- .../pullRequest/PullRequestDetailPanel.tsx | 13 +++- .../pullRequest/PullRequestGhosts.tsx | 5 +- .../web/src/components/ui/middle-truncate.tsx | 69 +++++++++++++++++++ 9 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/ui/middle-truncate.tsx diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index cdf3aeffbea1..ffc89c33e794 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -54,6 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; @@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + @@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ - {previousWorktreeLabel} + ) : null} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d958f2f76625..53f7c3d2c232 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -73,6 +73,7 @@ import { } from "./ui/combobox"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; export interface BranchToolbarBranchSelectorHandle { open: () => void; @@ -747,7 +748,7 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- {itemValue} + {badge && {badge}}
@@ -807,12 +808,11 @@ export function BranchToolbarBranchSelector({ data-composer-label className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" > - - {triggerLabel} - + className="flex w-full max-w-[240px] transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" + /> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a03196fc3b8d..f919d5fe710f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { MiddleTruncate } from "./ui/middle-truncate"; import { composerDraftHasUserContent, DraftId, @@ -379,7 +380,7 @@ function SidebarThreadTooltip({ {thread.branch ? (
-
{thread.branch}
+
) : null} {branchMismatch ? ( @@ -1932,9 +1933,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {thread.branch ? ( <> - - {thread.branch} - + ) : ( diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 275105a79099..58c28609dcef 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { cn } from "~/lib/utils"; +import { MiddleTruncate } from "./ui/middle-truncate"; + /** * Flip this while reviewing command-palette thread subtitles. * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default) @@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: { {projectLabel ? : null} - {branchLabel} + ) : null} diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index f0cdc0163ce6..02155ac7c72c 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - - {node.name} - + {node.stat && ( diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 6fbe7a2b9b5e..bbcd92a2f98b 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -19,6 +19,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Spinner } from "~/components/ui/spinner"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; @@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { {snapshot.branch ? ( <>
Branch
-
{snapshot.branch}
+
+ +
) : null} {snapshot.baseRef ? ( <>
Base
-
{snapshot.baseRef}
+
+ +
) : null} {snapshot.worktreePath ? ( <>
Path
-
{snapshot.worktreePath}
+
+ +
) : null} {snapshot.setupScript ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 98d172f53eb5..2a1140a8720a 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -120,6 +120,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -2249,7 +2250,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + +
} /> @@ -2275,7 +2278,9 @@ export function PullRequestDetailPanel({ {detail.headBranch} + + + } /> {detail.headBranch} @@ -2422,7 +2427,9 @@ export function PullRequestDetailPanel({ className="size-3 shrink-0" /> ) : null} - {detail.baseBranch} + + +
} /> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 90f88da93668..61ff2c538856 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { pullRequestLabelColor } from "./pullRequestList.logic"; import { @@ -252,7 +253,9 @@ export function PullRequestDetailGhost({ {seed ? ( - {seed.baseBranch} + + + ) : ( diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx new file mode 100644 index 000000000000..b1605f3990ed --- /dev/null +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -0,0 +1,69 @@ +import type { ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * Truncates in the middle, the way Finder does, for strings that carry meaning at both ends: + * branch names, paths, worktree names, shas. A tail-cut `fix/cache-main-20260918-180825` loses + * the date that tells two branches apart; a path loses its file name. + * + * CSS has no middle ellipsis, so the string is split into a head that truncates and a tail + * that does not. No measuring and no observers, so it costs the same as `truncate` in a long + * list and works inside `content-visibility` skipping. Both halves are real text: selection + * and copy give the whole string, and a screen reader reads it through. + */ +export function MiddleTruncate({ + value, + tail, + showTitle = true, + className, + ...props +}: Omit, "children"> & { + value: string; + /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */ + tail?: number; + /** The full value on hover. Off where a tooltip already carries it. */ + showTitle?: boolean; +}) { + const split = splitForMiddleTruncate(value, tail); + return ( + + {split ? ( + <> + {split.head} + {split.tail} + + ) : ( + {value} + )} + + ); +} + +const DEFAULT_TAIL = 10; +const MAX_SEGMENT_TAIL = 16; + +/** + * Where to cut. A path keeps its last segment when that is short enough to be the useful + * part; anything else keeps a fixed count. Nothing is split when the tail would be most of + * the string, since then the head could never show enough to be worth an ellipsis. + */ +export function splitForMiddleTruncate( + value: string, + tail?: number, +): { head: string; tail: string } | null { + let keep = tail ?? DEFAULT_TAIL; + if (tail === undefined) { + const slash = value.lastIndexOf("/"); + if (slash > 0 && slash < value.length - 1) { + const segment = value.length - slash - 1; + keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; + } + } + if (keep <= 0 || value.length <= keep + 4) return null; + return { head: value.slice(0, value.length - keep), tail: value.slice(value.length - keep) }; +} From f6b267dbbd861888197c6fd175157c524b8e531e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 23:32:04 +0000 Subject: [PATCH 2/4] fix(web): split middle truncation on code points and clip the tail A cut inside a surrogate pair drew two broken glyphs. The root now clips a tail that cannot fit instead of overlapping its neighbor. The branch picker rows get the hover title back since they have no tooltip, and the sidebar rows drop it since their hover card already shows the branch. Co-Authored-By: Claude Fable 5.1 --- .../components/BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/components/ui/middle-truncate.tsx | 16 ++++++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 53f7c3d2c232..9708164ddd09 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -748,7 +748,7 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
- + {badge && {badge}}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f919d5fe710f..cffe4c7dd0a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1935,6 +1935,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index b1605f3990ed..fd8a25447738 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -29,7 +29,7 @@ export function MiddleTruncate({ return ( {split ? ( @@ -56,14 +56,18 @@ export function splitForMiddleTruncate( value: string, tail?: number, ): { head: string; tail: string } | null { + // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken + // glyphs where an emoji or a CJK extension character used to be. + const chars = Array.from(value); let keep = tail ?? DEFAULT_TAIL; if (tail === undefined) { - const slash = value.lastIndexOf("/"); - if (slash > 0 && slash < value.length - 1) { - const segment = value.length - slash - 1; + const slash = chars.lastIndexOf("/"); + if (slash > 0 && slash < chars.length - 1) { + const segment = chars.length - slash - 1; keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; } } - if (keep <= 0 || value.length <= keep + 4) return null; - return { head: value.slice(0, value.length - keep), tail: value.slice(value.length - keep) }; + if (keep <= 0 || chars.length <= keep + 4) return null; + const cut = chars.length - keep; + return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") }; } From f15eedf41ef6b090b21ff005188475d9553a1900 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:46:06 +0000 Subject: [PATCH 3/4] feat(web): truncate the linked panel's branches in the middle The shared pull request row landed on main, so its head and base branches get the same treatment as the rest. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/PullRequestListRow.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index e77f82717698..26667c20d94f 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorAvatar, @@ -151,8 +152,12 @@ export function PullRequestRowAuthor({ /** `head → base`, in the mono the branches are typed in. */ export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { return ( - - {head} → {base} + + + + → + + ); } From 3323ab709a3a924b93a18f7b4f15f680b86e19ea Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:53:08 +0000 Subject: [PATCH 4/4] fix(web): keep the panel's base branch readable beside a long head The arrow is no longer hidden from screen readers, and the base keeps its width up to 45% of the line so a long head cannot squeeze it out. Co-Authored-By: Claude Fable 5.1 --- .../components/pullRequest/PullRequestListRow.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index 26667c20d94f..ea1577a23558 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -149,15 +149,17 @@ export function PullRequestRowAuthor({ ); } -/** `head → base`, in the mono the branches are typed in. */ +/** + * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is + * short of room. The base keeps its width up to a share of the line, so a long head cannot + * squeeze a short `main` out; the arrow stays readable so the two are not read as one name. + */ export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { return ( - - → - - + → + ); }