From e27f3df39779f4758404a3bd0332c401ed85a493 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 25 May 2026 17:57:30 -0700 Subject: [PATCH] Re-add scroll to open workspace control (#2796) Co-authored-by: Orca --- src/renderer/src/assets/main.css | 50 +++++ .../ScrollToCurrentWorkspaceToolbarButton.tsx | 27 +++ .../src/components/sidebar/SidebarToolbar.tsx | 2 + .../src/components/sidebar/WorktreeList.tsx | 193 +++++++++++++++- .../worktree-scroll-to-current-button.test.ts | 22 ++ .../lib/scroll-to-current-workspace-status.ts | 9 + src/renderer/src/store/slices/ui.ts | 9 +- tests/e2e/worktree-scroll-to-current.spec.ts | 209 ++++++++++++++++++ 8 files changed, 508 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts create mode 100644 src/renderer/src/lib/scroll-to-current-workspace-status.ts create mode 100644 tests/e2e/worktree-scroll-to-current.spec.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 943c49648..97719fcb2 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx new file mode 100644 index 000000000..3cdabdd05 --- /dev/null +++ b/src/renderer/src/components/sidebar/ScrollToCurrentWorkspaceToolbarButton.tsx @@ -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 ( + + + + + + Reveal active workspace + + + ) +} diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 6ce03ff9e..476b4d42e 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -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() {
+ diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 23e2f2136..c210cb9ed 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 +): 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( WORKTREE_ROW_DRAG_INITIAL_STATE ) + const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) + const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState( + null + ) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) + const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const revealHighlightTimeoutRef = useRef(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 (
diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts new file mode 100644 index 000000000..933261f20 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/scroll-to-current-workspace-status.ts b/src/renderer/src/lib/scroll-to-current-workspace-status.ts new file mode 100644 index 000000000..dd84b5c0a --- /dev/null +++ b/src/renderer/src/lib/scroll-to-current-workspace-status.ts @@ -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)) +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index b57c1100f..976c470d0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (set, get) set({ pendingRevealWorktree: { worktreeId, - behavior: options?.behavior ?? 'smooth' + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight ? { highlight: true } : {}) } }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts new file mode 100644 index 000000000..2bcd821c8 --- /dev/null +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -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 { + 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 { + 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('[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 { + 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('[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([]) + }) +})