Fix right sidebar activity overflow (#2343)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 16:44:01 -04:00 committed by GitHub
parent 8b6f666de5
commit fbb4db5479
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 80 deletions

View File

@ -0,0 +1,142 @@
import React from 'react'
import { MoreHorizontal } from 'lucide-react'
import type { RightSidebarTab } from '@/store/slices/editor'
import type { CheckStatus } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
export type ActivityBarItem = {
id: RightSidebarTab
icon: React.ComponentType<{ size?: number; className?: string }>
title: string
shortcut: string
/** When true, hidden for non-git (folder-mode) repos. */
gitOnly?: boolean
}
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
success: 'bg-emerald-500',
failure: 'bg-rose-500',
pending: 'bg-amber-500',
neutral: 'bg-muted-foreground'
}
export function TopActivityOverflowMenu({
items,
activeTab,
onSelect,
checksStatus
}: {
items: ActivityBarItem[]
activeTab: RightSidebarTab
onSelect: (tab: RightSidebarTab) => void
checksStatus?: CheckStatus | null
}): React.JSX.Element {
const hiddenChecksStatus =
checksStatus && checksStatus !== 'neutral' && items.some((item) => item.id === 'checks')
? checksStatus
: null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex h-[36px] w-8 shrink-0 items-center justify-center text-muted-foreground/60 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="More sidebar tabs"
>
<MoreHorizontal size={16} />
{hiddenChecksStatus && (
<div
className={cn(
'absolute top-[8px] right-[4px] size-[7px] rounded-full ring-1 ring-sidebar',
STATUS_DOT_COLOR[hiddenChecksStatus] ?? 'bg-muted-foreground'
)}
/>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={6}>
{items.map((item) => {
const Icon = item.icon
const active = item.id === activeTab
return (
<DropdownMenuItem
key={item.id}
onSelect={() => onSelect(item.id)}
className={cn(active && 'bg-accent text-accent-foreground')}
aria-current={active ? 'page' : undefined}
>
<Icon size={14} />
<span>{item.title}</span>
{item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ActivityBarButton({
item,
active,
onClick,
layout,
statusIndicator
}: {
item: ActivityBarItem
active: boolean
onClick: () => void
layout: 'top' | 'side'
statusIndicator?: CheckStatus | null
}): React.JSX.Element {
const Icon = item.icon
const isTop = layout === 'top'
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'relative flex shrink-0 items-center justify-center transition-colors right-sidebar-header-no-drag',
isTop ? 'h-[36px] w-9' : 'w-10 h-10',
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
)}
onClick={onClick}
aria-label={item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
>
<Icon size={isTop ? 16 : 18} />
{statusIndicator && statusIndicator !== 'neutral' && (
<div
className={cn(
'absolute rounded-full size-[7px] ring-1 ring-sidebar',
isTop ? 'top-[8px] right-[5px]' : 'top-[7px] right-[7px]',
STATUS_DOT_COLOR[statusIndicator] ?? 'bg-muted-foreground'
)}
/>
)}
{active && isTop && (
<div className="absolute bottom-0 left-[25%] right-[25%] h-[2px] bg-foreground rounded-t" />
)}
{active && !isTop && (
<div className="absolute right-0 top-[25%] bottom-[25%] w-[2px] bg-foreground rounded-l" />
)}
</button>
</TooltipTrigger>
<TooltipContent side={isTop ? 'bottom' : 'left'} sideOffset={6}>
{item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { RightSidebarTab } from '@/store/slices/editor'
import { getTopActivityBarLayout } from './activity-bar-overflow'
const items = (
['explorer', 'search', 'source-control', 'checks', 'ports'] as RightSidebarTab[]
).map((id) => ({ id }))
describe('getTopActivityBarLayout', () => {
it('shows every item when the top activity strip has enough room', () => {
const layout = getTopActivityBarLayout(items, 180, 'explorer')
expect(layout.visibleItems.map((item) => item.id)).toEqual([
'explorer',
'search',
'source-control',
'checks',
'ports'
])
expect(layout.overflowItems).toEqual([])
})
it('moves trailing items behind the overflow menu when width is tight', () => {
const layout = getTopActivityBarLayout(items, 160, 'explorer')
expect(layout.visibleItems.map((item) => item.id)).toEqual([
'explorer',
'search',
'source-control'
])
expect(layout.overflowItems.map((item) => item.id)).toEqual(['checks', 'ports'])
})
it('keeps the active tab visible even when it would otherwise overflow', () => {
const layout = getTopActivityBarLayout(items, 160, 'ports')
expect(layout.visibleItems.map((item) => item.id)).toEqual(['explorer', 'search', 'ports'])
expect(layout.overflowItems.map((item) => item.id)).toEqual(['source-control', 'checks'])
})
})

View File

@ -0,0 +1,36 @@
import type { RightSidebarTab } from '@/store/slices/editor'
const TOP_ACTIVITY_BUTTON_WIDTH = 36
const TOP_ACTIVITY_MORE_BUTTON_WIDTH = 32
export function getTopActivityBarLayout<T extends { id: RightSidebarTab }>(
items: readonly T[],
availableWidth: number | null,
activeId: RightSidebarTab
): { visibleItems: T[]; overflowItems: T[] } {
if (!availableWidth || !Number.isFinite(availableWidth)) {
return { visibleItems: [...items], overflowItems: [] }
}
if (items.length * TOP_ACTIVITY_BUTTON_WIDTH <= availableWidth) {
return { visibleItems: [...items], overflowItems: [] }
}
const visibleCount = Math.max(
1,
Math.min(
items.length - 1,
Math.floor((availableWidth - TOP_ACTIVITY_MORE_BUTTON_WIDTH) / TOP_ACTIVITY_BUTTON_WIDTH)
)
)
const visibleItems = items.slice(0, visibleCount)
const activeItem = items.find((item) => item.id === activeId)
if (activeItem && !visibleItems.some((item) => item.id === activeItem.id)) {
visibleItems[visibleItems.length - 1] = activeItem
}
const visibleIds = new Set(visibleItems.map((item) => item.id))
return {
visibleItems,
overflowItems: items.filter((item) => !visibleIds.has(item.id))
}
}

View File

@ -4,7 +4,7 @@ import { useAppStore } from '@/store'
import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor'
import type { ActivityBarPosition } from '@/store/slices/editor'
import type { CheckStatus } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
@ -22,6 +22,12 @@ import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
import PortsPanel from './PortsPanel'
import { getTopActivityBarLayout } from './activity-bar-overflow'
import {
ActivityBarButton,
TopActivityOverflowMenu,
type ActivityBarItem
} from './activity-bar-buttons'
const MIN_WIDTH = 220
// Why: long file names (e.g. construction drawing sheets, multi-part document
@ -33,7 +39,6 @@ const MIN_NON_SIDEBAR_AREA = 320
const ABSOLUTE_FALLBACK_MAX_WIDTH = 2000
const ACTIVITY_BAR_SIDE_WIDTH = 40
function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
@ -60,16 +65,7 @@ function getActiveChecksStatus(state: ReturnType<typeof useAppStore.getState>):
return state.prCache[prCacheKey]?.data?.checksStatus ?? null
}
type ActivityBarItem = {
id: RightSidebarTab
icon: React.ComponentType<{ size?: number; className?: string }>
title: string
shortcut: string
/** When true, hidden for non-git (folder-mode) repos. */
gitOnly?: boolean
}
const isMac = navigator.userAgent.includes('Mac')
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
const mod = isMac ? '\u2318' : 'Ctrl+'
const ACTIVITY_ITEMS: ActivityBarItem[] = [
@ -120,6 +116,7 @@ function RightSidebarInner(): React.JSX.Element {
const checksStatus = useAppStore(getActiveChecksStatus)
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
const [topActivityStripWidth, setTopActivityStripWidth] = useState<number | null>(null)
// Why: source control and checks are meaningless for non-git folders.
// Hide those tabs so the activity bar only shows relevant actions.
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
@ -153,6 +150,7 @@ function RightSidebarInner(): React.JSX.Element {
renderedExtraWidth: activityBarSideWidth,
setWidth: setRightSidebarWidth
})
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
const panelContent = (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
@ -177,13 +175,18 @@ function RightSidebarInner(): React.JSX.Element {
</div>
)
const activityBarIcons = visibleItems.map((item) => (
const topActivityLayout = useMemo(
() => getTopActivityBarLayout(visibleItems, topActivityStripWidth, effectiveTab),
[visibleItems, topActivityStripWidth, effectiveTab]
)
const sideActivityBarIcons = visibleItems.map((item) => (
<ActivityBarButton
key={item.id}
item={item}
active={effectiveTab === item.id}
onClick={() => setRightSidebarTab(item.id)}
layout={activityBarPosition}
layout="side"
statusIndicator={item.id === 'checks' ? checksStatus : null}
/>
))
@ -232,11 +235,33 @@ function RightSidebarInner(): React.JSX.Element {
<div className="flex items-center border-b border-border h-[36px] min-h-[36px] pl-2 pr-1 right-sidebar-header-inset right-sidebar-header-drag overflow-hidden">
<TooltipProvider delayDuration={400}>
<ContextMenuTrigger asChild>
<div className="right-sidebar-activity-strip flex min-w-0 flex-1 items-center overflow-x-auto overflow-y-hidden scrollbar-sleek right-sidebar-header-no-drag">
{/* Why: Windows window controls can leave less safe header width
than the activity buttons need; scroll inside the safe area
instead of letting buttons extend under the overlay. */}
<div className="flex shrink-0">{activityBarIcons}</div>
<div
ref={topActivityStripRef}
className="right-sidebar-activity-strip flex min-w-0 flex-1 items-center overflow-hidden right-sidebar-header-no-drag"
>
{/* Why: the top strip shares a narrow titlebar with the close
button and Windows controls. Overflow goes behind More
instead of creating a horizontally scrollable toolbar. */}
<div className="flex min-w-0 shrink">
{topActivityLayout.visibleItems.map((item) => (
<ActivityBarButton
key={item.id}
item={item}
active={effectiveTab === item.id}
onClick={() => setRightSidebarTab(item.id)}
layout="top"
statusIndicator={item.id === 'checks' ? checksStatus : null}
/>
))}
</div>
{topActivityLayout.overflowItems.length > 0 && (
<TopActivityOverflowMenu
items={topActivityLayout.overflowItems}
activeTab={effectiveTab}
onSelect={setRightSidebarTab}
checksStatus={checksStatus}
/>
)}
</div>
</ContextMenuTrigger>
<div className="flex shrink-0 items-center right-sidebar-header-no-drag">
@ -280,7 +305,7 @@ function RightSidebarInner(): React.JSX.Element {
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border side-activity-bar-windows-inset">
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
<TooltipProvider delayDuration={400}>{sideActivityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
<ActivityBarPositionMenu
@ -320,69 +345,28 @@ function computeMaxRightSidebarWidth(): number {
return Math.max(MIN_WIDTH, window.innerWidth - MIN_NON_SIDEBAR_AREA)
}
// ─── Status indicator dot color mapping ──────
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
success: 'bg-emerald-500',
failure: 'bg-rose-500',
pending: 'bg-amber-500',
neutral: 'bg-muted-foreground'
}
function useMeasuredWidth(onWidth: (width: number | null) => void) {
const observerRef = React.useRef<ResizeObserver | null>(null)
// ─── Activity Bar Button (shared for top + side) ──────
function ActivityBarButton({
item,
active,
onClick,
layout,
statusIndicator
}: {
item: ActivityBarItem
active: boolean
onClick: () => void
layout: 'top' | 'side'
statusIndicator?: CheckStatus | null
}): React.JSX.Element {
const Icon = item.icon
const isTop = layout === 'top'
return React.useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center transition-colors right-sidebar-header-no-drag',
isTop ? 'h-[36px] w-9' : 'w-10 h-10',
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
)}
onClick={onClick}
aria-label={item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
>
<Icon size={isTop ? 16 : 18} />
if (!node || typeof ResizeObserver === 'undefined') {
onWidth(node ? node.getBoundingClientRect().width : null)
return
}
{/* Status indicator dot */}
{statusIndicator && statusIndicator !== 'neutral' && (
<div
className={cn(
'absolute rounded-full size-[7px] ring-1 ring-sidebar',
isTop ? 'top-[8px] right-[5px]' : 'top-[7px] right-[7px]',
STATUS_DOT_COLOR[statusIndicator] ?? 'bg-muted-foreground'
)}
/>
)}
{/* Active indicator */}
{active && isTop && (
<div className="absolute bottom-0 left-[25%] right-[25%] h-[2px] bg-foreground rounded-t" />
)}
{active && !isTop && (
<div className="absolute right-0 top-[25%] bottom-[25%] w-[2px] bg-foreground rounded-l" />
)}
</button>
</TooltipTrigger>
<TooltipContent side={isTop ? 'bottom' : 'left'} sideOffset={6}>
{item.shortcut ? `${item.title} (${item.shortcut})` : item.title}
</TooltipContent>
</Tooltip>
const updateWidth = (): void => {
onWidth(node.getBoundingClientRect().width)
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
observer.observe(node)
observerRef.current = observer
},
[onWidth]
)
}