Re-add scroll to open workspace control (#2796)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-25 17:57:30 -07:00 committed by GitHub
parent 36357dfcb7
commit e27f3df397
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 508 additions and 13 deletions

View File

@ -829,6 +829,56 @@
background: color-mix(in srgb, var(--accent) 30%, transparent);
}
.scroll-to-current-workspace-reveal-highlight {
position: relative;
isolation: isolate;
overflow: visible;
}
.scroll-to-current-workspace-reveal-highlight::before {
content: '';
position: absolute;
inset: -2px;
z-index: 30;
pointer-events: none;
border: 1.5px solid color-mix(in srgb, var(--terminal-pane-locate) 68%, transparent);
border-radius: calc(var(--radius-md) + 2px);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--terminal-pane-locate) 34%, transparent),
0 0 18px color-mix(in srgb, var(--terminal-pane-locate) 36%, transparent);
animation: scroll-to-current-workspace-reveal-glow 1.5s ease-out forwards;
}
@keyframes scroll-to-current-workspace-reveal-glow {
0% {
opacity: 1;
transform: scale(0.992);
}
24% {
opacity: 0.72;
transform: scale(1);
}
60% {
opacity: 0.28;
transform: scale(1.006);
}
100% {
opacity: 0;
transform: scale(1.006);
}
}
@media (prefers-reduced-motion: reduce) {
.scroll-to-current-workspace-reveal-highlight::before {
animation: none;
opacity: 1;
transform: none;
}
}
@keyframes settings-shell-enter {
from {
opacity: 0;

View File

@ -0,0 +1,27 @@
import { Crosshair } from 'lucide-react'
import React from 'react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { requestScrollToCurrentWorkspaceReveal } from '@/lib/scroll-to-current-workspace-status'
export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label="Reveal active workspace"
onClick={requestScrollToCurrentWorkspaceReveal}
className="text-muted-foreground"
>
<Crosshair className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reveal active workspace
</TooltipContent>
</Tooltip>
)
}

View File

@ -33,6 +33,7 @@ import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import type { GitHubViewer } from '../../../../shared/types'
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton'
const GITHUB_ISSUES_URL = 'https://github.com/stablyai/orca/issues/'
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
@ -288,6 +289,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<ScrollToCurrentWorkspaceToolbarButton />
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>

View File

@ -100,6 +100,7 @@ import {
} from '@/hooks/useVirtualizedScrollAnchor'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT } from '@/lib/scroll-to-current-workspace-status'
import { useRepoHeaderDrag } from './repo-header-drag'
import WorktreeContextMenu from './WorktreeContextMenu'
import {
@ -215,6 +216,55 @@ function getWorktreeOptionId(worktreeId: string): string {
return `worktree-list-option-${encodeURIComponent(worktreeId)}`
}
function getMountedWorktreeBounds(
container: HTMLElement,
worktreeId: string
): VirtualItemBounds | null {
const element = document.getElementById(getWorktreeOptionId(worktreeId))
if (!element || !container.contains(element)) {
return null
}
const containerRect = container.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
return {
index: -1,
start: elementRect.top - containerRect.top + container.scrollTop,
end: elementRect.bottom - containerRect.top + container.scrollTop
}
}
export function getScrollTopToRevealBounds(
container: HTMLElement,
bounds: Pick<VirtualItemBounds, 'start' | 'end'>
): number | null {
const viewportTop = container.scrollTop
const viewportBottom = viewportTop + container.clientHeight
if (bounds.start < viewportTop) {
return bounds.start
}
if (bounds.end > viewportBottom) {
return bounds.end - container.clientHeight
}
return null
}
function revealMountedWorktreeElement(
container: HTMLElement,
worktreeId: string,
behavior: ScrollBehavior
): boolean {
const bounds = getMountedWorktreeBounds(container, worktreeId)
if (!bounds) {
return false
}
const nextScrollTop = getScrollTopToRevealBounds(container, bounds)
if (nextScrollTop !== null) {
container.scrollTo({ top: Math.max(0, nextScrollTop), behavior })
}
return true
}
function getWorktreeVisibilityMenuLabel(repo: Repo): string {
const visibility = effectiveExternalWorktreeVisibility(
repo,
@ -317,6 +367,12 @@ type WorktreeDragSession = {
rects: readonly WorktreeDragRect[]
}
type VirtualItemBounds = {
index: number
start: number
end: number
}
type WorktreePointerDrag = {
pointerId: number
sourceRow: HTMLElement
@ -565,9 +621,30 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
WORKTREE_ROW_DRAG_INITIAL_STATE
)
const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0)
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState<string | null>(
null
)
const worktreeDragSessionRef = useRef<WorktreeDragSession | null>(null)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null)
const revealHighlightTimeoutRef = useRef<number | null>(null)
const flashRevealedWorktree = useCallback((worktreeId: string) => {
if (revealHighlightTimeoutRef.current !== null) {
window.clearTimeout(revealHighlightTimeoutRef.current)
}
// Why: remove before add restarts the CSS glow when the user repeatedly
// asks to reveal the same active workspace.
setHighlightedRevealWorktreeId(null)
window.requestAnimationFrame(() => {
setHighlightedRevealWorktreeId(worktreeId)
revealHighlightTimeoutRef.current = window.setTimeout(() => {
revealHighlightTimeoutRef.current = null
setHighlightedRevealWorktreeId(null)
}, 1500)
})
}, [])
const suppressWorktreeClickUntilRef = useRef(0)
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
const lastVisibleRefreshKeyRef = useRef('')
@ -929,20 +1006,65 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists })
if (outcome === 'scroll-and-clear') {
// Why: `align: 'auto'` is a no-op when the card is already visible and
// otherwise scrolls the minimum amount to bring it into view. Using
// 'center' here made every worktree click re-center the sidebar, which
// is visually jumpy even when nothing needed to move. `behavior: 'smooth'`
// animates that minimum scroll so off-screen reveals slide into view
// instead of snapping — matching the native scroll-into-view feel.
const targetRow = renderRows[targetIndex]
const container = scrollRef.current
const retryExactRevealOnNextFrame = () => {
const previousRetry = pendingRevealRetryRef.current
const nextRetryCount =
previousRetry?.worktreeId === pendingRevealWorktree.worktreeId
? previousRetry.count + 1
: 1
pendingRevealRetryRef.current = {
worktreeId: pendingRevealWorktree.worktreeId,
count: nextRetryCount
}
if (nextRetryCount <= 8) {
requestAnimationFrame(() => setPendingRevealRetryTick((tick) => tick + 1))
} else {
pendingRevealRetryRef.current = null
clearPendingRevealWorktreeId()
}
}
if (
container &&
revealMountedWorktreeElement(
container,
pendingRevealWorktree.worktreeId,
pendingRevealWorktree.behavior
)
) {
if (pendingRevealWorktree.highlight) {
flashRevealedWorktree(pendingRevealWorktree.worktreeId)
}
pendingRevealRetryRef.current = null
clearPendingRevealWorktreeId()
return
}
if (targetRow?.type !== 'lineage-group') {
// Why: virtual row indexing can leave the card edge slightly clipped;
// stage it into the mounted window, then retry the exact DOM reveal.
virtualizer.scrollToIndex(targetIndex, {
align: 'auto',
behavior: 'auto'
})
retryExactRevealOnNextFrame()
return
}
// Why: for grouped lineage rows the virtual row is only a staging
// target. Jump it into the mounted window first, then retry the exact
// card reveal instead of clearing while a smooth virtual scroll is
// still in flight.
virtualizer.scrollToIndex(targetIndex, {
align: 'auto',
behavior: pendingRevealWorktree.behavior
behavior: 'auto'
})
clearPendingRevealWorktreeId()
retryExactRevealOnNextFrame()
return
}
if (outcome === 'clear') {
pendingRevealRetryRef.current = null
clearPendingRevealWorktreeId()
}
})
@ -960,7 +1082,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
toggleGroup,
collapsedGroups,
workspaceStatuses,
settings
settings,
pendingRevealRetryTick,
flashRevealedWorktree
])
const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache))
@ -1185,6 +1309,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
markScrollMovement()
}, [markScrollMovement])
useEffect(() => {
return () => {
if (revealHighlightTimeoutRef.current !== null) {
window.clearTimeout(revealHighlightTimeoutRef.current)
}
}
}, [])
const cleanupWorktreePointerDrag = useCallback(() => {
const drag = worktreePointerDragRef.current
if (!drag) {
@ -2112,9 +2244,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-drag-group-index={worktreeDragGroupIndex}
className={cn(
'relative transition-[opacity,transform,filter] duration-150 ease-out',
highlightedRevealWorktreeId === itemRow.worktree.id &&
'scroll-to-current-workspace-reveal-highlight',
worktreeDragState.draggingWorktreeId === itemRow.worktree.id &&
'z-20 scale-[0.985] opacity-35 saturate-75'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === itemRow.worktree.id ? 'true' : undefined
}
// Why: nested child cards live inside the parent's clickable
// card body; bubbling would activate/edit the parent too.
onClick={nested ? stopNestedWorktreeCardBubble : undefined}
@ -2197,11 +2334,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
highlightedRevealWorktreeId === child.worktree.id &&
'scroll-to-current-workspace-reveal-highlight',
isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
}
onClick={handleClick}
onDoubleClick={(event) => event.stopPropagation()}
>
@ -2416,6 +2558,7 @@ const WorktreeList = React.memo(function WorktreeList({
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar)
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
// Read tabsByWorktree when needed for filtering or sorting
@ -2705,7 +2848,6 @@ const WorktreeList = React.memo(function WorktreeList({
])
const worktrees = visibleWorktrees
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
@ -3018,6 +3160,35 @@ const WorktreeList = React.memo(function WorktreeList({
}
}, [setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState])
const handleRevealCurrentWorkspaceRequest = useCallback(() => {
if (!activeWorktreeId) {
return
}
const activeWorktree = worktreeMap.get(activeWorktreeId)
if (!activeWorktree || activeWorktree.isArchived) {
return
}
if (!worktrees.some((worktree) => worktree.id === activeWorktreeId)) {
// Why: the toolbar action promises to reveal the current workspace; when
// sidebar filters hide it, relax those filters before queuing the reveal.
clearFilters()
}
revealWorktreeInSidebar(activeWorktreeId, { behavior: 'smooth', highlight: true })
}, [activeWorktreeId, clearFilters, revealWorktreeInSidebar, worktreeMap, worktrees])
useEffect(() => {
window.addEventListener(
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
handleRevealCurrentWorkspaceRequest
)
return () => {
window.removeEventListener(
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
handleRevealCurrentWorkspaceRequest
)
}
}, [handleRevealCurrentWorkspaceRequest])
if (worktrees.length === 0) {
return (
<div data-worktree-sidebar-container className="relative min-h-0 flex-1">

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getScrollTopToRevealBounds } from './WorktreeList'
describe('getScrollTopToRevealBounds', () => {
const makeContainer = (scrollTop: number, clientHeight: number) =>
({
scrollTop,
clientHeight
}) as HTMLElement
it('scrolls upward to reveal a mounted current workspace card above the viewport', () => {
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(60)
})
it('scrolls downward to reveal a mounted current workspace card below the viewport', () => {
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(140)
})
it('does not scroll when the current workspace card is already fully visible', () => {
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBeNull()
})
})

View File

@ -0,0 +1,9 @@
export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT =
'orca-scroll-to-current-workspace-reveal-request'
export function requestScrollToCurrentWorkspaceReveal(): void {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new Event(SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT))
}

View File

@ -58,6 +58,7 @@ import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports
export type PendingSidebarWorktreeReveal = {
worktreeId: string
behavior: 'auto' | 'smooth'
highlight?: boolean
}
function clampPetSize(size: number): number {
@ -530,7 +531,10 @@ export type UISlice = {
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
revealWorktreeInSidebar: (
worktreeId: string,
options?: { behavior?: PendingSidebarWorktreeReveal['behavior'] }
options?: {
behavior?: PendingSidebarWorktreeReveal['behavior']
highlight?: boolean
}
) => void
clearPendingRevealWorktreeId: () => void
// Why: lets the SourceControl sidebar request that the diff editor scroll
@ -1121,7 +1125,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set({
pendingRevealWorktree: {
worktreeId,
behavior: options?.behavior ?? 'smooth'
behavior: options?.behavior ?? 'smooth',
...(options?.highlight ? { highlight: true } : {})
}
}),
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }),

View File

@ -0,0 +1,209 @@
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const WORKTREE_OPTION_PREFIX = 'worktree-list-option-'
function worktreeOption(page: Page, worktreeId: string) {
return page.locator(`[id="${WORKTREE_OPTION_PREFIX}${encodeURIComponent(worktreeId)}"]`)
}
async function prepareSidebarForScrollTest(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
state.setActiveView('terminal')
state.setSidebarOpen(true)
state.setGroupBy('none')
state.setSortBy('recent')
state.setShowActiveOnly(false)
state.setShowSleepingWorkspaces(true)
state.setHideDefaultBranchWorkspace(false)
state.setFilterRepoIds([])
})
}
async function forceCurrentWorkspaceClipped(page: Page, targetId: string): Promise<void> {
await page.locator('[data-worktree-sidebar]').evaluate((element, targetId) => {
const scroller = element as HTMLElement
const target = document.getElementById(`worktree-list-option-${encodeURIComponent(targetId)}`)
if (!target) {
throw new Error('Target workspace row is not mounted')
}
scroller.style.height = '72px'
scroller.style.maxHeight = '72px'
scroller.style.overflowY = 'auto'
const scrollerBounds = scroller.getBoundingClientRect()
const targetBounds = target.getBoundingClientRect()
const desiredTargetTop = scrollerBounds.bottom - 24
scroller.scrollTop += targetBounds.top - desiredTargetTop
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
window.dispatchEvent(new Event('resize'))
}, targetId)
await expect
.poll(
() =>
page.evaluate((targetId) => {
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const target = document.getElementById(
`worktree-list-option-${encodeURIComponent(targetId)}`
)
if (!scroller || !target) {
return false
}
const scrollerBounds = scroller.getBoundingClientRect()
const targetBounds = target.getBoundingClientRect()
return (
targetBounds.top < scrollerBounds.bottom && targetBounds.bottom > scrollerBounds.bottom
)
}, targetId),
{
timeout: 10_000,
message: 'Target workspace should be clipped before using the reveal button'
}
)
.toBe(true)
}
async function expectNoRevealHighlightDuring(
page: Page,
targetId: string,
durationMs: number
): Promise<void> {
const deadline = Date.now() + durationMs
while (Date.now() < deadline) {
const isHighlighted = await page.evaluate((targetId) => {
const target = document.getElementById(`worktree-list-option-${encodeURIComponent(targetId)}`)
return target?.getAttribute('data-scroll-reveal-highlight') === 'true'
}, targetId)
expect(isHighlighted).toBe(false)
await page.waitForTimeout(50)
}
}
test.describe('Reveal active workspace button', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('reveals the current workspace when it is clipped in the production sidebar', async ({
orcaPage
}) => {
await prepareSidebarForScrollTest(orcaPage)
const renderedOptions = orcaPage.locator('[data-worktree-sidebar] [role="option"]')
await expect(renderedOptions).toHaveCount(2)
const targetIdAttribute = await renderedOptions.last().getAttribute('id')
if (!targetIdAttribute?.startsWith(WORKTREE_OPTION_PREFIX)) {
throw new Error('Bottom workspace row did not expose the expected option id')
}
const targetId = decodeURIComponent(targetIdAttribute.slice(WORKTREE_OPTION_PREFIX.length))
const targetRow = worktreeOption(orcaPage, targetId)
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
await renderedOptions.last().click()
await expect(targetRow).toHaveAttribute('aria-current', 'page')
await expectNoRevealHighlightDuring(orcaPage, targetId, 400)
await expect(revealButton).toBeVisible()
await expect(revealButton).toBeEnabled()
await forceCurrentWorkspaceClipped(orcaPage, targetId)
await expect(revealButton).toBeVisible()
await expect(revealButton).toBeEnabled()
await revealButton.click()
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
await expect
.poll(
() =>
orcaPage.evaluate((targetId) => {
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const target = document.getElementById(
`worktree-list-option-${encodeURIComponent(targetId)}`
)
if (!scroller || !target) {
return false
}
const scrollerBounds = scroller.getBoundingClientRect()
const targetBounds = target.getBoundingClientRect()
return (
targetBounds.top >= scrollerBounds.top - 1 &&
targetBounds.bottom <= scrollerBounds.bottom + 1
)
}, targetId),
{
timeout: 10_000,
message: 'Reveal button did not scroll the current workspace fully into view'
}
)
.toBe(true)
await expect(revealButton).toBeVisible()
await expect(revealButton).toBeEnabled()
})
test('clears sidebar filters before revealing a hidden current workspace', async ({
orcaPage
}) => {
await prepareSidebarForScrollTest(orcaPage)
const renderedOptions = orcaPage.locator('[data-worktree-sidebar] [role="option"]')
await expect(renderedOptions).toHaveCount(2)
const targetIdAttribute = await renderedOptions.last().getAttribute('id')
if (!targetIdAttribute?.startsWith(WORKTREE_OPTION_PREFIX)) {
throw new Error('Bottom workspace row did not expose the expected option id')
}
const targetId = decodeURIComponent(targetIdAttribute.slice(WORKTREE_OPTION_PREFIX.length))
const targetRow = worktreeOption(orcaPage, targetId)
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
await renderedOptions.last().click()
await expect(targetRow).toHaveAttribute('aria-current', 'page')
await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
store.getState().setFilterRepoIds(['__filtered_repo__'])
})
await expect(renderedOptions).toHaveCount(0)
await expect(orcaPage.getByText('No workspaces found')).toBeVisible()
await revealButton.click()
await expect(targetRow).toBeVisible()
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
await expect
.poll(
() =>
orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
return store.getState().filterRepoIds
}),
{
timeout: 10_000,
message: 'Reveal button should clear repo filters that hide the current workspace'
}
)
.toEqual([])
})
})