authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-20 23:43:39-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 23:43:39-03:00
logf7efb5354a3e6fb5e6b13a658ba57020e2c554ad
tree3487173f9ac2e3c4c5cf14cdf13a616ed1b28159
parent2efb8178d83f4f7c4ccc3ae1165f2a2ded0b2742
signature Signed by PGP key B5690EEEBB952194

feat(web): truncate branch names and paths in the middle (#12805)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

10 files changed, 137 insertions(+), 30 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-4
......@@ -54,6 +54,7 @@ import {
5454} from "./ui/menu";
5555import { Separator } from "./ui/separator";
5656import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
57import { MiddleTruncate } from "./ui/middle-truncate";
5758import { ComposerSurface } from "./chat/ComposerSurface";
5859import { useComposerMenuProps } from "./chat/composerEventScope";
5960import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
......@@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
283284 ) : (
284285 <FolderIcon className="size-3" />
285286 )}
286 <span className="min-w-0 truncate">
287 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
288 </span>
287 <MiddleTruncate value={resolveCurrentWorkspaceLabel(activeWorktreePath)} />
289288 </span>
290289 </MenuRadioItem>
291290 <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick>
......@@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
298297 <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick>
299298 <span className="flex min-w-0 items-center gap-1.5">
300299 <HistoryIcon className="size-3" />
301 <span className="min-w-0 truncate">{previousWorktreeLabel}</span>
300 <MiddleTruncate value={previousWorktreeLabel} />
302301 </span>
303302 </MenuRadioItem>
304303 ) : null}
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-6
......@@ -73,6 +73,7 @@ import {
7373} from "./ui/combobox";
7474import { stackedThreadToast, toastManager } from "./ui/toast";
7575import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
76import { MiddleTruncate } from "./ui/middle-truncate";
7677
7778export interface BranchToolbarBranchSelectorHandle {
7879 open: () => void;
......@@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({
757758 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
758759 >
759760 <div className="flex w-full min-w-0 items-center justify-between gap-2">
760 <span className="min-w-0 flex-1 truncate">{itemValue}</span>
761 <MiddleTruncate value={itemValue} className="flex-1" />
761762 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
762763 </div>
763764 </ComboboxItem>
......@@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({
818819 data-composer-label
819820 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
820821 >
821 <span
822 <MiddleTruncate
823 value={triggerLabel}
822824 data-composer-label-motion
823 className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
824 >
825 {triggerLabel}
826 </span>
825 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"
826 />
827827 </span>
828828 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
829829 </ComboboxTrigger>
apps/web/src/components/Sidebar.tsx+7-4
......@@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom
242242import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
243243import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
244244import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
245import { MiddleTruncate } from "./ui/middle-truncate";
245246import {
246247 composerDraftHasUserContent,
247248 DraftId,
......@@ -379,7 +380,7 @@ function SidebarThreadTooltip({
379380 {thread.branch ? (
380381 <div className="flex min-w-0 items-center gap-2">
381382 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />
382 <div className="min-w-0 truncate text-foreground/75">{thread.branch}</div>
383 <MiddleTruncate value={thread.branch} className="flex text-foreground/75" />
383384 </div>
384385 ) : null}
385386 {branchMismatch ? (
......@@ -1932,9 +1933,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
19321933 {thread.branch ? (
19331934 <>
19341935 <ThreadWorktreeIndicator thread={thread} />
1935 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">
1936 {thread.branch}
1937 </span>
1936 <MiddleTruncate
1937 value={thread.branch}
1938 showTitle={false}
1939 className="flex-1 text-muted-foreground/40"
1940 />
19381941 </>
19391942 ) : (
19401943 <span className="flex-1" />
apps/web/src/components/ThreadCommandSubtitle.tsx+3-1
......@@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
44import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
55import { cn } from "~/lib/utils";
66
7import { MiddleTruncate } from "./ui/middle-truncate";
8
79/**
810 * Flip this while reviewing command-palette thread subtitles.
911 * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default)
......@@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: {
8486 {projectLabel ? <CommandPaletteMetaDot /> : null}
8587 <span className="inline-flex min-w-0 items-center gap-1">
8688 <WorkspaceIcon variant={variant} isWorktree={isWorktree} />
87 <span className="min-w-0 truncate">{branchLabel}</span>
89 <MiddleTruncate value={branchLabel} />
8890 </span>
8991 </>
9092 ) : null}
apps/web/src/components/chat/ChangedFilesTree.tsx+5-3
......@@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
1919import { PierreEntryIcon } from "./PierreEntryIcon";
2020import { Button } from "../ui/button";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import { MiddleTruncate } from "../ui/middle-truncate";
2223
2324const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2425
......@@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
246247 theme={resolvedTheme}
247248 className="size-3.5 text-muted-foreground/70"
248249 />
249 <span className="truncate font-mono text-xs text-foreground/85 group-hover:text-foreground">
250 {node.name}
251 </span>
250 <MiddleTruncate
251 value={node.name}
252 className="font-mono text-xs text-foreground/85 group-hover:text-foreground"
253 />
252254 {node.stat && (
253255 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
254256 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
apps/web/src/components/chat/WorktreeSetupCard.tsx+10-3
......@@ -19,6 +19,7 @@ import { useEffect, useState, type ReactNode } from "react";
1919
2020import { Button } from "~/components/ui/button";
2121import { Spinner } from "~/components/ui/spinner";
22import { MiddleTruncate } from "../ui/middle-truncate";
2223import { observeVisibleAnimation } from "~/lib/visibleAnimation";
2324import { cn } from "~/lib/utils";
2425
......@@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
260261 {snapshot.branch ? (
261262 <>
262263 <dt className="text-foreground/80">Branch</dt>
263 <dd className="truncate font-mono">{snapshot.branch}</dd>
264 <dd className="min-w-0 font-mono">
265 <MiddleTruncate value={snapshot.branch} className="flex" />
266 </dd>
264267 </>
265268 ) : null}
266269 {snapshot.baseRef ? (
267270 <>
268271 <dt className="text-foreground/80">Base</dt>
269 <dd className="truncate font-mono">{snapshot.baseRef}</dd>
272 <dd className="min-w-0 font-mono">
273 <MiddleTruncate value={snapshot.baseRef} className="flex" />
274 </dd>
270275 </>
271276 ) : null}
272277 {snapshot.worktreePath ? (
273278 <>
274279 <dt className="text-foreground/80">Path</dt>
275 <dd className="truncate font-mono">{snapshot.worktreePath}</dd>
280 <dd className="min-w-0 font-mono">
281 <MiddleTruncate value={snapshot.worktreePath} className="flex" />
282 </dd>
276283 </>
277284 ) : null}
278285 {snapshot.setupScript ? (
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+16-5
......@@ -121,6 +121,7 @@ import {
121121import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
122122import { toastManager } from "../ui/toast";
123123import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
124import { MiddleTruncate } from "../ui/middle-truncate";
124125import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
125126import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
126127import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
......@@ -2267,7 +2268,9 @@ export function PullRequestDetailPanel({
22672268 className="size-3 shrink-0"
22682269 />
22692270 ) : null}
2270 <code className="min-w-0 truncate">{detail.baseBranch}</code>
2271 <code className="flex min-w-0">
2272 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2273 </code>
22712274 </PullRequestBaseFreshnessWarning>
22722275 ) : (
22732276 <Tooltip>
......@@ -2280,7 +2283,9 @@ export function PullRequestDetailPanel({
22802283 className="size-3 shrink-0"
22812284 />
22822285 ) : null}
2283 <code className="min-w-0 truncate">{detail.baseBranch}</code>
2286 <code className="flex min-w-0">
2287 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2288 </code>
22842289 </span>
22852290 }
22862291 />
......@@ -2298,7 +2303,9 @@ export function PullRequestDetailPanel({
22982303 <Tooltip>
22992304 <TooltipTrigger
23002305 render={
2301 <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code>
2306 <code className="flex min-w-0 flex-1">
2307 <MiddleTruncate value={detail.headBranch} showTitle={false} />
2308 </code>
23022309 }
23032310 />
23042311 <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>
......@@ -2452,7 +2459,9 @@ export function PullRequestDetailPanel({
24522459 className="size-3 shrink-0"
24532460 />
24542461 ) : null}
2455 <code className="min-w-0 truncate">{detail.baseBranch}</code>
2462 <code className="flex min-w-0">
2463 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2464 </code>
24562465 </PullRequestBaseFreshnessWarning>
24572466 ) : (
24582467 <Tooltip>
......@@ -2465,7 +2474,9 @@ export function PullRequestDetailPanel({
24652474 className="size-3 shrink-0"
24662475 />
24672476 ) : null}
2468 <code className="min-w-0 truncate">{detail.baseBranch}</code>
2477 <code className="flex min-w-0">
2478 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2479 </code>
24692480 </span>
24702481 }
24712482 />
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+4-1
......@@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";
2727
2828import { Button, InlineButton } from "../ui/button";
2929import { Toggle, ToggleGroup } from "../ui/toggle-group";
30import { MiddleTruncate } from "../ui/middle-truncate";
3031import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
3132import {
3233 PullRequestActorLabel,
......@@ -252,7 +253,9 @@ export function PullRequestDetailGhost({
252253 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
253254 {seed ? (
254255 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
255 <code className="min-w-0 truncate">{seed.baseBranch}</code>
256 <code className="flex min-w-0">
257 <MiddleTruncate value={seed.baseBranch} />
258 </code>
256259 </span>
257260 ) : (
258261 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
apps/web/src/components/pullRequest/PullRequestListRow.tsx+10-3
......@@ -8,6 +8,7 @@ import type { ReactNode } from "react";
88import { cn } from "~/lib/utils";
99import { formatRelativeTimeLabel } from "~/timestampFormat";
1010
11import { MiddleTruncate } from "../ui/middle-truncate";
1112import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1213import {
1314 PullRequestActorAvatar,
......@@ -148,11 +149,17 @@ export function PullRequestRowAuthor({
148149 );
149150}
150151
151/** `head → base`, in the mono the branches are typed in. */
152/**
153 * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is
154 * short of room. The base keeps its width up to a share of the line, so a long head cannot
155 * squeeze a short `main` out; the arrow stays readable so the two are not read as one name.
156 */
152157export function PullRequestRowBranches({ head, base }: { head: string; base: string }) {
153158 return (
154 <span className="truncate font-mono">
155 {head} → {base}
159 <span className="flex min-w-0 items-center gap-1 font-mono">
160 <MiddleTruncate value={head} />
161 <span className="shrink-0">→</span>
162 <MiddleTruncate value={base} className="max-w-[45%] shrink-0" />
156163 </span>
157164 );
158165}
apps/web/src/components/ui/middle-truncate.tsx created+73
......@@ -0,0 +1,73 @@
1import type { ComponentProps } from "react";
2
3import { cn } from "~/lib/utils";
4
5/**
6 * Truncates in the middle, the way Finder does, for strings that carry meaning at both ends:
7 * branch names, paths, worktree names, shas. A tail-cut `fix/cache-main-20260918-180825` loses
8 * the date that tells two branches apart; a path loses its file name.
9 *
10 * CSS has no middle ellipsis, so the string is split into a head that truncates and a tail
11 * that does not. No measuring and no observers, so it costs the same as `truncate` in a long
12 * list and works inside `content-visibility` skipping. Both halves are real text: selection
13 * and copy give the whole string, and a screen reader reads it through.
14 */
15export function MiddleTruncate({
16 value,
17 tail,
18 showTitle = true,
19 className,
20 ...props
21}: Omit<ComponentProps<"span">, "children"> & {
22 value: string;
23 /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */
24 tail?: number;
25 /** The full value on hover. Off where a tooltip already carries it. */
26 showTitle?: boolean;
27}) {
28 const split = splitForMiddleTruncate(value, tail);
29 return (
30 <span
31 {...(showTitle ? { title: value } : {})}
32 className={cn("inline-flex min-w-0 max-w-full overflow-hidden whitespace-nowrap", className)}
33 {...props}
34 >
35 {split ? (
36 <>
37 <span className="min-w-0 truncate">{split.head}</span>
38 <span className="shrink-0">{split.tail}</span>
39 </>
40 ) : (
41 <span className="min-w-0 truncate">{value}</span>
42 )}
43 </span>
44 );
45}
46
47const DEFAULT_TAIL = 10;
48const MAX_SEGMENT_TAIL = 16;
49
50/**
51 * Where to cut. A path keeps its last segment when that is short enough to be the useful
52 * part; anything else keeps a fixed count. Nothing is split when the tail would be most of
53 * the string, since then the head could never show enough to be worth an ellipsis.
54 */
55export function splitForMiddleTruncate(
56 value: string,
57 tail?: number,
58): { head: string; tail: string } | null {
59 // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken
60 // glyphs where an emoji or a CJK extension character used to be.
61 const chars = Array.from(value);
62 let keep = tail ?? DEFAULT_TAIL;
63 if (tail === undefined) {
64 const slash = chars.lastIndexOf("/");
65 if (slash > 0 && slash < chars.length - 1) {
66 const segment = chars.length - slash - 1;
67 keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL;
68 }
69 }
70 if (keep <= 0 || chars.length <= keep + 4) return null;
71 const cut = chars.length - keep;
72 return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") };
73}