mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
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.
This commit is contained in:
@@ -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'
|
||||
@@ -410,50 +411,119 @@ 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 resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const handleRef = useCallback((node: HTMLSpanElement | null) => {
|
||||
resizeObserverRef.current?.disconnect()
|
||||
resizeObserverRef.current = null
|
||||
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)
|
||||
resizeObserverRef.current = observer
|
||||
}, [])
|
||||
|
||||
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
|
||||
// Why: ResizeObserver misses text-only changes; remount so a new slug remeasures.
|
||||
key={name}
|
||||
ref={handleRef}
|
||||
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">
|
||||
@@ -2627,16 +2697,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
)}
|
||||
</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(
|
||||
@@ -2653,17 +2720,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
)}
|
||||
</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">
|
||||
@@ -2833,8 +2901,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
titleRange={result.titleRange}
|
||||
secondaryText={result.secondaryText}
|
||||
secondaryRange={result.secondaryRange}
|
||||
worktreeName={result.worktreeName}
|
||||
worktreeRange={result.worktreeRange}
|
||||
leadingBadges={
|
||||
<>
|
||||
{result.isCurrentTab && (
|
||||
@@ -2858,6 +2924,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
/>
|
||||
</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">
|
||||
@@ -2915,8 +2987,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
titleRange={result.titleRange}
|
||||
secondaryText={result.secondaryText}
|
||||
secondaryRange={result.secondaryRange}
|
||||
worktreeName={result.worktreeName}
|
||||
worktreeRange={result.worktreeRange}
|
||||
leadingBadges={
|
||||
<>
|
||||
{result.isCurrentTab && (
|
||||
@@ -2940,6 +3010,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
/>
|
||||
</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">
|
||||
@@ -2994,8 +3070,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
titleRange={result.titleRange}
|
||||
secondaryText={result.secondaryText}
|
||||
secondaryRange={result.secondaryRange}
|
||||
worktreeName={result.worktreeName}
|
||||
worktreeRange={result.worktreeRange}
|
||||
leadingBadges={
|
||||
<>
|
||||
{result.isCurrentPage && (
|
||||
@@ -3019,6 +3093,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
/>
|
||||
</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">
|
||||
@@ -3088,9 +3168,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2103,7 +2103,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": {
|
||||
|
||||
Reference in New Issue
Block a user