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([])
+ })
+})