Move worktree labels to right badge rail with truncation tooltips (#14313)

* refactor(palette): move worktree labels to right badge rail with tooltip

Move worktree and branch names from the tab title line to a dedicated right-side badge rail, preventing long titles from being truncated. Add smart tooltips showing full names when truncated, and distinguish between workspace names and branch names based on whether they're auto-generated labels.

* fix(palette): disconnect worktree rail ResizeObserver on unmount

Move truncation observation into a layout effect so React Doctor sees a cleanup path and the subscription cannot leak after unmount.

(cherry picked from commit d957e1c0f5)
This commit is contained in:
Jinjing
2026-08-13 22:43:29 -07:00
parent 14f126ea03
commit ddb3913052
3 changed files with 183 additions and 48 deletions
@@ -43,6 +43,7 @@ import {
CommandEmpty,
CommandItem
} from '@/components/ui/command'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
@@ -419,50 +420,113 @@ function PaletteOpenTabPrimaryLine({
titleRange,
secondaryText,
secondaryRange,
worktreeName,
worktreeRange,
leadingBadges
}: {
title: string
titleRange: MatchRange | null
secondaryText: string
secondaryRange: MatchRange | null
worktreeName: string
worktreeRange: MatchRange | null
leadingBadges?: React.ReactNode
}): React.JSX.Element {
// Why gate on non-empty: empty secondaries (terminals/simulators) used to still
// render two "·" separators, which read as a double mark and stole width from
// the worktree name until it collided with host/repo badges.
// render a leftover "·" after the title.
const showSecondary = secondaryText.trim().length > 0
const showWorktree = worktreeName.trim().length > 0
return (
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span className="min-w-0 max-w-[42%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<span
data-slot="palette-open-tab-title"
className="min-w-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
>
<HighlightedText text={title} matchRange={titleRange} />
</span>
{leadingBadges}
{showSecondary ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<span className="min-w-0 max-w-[34%] truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={secondaryText} matchRange={secondaryRange} />
</span>
</>
) : null}
{showWorktree ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={worktreeName} matchRange={worktreeRange} />
</span>
</>
) : null}
</div>
)
}
function resolveOpenTabWorktreeRailTooltip({
isBranch,
truncated,
name
}: {
isBranch: boolean
truncated: boolean
name: string
}): string {
if (truncated) {
return name
}
return isBranch
? translate('auto.components.WorktreeJumpPalette.paletteOpenTabBranch', 'Branch name')
: translate('auto.components.WorktreeJumpPalette.paletteOpenTabWorkspace', 'Workspace name')
}
function PaletteOpenTabWorktreeRailLabel({
name,
matchRange,
worktree,
className,
slot = 'palette-open-tab-worktree'
}: {
name: string
matchRange: MatchRange | null
worktree?: Pick<Worktree, 'branch'> | null
className?: string
slot?: string
}): React.JSX.Element | null {
const [truncated, setTruncated] = useState(false)
const labelRef = useRef<HTMLSpanElement | null>(null)
// Why: observe in an effect so unmount disconnects the ResizeObserver instead of
// leaking the callback-ref subscription (react-doctor effect-needs-cleanup).
useLayoutEffect(() => {
const node = labelRef.current
if (!node) {
setTruncated(false)
return
}
const updateTruncated = (): void => {
const next = node.scrollWidth > node.clientWidth
setTruncated((current) => (current === next ? current : next))
}
updateTruncated()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(updateTruncated)
observer.observe(node)
return () => observer.disconnect()
}, [name])
if (name.trim().length === 0) {
return null
}
// Why tag the visible value: a custom display name or folder path is a workspace
// label, not a branch, even when the workspace sits on one.
const isBranch = worktree != null && name === resolveWorktreeBranchLabel(worktree)
const tooltip = resolveOpenTabWorktreeRailTooltip({ isBranch, truncated, name })
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={labelRef} data-slot={slot} tabIndex={-1} className={className}>
<HighlightedText text={name} matchRange={matchRange} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 break-all">
{tooltip}
</TooltipContent>
</Tooltip>
)
}
function PaletteState({ title, subtitle }: { title: string; subtitle: string }): React.JSX.Element {
return (
<div className="px-5 py-8 text-center">
@@ -2686,16 +2750,13 @@ function WorktreeJumpPaletteContent({
)}
</span>
)}
<span className="truncate text-[14px] font-semibold text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
text={worktreeLabel}
matchRange={entry.match.displayNameRange}
/>
) : (
worktreeLabel
)}
</span>
<PaletteOpenTabWorktreeRailLabel
name={worktreeLabel}
matchRange={entry.match.displayNameRange}
worktree={worktree}
slot="palette-worktree-name"
className="truncate text-[14px] font-semibold text-foreground"
/>
{isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
@@ -2712,17 +2773,18 @@ function WorktreeJumpPaletteContent({
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="truncate text-[12px] font-medium text-muted-foreground/92">
{entry.match.branchRange ? (
<HighlightedText
text={branch}
{branch.trim().length > 0 ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<PaletteOpenTabWorktreeRailLabel
name={branch}
matchRange={entry.match.branchRange}
worktree={worktree}
slot="palette-worktree-branch"
className="truncate text-[12px] font-medium text-muted-foreground/92"
/>
) : (
branch
)}
</span>
</>
) : null}
</div>
{entry.match.supportingText && (
<div className="mt-1.5 flex min-w-0 items-center gap-2 text-[12px] leading-5 text-muted-foreground/88">
@@ -2883,8 +2945,6 @@ function WorktreeJumpPaletteContent({
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentTab && (
@@ -2908,6 +2968,12 @@ function WorktreeJumpPaletteContent({
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteOpenTabWorktreeRailLabel
name={result.worktreeName}
matchRange={result.worktreeRange}
worktree={workspaceTabWorktree}
className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground"
/>
<PaletteHostBadgeChip badge={workspaceTabHostBadge} />
{workspaceTabRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
@@ -2962,8 +3028,6 @@ function WorktreeJumpPaletteContent({
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentTab && (
@@ -2987,6 +3051,12 @@ function WorktreeJumpPaletteContent({
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteOpenTabWorktreeRailLabel
name={result.worktreeName}
matchRange={result.worktreeRange}
worktree={simulatorWorktree}
className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground"
/>
<PaletteHostBadgeChip badge={simulatorHostBadge} />
{simulatorRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
@@ -3038,8 +3108,6 @@ function WorktreeJumpPaletteContent({
titleRange={result.titleRange}
secondaryText={result.secondaryText}
secondaryRange={result.secondaryRange}
worktreeName={result.worktreeName}
worktreeRange={result.worktreeRange}
leadingBadges={
<>
{result.isCurrentPage && (
@@ -3063,6 +3131,12 @@ function WorktreeJumpPaletteContent({
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteOpenTabWorktreeRailLabel
name={result.worktreeName}
matchRange={result.worktreeRange}
worktree={browserWorktree}
className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground"
/>
<PaletteHostBadgeChip badge={browserHostBadge} />
{browserRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
@@ -3132,9 +3206,11 @@ function WorktreeJumpPaletteContent({
)
return (
<PaletteLiveStatusProvider active={paletteStatusInputsActive}>
{paletteDialog}
</PaletteLiveStatusProvider>
<TooltipProvider delayDuration={400}>
<PaletteLiveStatusProvider active={paletteStatusInputsActive}>
{paletteDialog}
</PaletteLiveStatusProvider>
</TooltipProvider>
)
}
@@ -102,7 +102,11 @@ function makeRepo(): Repo {
}
}
function makeWorktree(id: string, displayName: string): Worktree {
function makeWorktree(
id: string,
displayName: string,
overrides: Partial<Worktree> = {}
): Worktree {
return {
id,
repoId: 'repo-1',
@@ -120,7 +124,8 @@ function makeWorktree(id: string, displayName: string): Worktree {
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
lastActivityAt: 0,
...overrides
}
}
@@ -335,4 +340,56 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
expect(testContainer.textContent).toContain('Open Tabs')
expect(testContainer.textContent).not.toContain('Worktrees')
})
it('puts the tab title on the left and the worktree name in the badge rail', async () => {
const longTitle = 'macOS Orca App Permission Update Delivery'
await renderPalette({
worktreesByRepo: {
'repo-1': [makeWorktree('wt-tabs', 'user-support')]
},
showSleepingWorkspaces: true,
ptyIdsByTabId: { 'term-0': ['pty-0'] },
tabsByWorktree: {
'wt-tabs': [makeTerminalTab('term-0', 'wt-tabs', longTitle)]
},
unifiedTabsByWorktree: {
'wt-tabs': [makeUnifiedTab('tab-0', 'wt-tabs', 'term-0', longTitle)]
},
groupsByWorktree: { 'wt-tabs': [makeGroup('wt-tabs', ['tab-0'])] },
activeGroupIdByWorktree: { 'wt-tabs': 'group-wt-tabs' }
})
const row = testContainer.querySelector('[data-command-item="workspace-tab:tab-0"]')
expect(row).not.toBeNull()
const title = row?.querySelector('[data-slot="palette-open-tab-title"]')
const worktree = row?.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(title?.textContent).toBe(longTitle)
expect(worktree?.textContent).toBe('user-support')
expect(worktree?.compareDocumentPosition(title ?? document.createElement('span'))).toBe(
Node.DOCUMENT_POSITION_PRECEDING
)
})
it('tags the worktree rail label as a branch when the visible name is the branch', async () => {
await renderPalette({
worktreesByRepo: {
'repo-1': [makeWorktree('wt-tabs', '', { displayName: '' })]
},
showSleepingWorkspaces: true,
ptyIdsByTabId: { 'term-0': ['pty-0'] },
tabsByWorktree: {
'wt-tabs': [makeTerminalTab('term-0', 'wt-tabs', 'Query the API')]
},
unifiedTabsByWorktree: {
'wt-tabs': [makeUnifiedTab('tab-0', 'wt-tabs', 'term-0', 'Query the API')]
},
groupsByWorktree: { 'wt-tabs': [makeGroup('wt-tabs', ['tab-0'])] },
activeGroupIdByWorktree: { 'wt-tabs': 'group-wt-tabs' }
})
const row = testContainer.querySelector('[data-command-item="workspace-tab:tab-0"]')
expect(row).not.toBeNull()
const worktree = row?.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(worktree?.textContent).toBe('main')
})
})
+3 -1
View File
@@ -2127,7 +2127,9 @@
"pluginCommandFailed": "Could not run the plugin command.",
"recentChatsTerminalsHeader": "Recent Chats & Terminals",
"27f10cca63": "Search chats, terminals, worktrees, settings, and actions...",
"2770f02910": "Search chats, terminals, worktrees, settings, and actions"
"2770f02910": "Search chats, terminals, worktrees, settings, and actions",
"paletteOpenTabBranch": "Branch name",
"paletteOpenTabWorkspace": "Workspace name"
},
"github": {
"pr": {