Fix sidebar active workspace reveal

This commit is contained in:
brennanb2025
2026-05-19 10:49:08 -07:00
parent 363ddb5ef6
commit 7982fde510
8 changed files with 528 additions and 19 deletions
+182
View File
@@ -0,0 +1,182 @@
# Sidebar Active Workspace Reveal
## Problem or goal
When Orca opens, the worktree sidebar can land at an arbitrary scroll position instead of showing the active workspace. Users also need an explicit sidebar affordance to jump back to the current workspace and automatically open any collapsed repo, status-group, pinned, or lineage sections that hide it.
## Current behavior
- The sidebar viewport restores raw scroll offset on mount from an app-level ref, and the virtualizer also seeds its initial window from that same offset. This preserves position across sidebar remounts, but it does not ensure the active workspace is visible on app open. See [src/renderer/src/App.tsx](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/App.tsx:271) and [src/renderer/src/hooks/useVirtualizedScrollAnchor.ts](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts:154).
- The worktree list already has a reveal pipeline driven by `pendingRevealWorktreeId`. That pipeline expands collapsed lineage ancestors, the pinned group, and the active grouping bucket, then scrolls the target row into view with `align: 'auto'`. See [src/renderer/src/components/sidebar/WorktreeList.tsx](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/components/sidebar/WorktreeList.tsx:557).
- Explicit workspace activation paths call `revealWorktreeInSidebar(worktreeId)`, but startup restore does not. That means click/navigation flows reveal correctly, while initial app load can show a stale or otherwise non-useful viewport. See [src/renderer/src/lib/worktree-activation.ts](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/lib/worktree-activation.ts:181) and [src/renderer/src/store/slices/ui.ts](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/store/slices/ui.ts:922).
- The active workspace is already known in the sidebar render tree and is used to mark the current row, so the missing piece is reveal timing, not selection state. See [src/renderer/src/components/sidebar/WorktreeList.tsx](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/components/sidebar/WorktreeList.tsx:1945) and [src/renderer/src/components/sidebar/WorktreeList.tsx](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/components/sidebar/WorktreeList.tsx:1233).
- The bottom sidebar toolbar has room for an additional compact action and already uses the correct tooltip/button primitives for icon-only controls. See [src/renderer/src/components/sidebar/SidebarToolbar.tsx](/Users/thebr/orca/workspaces/orca/open-scroll/src/renderer/src/components/sidebar/SidebarToolbar.tsx:279).
## Proposed design
### 1. Keep one reveal pipeline, but make the request typed and repeatable
Do not build a separate “scroll to active workspace” implementation. Keep the existing reveal pipeline as the single mechanism that:
- resolves the target workspace,
- expands collapsed containers that hide it,
- and scrolls the virtualized list to the target row.
Replace `pendingRevealWorktreeId: string | null` with an ephemeral request object:
- `worktreeId: string`
- `reason: 'activation' | 'startup' | 'manual-button'`
- `behavior: 'auto' | 'smooth'`
- `requestKey: string` or monotonic number
Why this change:
- Startup and manual reveal need different scroll behavior, but should still share the same executor.
- A unique `requestKey` lets the same workspace be revealed twice in a row without relying on store identity quirks.
- The reveal effect can reason about whether a request is startup-only or user-triggered without adding side channels.
### 2. Add a startup-only active-workspace reveal pass, but gate it on hydrated state
Add a `useEffect` in `WorktreeList` that runs only after the sidebar has enough restored state to make a stable reveal decision.
- Preconditions:
- `activeWorktreeId` is non-null
- workspace-session hydration has completed
- persisted UI hydration has completed, so collapsed-group and filter state are final for this mount
- the active workspace exists in hydrated worktree data
- the effect has not already run for the current startup/mount cycle
- there is no existing pending reveal request
- Action:
- enqueue a reveal request for `activeWorktreeId` with `reason: 'startup'` and `behavior: 'auto'`
Why this shape:
- It intentionally funnels startup through the same expansion + virtual-scroll logic already used by explicit activation.
- It only runs once per startup/mount cycle, so it will not keep fighting user scroll after the app is open.
- It avoids changing `activeWorktreeId` or `activeView`; startup should reveal the current workspace, not reinterpret navigation state.
- Gating on hydration matters because firing before UI/session restore settles can produce a false miss, clear the request too early, or scroll to a row that moves again once collapsed/filter state finishes restoring.
Implementation detail:
- Track a local `hasQueuedInitialActiveRevealRef`.
- Reset that ref only when the sidebar truly remounts for a new app session, not when ordinary row updates happen.
- Skip the effect if a reveal is already pending so explicit navigation wins.
### 3. Preserve smooth manual reveal, but use instant startup reveal
Behavior rules:
- Existing activation flows keep `behavior: 'smooth'`.
- Startup reveal uses `behavior: 'auto'` so launch lands deterministically on the active workspace instead of animating from a stale offset.
- The manual button uses `behavior: 'smooth'`.
This keeps the list pleasant during normal interaction without making startup feel jumpy or delayed.
### 4. Add a sidebar button to reveal the current workspace
Add an icon-only ghost button in the right-side icon cluster of `SidebarToolbar`, next to the existing utility actions.
Button behavior:
- On click, if `activeWorktreeId` exists, call `revealWorktreeInSidebar(activeWorktreeId)` with `reason: 'manual-button'`.
- If no active workspace exists, disable the button.
- Tooltip label: `Reveal current workspace`.
Why this placement:
- It is always visible in the sidebar.
- It matches existing compact utility actions and tooltip patterns in the same toolbar.
- It avoids adding noise to each worktree row or to the already busier sidebar header.
### 5. Let reveal open collapsed containers, but do not clear unrelated filters
Keep current reveal semantics for collapsed groups and lineage expansion. Do not widen the feature into “clear all filters until you can see the workspace” for the manual button or startup path.
Reasoning:
- The existing activation helper already clears repo filters when switching to another workspace because that is a navigation action.
- Startup reveal and the manual button should be less destructive: they should open collapsed containers, but they should not silently discard user filter state.
- If the active workspace is excluded by filters, the button can no-op and optionally show a tooltip-disabled state later; that is a smaller behavioral change than mutating filter state behind the user’s back.
### 6. Make the reveal executor retain requests until they are actually resolvable
Update the existing reveal effect in `WorktreeList` so it only clears a pending request when one of these is true:
- the target row was found and a scroll attempt was issued; or
- the target worktree no longer exists, so the request is invalid.
If the target workspace still exists but its row is temporarily absent because grouping, filtering, or hydration inputs are still settling, keep the request queued for the next render instead of clearing it immediately.
Why this matters:
- The current bare-id flow is safe for explicit activation because the row usually exists by the time reveal runs.
- Startup reveal is more timing-sensitive: worktree data, persisted UI state, and virtual rows can settle across adjacent renders.
- Clearing on first miss would reintroduce the original bug in a more intermittent form.
## Alternatives considered
### A. Scroll directly from the toolbar button without store state
Rejected. `SidebarToolbar` does not own the virtualizer or the collapsed-group expansion logic, so this would either duplicate sidebar internals or require brittle imperative refs threaded across components.
### B. Reuse `activateAndRevealWorktree()` for the toolbar button
Rejected. The button is a visibility affordance, not navigation. Routing it through activation would incorrectly switch `activeView` to terminal and re-run selection side effects the user did not ask for.
### C. Restore only the persisted scroll offset
Rejected. The bad open-app position is a symptom of list shape changing across hydration and collapsed sections. Restoring an offset alone cannot guarantee the active workspace is visible and cannot open the containers that hide it.
## Edge cases
- Active workspace is inside collapsed lineage groups: reveal must expand every valid lineage ancestor before scrolling.
- Active workspace is pinned: reveal should expand only the pinned section, not its underlying status/repo bucket.
- Active workspace is hidden by `tasks` or `activity` full-page views: startup auto-reveal should skip, because those views intentionally suppress sidebar selection styling.
- Active workspace is filtered out by repo/active-only/default-branch filters: startup reveal and the manual button should not clear filters automatically.
- No active workspace exists: startup effect and manual button both no-op; the button should be disabled.
- Startup hydration has restored `activeWorktreeId`, but rows are not renderable yet: keep the request pending until the row can be resolved or the worktree becomes invalid.
- Another action queues an activation/manual reveal before the startup effect fires: explicit navigation wins, and startup should mark itself complete instead of enqueueing a second competing request.
- Active workspace row is already visible: reveal should remain effectively a no-op apart from clearing any pending request.
- SSH/remote workspaces: reveal logic must stay renderer-only and depend only on sidebar row presence/state, not local filesystem assumptions.
## Test plan
### Unit / component coverage
- Add `WorktreeList` tests covering startup auto-reveal queuing:
- queues exactly one reveal for the restored `activeWorktreeId`
- waits for workspace-session hydration and persisted UI hydration before queuing
- does not queue repeatedly after row/filter resorting
- skips when `pendingRevealWorktreeId` is already set
- skips when there is no active workspace
- Extend reveal-pipeline tests to cover request metadata:
- startup requests use `behavior: 'auto'`
- activation/manual requests use `behavior: 'smooth'`
- requests stay pending when the worktree exists but the row is not yet renderable
- requests clear only after scrolling or after the target worktree disappears
- Add toolbar tests for the new button:
- enabled when `activeWorktreeId` exists
- disabled when it does not
- clicking dispatches reveal for the active workspace
### Playwright coverage
- Add an end-to-end test that restores a session with:
- multiple repos/groups,
- the active workspace outside the initial viewport,
- and its parent group collapsed.
- Expect app launch to land with the active workspace visible and its required containers expanded.
- Add a second scenario where the user collapses the containing group, clicks `Reveal current workspace`, and expects the row to become visible again.
## Rollout order
1. Extend the reveal request state from `string` to a typed payload.
2. Update the existing reveal effect in `WorktreeList` to consume the new payload, honor scroll behavior, and retain unresolved requests.
3. Add the startup one-shot auto-reveal effect behind hydration gates.
4. Add the toolbar button wired to the shared reveal action.
5. Add unit/component coverage.
6. Add Playwright coverage for startup and manual reveal.
## ref-oss usage
Not used. The risk is local to Orca’s existing virtualized sidebar and reveal behavior, and the current in-repo reveal pipeline already provides the implementation pattern to reuse.
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { canRevealCurrentWorkspace, revealCurrentWorkspaceFromToolbar } from './SidebarToolbar'
describe('SidebarToolbar reveal current workspace action', () => {
it('disables the action when there is no active workspace', () => {
expect(canRevealCurrentWorkspace(null)).toBe(false)
})
it('enables the action when an active workspace exists', () => {
expect(canRevealCurrentWorkspace('wt-1')).toBe(true)
})
it('uses smooth reveal behavior from the toolbar action', () => {
const revealCurrentWorkspace = vi.fn(() => true)
expect(revealCurrentWorkspaceFromToolbar(revealCurrentWorkspace)).toBe(true)
expect(revealCurrentWorkspace).toHaveBeenCalledWith({ behavior: 'smooth' })
})
})
@@ -3,6 +3,7 @@ import {
BookOpen,
Boxes,
CircleHelp,
Crosshair,
ExternalLink,
FolderPlus,
Github,
@@ -33,6 +34,7 @@ import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import type { GitHubViewer } from '../../../../shared/types'
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
import { revealCurrentSidebarWorktree } from './reveal-sidebar-worktree'
const GITHUB_ISSUES_URL = 'https://github.com/stablyai/orca/issues/'
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
@@ -47,6 +49,16 @@ type SubmitIdentity = {
githubEmail: string | null
}
export function canRevealCurrentWorkspace(activeWorktreeId: string | null): boolean {
return activeWorktreeId !== null
}
export function revealCurrentWorkspaceFromToolbar(
revealCurrentWorkspace: typeof revealCurrentSidebarWorktree
): boolean {
return revealCurrentWorkspace({ behavior: 'smooth' })
}
function openExternalUrl(url: string): void {
void window.api.shell.openUrl(url)
}
@@ -259,6 +271,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const openSkillsPage = useAppStore((s) => s.openSkillsPage)
const openSpacePage = useAppStore((s) => s.openSpacePage)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const [feedbackOpen, setFeedbackOpen] = useState(false)
const lastShowOnboardingAtRef = React.useRef(0)
@@ -296,6 +309,26 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label="Reveal current workspace"
className="text-muted-foreground"
disabled={!canRevealCurrentWorkspace(activeWorktreeId)}
onClick={() => {
revealCurrentWorkspaceFromToolbar(revealCurrentSidebarWorktree)
}}
>
<Crosshair className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reveal current workspace
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
@@ -83,6 +83,8 @@ import {
import { branchDisplayName } from './WorktreeCardHelpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRepoHeaderCreateState } from './repo-header-create-state'
import { revealCurrentSidebarWorktree } from './reveal-sidebar-worktree'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
// How long to wait after a sortEpoch bump before actually re-sorting.
// Prevents jarring position shifts when background events (AI starting work,
@@ -105,6 +107,34 @@ export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
return !args.isScrolling && args.now >= args.suppressUntil
}
export function shouldQueueStartupSidebarReveal(args: {
hasQueuedStartupReveal: boolean
workspaceSessionReady: boolean
persistedUIReady: boolean
activeWorktreeId: string | null
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
renderRowCount: number
}): boolean {
return (
!args.hasQueuedStartupReveal &&
args.workspaceSessionReady &&
args.persistedUIReady &&
args.activeWorktreeId !== null &&
args.pendingRevealWorktree === null &&
args.renderRowCount > 0
)
}
export function resolvePendingSidebarReveal(args: {
targetIndex: number
targetWorktreeStillExists: boolean
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
if (args.targetIndex !== -1) {
return 'scroll-and-clear'
}
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
@@ -152,7 +182,7 @@ type VirtualizedWorktreeViewportProps = {
handleCreateForRepo: (repoId: string) => void
handleRemoveRepo: (repo: Repo) => void
activeModal: string
pendingRevealWorktreeId: string | null
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
clearPendingRevealWorktreeId: () => void
worktrees: Worktree[]
selectedWorktreeIds: ReadonlySet<string>
@@ -336,7 +366,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleCreateForRepo,
handleRemoveRepo,
activeModal,
pendingRevealWorktreeId,
pendingRevealWorktree,
clearPendingRevealWorktreeId,
worktrees,
selectedWorktreeIds,
@@ -555,12 +585,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
})
React.useEffect(() => {
if (!pendingRevealWorktreeId) {
if (!pendingRevealWorktree) {
return
}
{
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktreeId)
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
if (targetWorktree && !targetWorktree.isPinned) {
const seen = new Set<string>()
let current: Worktree | undefined = targetWorktree
@@ -607,22 +637,33 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
requestAnimationFrame(() => {
const targetIndex = renderRows.findIndex((row) =>
renderRowContainsWorktree(row, pendingRevealWorktreeId)
const targetWorktreeStillExists = worktrees.some(
(worktree) => worktree.id === pendingRevealWorktree.worktreeId
)
if (targetIndex !== -1) {
const targetIndex = renderRows.findIndex((row) =>
renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId)
)
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.
virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'smooth' })
virtualizer.scrollToIndex(targetIndex, {
align: 'auto',
behavior: pendingRevealWorktree.behavior
})
clearPendingRevealWorktreeId()
return
}
if (outcome === 'clear') {
clearPendingRevealWorktreeId()
}
clearPendingRevealWorktreeId()
})
}, [
pendingRevealWorktreeId,
pendingRevealWorktree,
groupBy,
worktrees,
repoMap,
@@ -709,7 +750,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// rendered rows. Otherwise Cmd+Shift+Up/Down would skip any worktree
// hidden in a collapsed group — in particular it couldn't cross the
// Pinned/All boundary when either section is collapsed. Reveal will
// uncollapse the target section (see pendingRevealWorktreeId effect).
// uncollapse the target section (see pendingRevealWorktree effect).
const worktreeRows = buildRows(
groupBy,
worktrees,
@@ -1510,8 +1551,10 @@ const WorktreeList = React.memo(function WorktreeList({
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId)
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
// Read tabsByWorktree when needed for filtering or sorting
const needsTabs = showActiveOnly || sortBy === 'smart'
@@ -1946,6 +1989,36 @@ const WorktreeList = React.memo(function WorktreeList({
// sidebar card should appear selected while one of them is active.
const selectedSidebarWorktreeId =
activeView === 'tasks' || activeView === 'activity' ? null : activeWorktreeId
const hasQueuedStartupRevealRef = useRef(false)
useEffect(() => {
if (!workspaceSessionReady || !persistedUIReady) {
return
}
if (
!shouldQueueStartupSidebarReveal({
hasQueuedStartupReveal: hasQueuedStartupRevealRef.current,
workspaceSessionReady,
persistedUIReady,
activeWorktreeId,
pendingRevealWorktree,
renderRowCount: rows.length
})
) {
return
}
// Why: session hydration restores the active workspace selection without
// going through the normal activation helper, so queue one non-animated
// reveal here to correct any stale virtualized scroll offset on startup.
hasQueuedStartupRevealRef.current = revealCurrentSidebarWorktree({ behavior: 'auto' })
}, [
activeWorktreeId,
pendingRevealWorktree,
persistedUIReady,
rows.length,
workspaceSessionReady
])
// Why layout effect instead of effect: the global Cmd/Ctrl+1–9 key handler
// can fire immediately after React commits the new grouped/collapsed order.
@@ -2086,7 +2159,7 @@ const WorktreeList = React.memo(function WorktreeList({
handleCreateForRepo={handleCreateForRepo}
handleRemoveRepo={handleRemoveRepo}
activeModal={activeModal}
pendingRevealWorktreeId={pendingRevealWorktreeId}
pendingRevealWorktree={pendingRevealWorktree}
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
worktrees={worktrees}
selectedWorktreeIds={selectedWorktreeIds}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const revealWorktreeInSidebar = vi.fn()
const baseState: {
activeWorktreeId: string | null
revealWorktreeInSidebar: typeof revealWorktreeInSidebar
worktreesByRepo: Record<string, ({ id: string; repoId: string } & Record<string, unknown>)[]>
} = {
activeWorktreeId: 'wt-1',
revealWorktreeInSidebar,
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/one',
displayName: 'One',
branch: 'one',
head: 'abc',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
]
}
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => baseState
}
}))
describe('reveal-sidebar-worktree', () => {
beforeEach(() => {
revealWorktreeInSidebar.mockReset()
baseState.activeWorktreeId = 'wt-1'
})
it('reveals the current workspace with the requested behavior', async () => {
const { revealCurrentSidebarWorktree } = await import('./reveal-sidebar-worktree')
expect(revealCurrentSidebarWorktree({ behavior: 'smooth' })).toBe(true)
expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1', { behavior: 'smooth' })
})
it('can reveal a specific workspace without changing other sidebar state', async () => {
const { revealSidebarWorktree } = await import('./reveal-sidebar-worktree')
expect(revealSidebarWorktree('wt-1', { behavior: 'auto' })).toBe(true)
expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1', { behavior: 'auto' })
})
it('does not reveal a missing workspace', async () => {
const { revealSidebarWorktree } = await import('./reveal-sidebar-worktree')
expect(revealSidebarWorktree('missing', { behavior: 'auto' })).toBe(false)
expect(revealWorktreeInSidebar).not.toHaveBeenCalled()
})
it('does nothing when there is no active workspace', async () => {
const { revealCurrentSidebarWorktree } = await import('./reveal-sidebar-worktree')
baseState.activeWorktreeId = null
expect(revealCurrentSidebarWorktree({ behavior: 'smooth' })).toBe(false)
expect(revealWorktreeInSidebar).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
export function revealSidebarWorktree(
worktreeId: string,
options?: { behavior?: 'auto' | 'smooth' }
): boolean {
const state = useAppStore.getState()
const worktree = findWorktreeById(state.worktreesByRepo, worktreeId)
if (!worktree) {
return false
}
state.revealWorktreeInSidebar(worktreeId, options)
return true
}
export function revealCurrentSidebarWorktree(options?: { behavior?: 'auto' | 'smooth' }): boolean {
const activeWorktreeId = useAppStore.getState().activeWorktreeId
if (!activeWorktreeId) {
return false
}
return revealSidebarWorktree(activeWorktreeId, options)
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { shouldAdjustWorktreeSidebarMeasuredRowScroll } from './WorktreeList'
import {
resolvePendingSidebarReveal,
shouldAdjustWorktreeSidebarMeasuredRowScroll,
shouldQueueStartupSidebarReveal
} from './WorktreeList'
describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => {
it('suppresses measured-row scroll correction while TanStack is scrolling', () => {
@@ -31,4 +35,83 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => {
})
).toBe(true)
})
it('queues a startup reveal once the active workspace and rows are ready', () => {
expect(
shouldQueueStartupSidebarReveal({
hasQueuedStartupReveal: false,
workspaceSessionReady: true,
persistedUIReady: true,
activeWorktreeId: 'wt-1',
pendingRevealWorktree: null,
renderRowCount: 3
})
).toBe(true)
})
it('does not queue a startup reveal when another reveal is already pending', () => {
expect(
shouldQueueStartupSidebarReveal({
hasQueuedStartupReveal: false,
workspaceSessionReady: true,
persistedUIReady: true,
activeWorktreeId: 'wt-1',
pendingRevealWorktree: { worktreeId: 'wt-2', behavior: 'smooth' },
renderRowCount: 3
})
).toBe(false)
})
it('does not queue a startup reveal twice', () => {
expect(
shouldQueueStartupSidebarReveal({
hasQueuedStartupReveal: true,
workspaceSessionReady: true,
persistedUIReady: true,
activeWorktreeId: 'wt-1',
pendingRevealWorktree: null,
renderRowCount: 3
})
).toBe(false)
})
it('does not queue a startup reveal before hydration is ready', () => {
expect(
shouldQueueStartupSidebarReveal({
hasQueuedStartupReveal: false,
workspaceSessionReady: false,
persistedUIReady: true,
activeWorktreeId: 'wt-1',
pendingRevealWorktree: null,
renderRowCount: 3
})
).toBe(false)
})
it('keeps pending reveal requests when the worktree still exists but the row is unresolved', () => {
expect(
resolvePendingSidebarReveal({
targetIndex: -1,
targetWorktreeStillExists: true
})
).toBe('keep-pending')
})
it('clears pending reveal requests once the target disappears', () => {
expect(
resolvePendingSidebarReveal({
targetIndex: -1,
targetWorktreeStillExists: false
})
).toBe('clear')
})
it('scrolls and clears once the target row is resolvable', () => {
expect(
resolvePendingSidebarReveal({
targetIndex: 4,
targetWorktreeStillExists: true
})
).toBe('scroll-and-clear')
})
})
+19 -5
View File
@@ -46,6 +46,11 @@ import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models'
import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache'
import { isGitRepoKind } from '../../../../shared/repo-kind'
export type PendingSidebarWorktreeReveal = {
worktreeId: string
behavior: 'auto' | 'smooth'
}
function clampPetSize(size: number): number {
if (!Number.isFinite(size)) {
return PET_SIZE_DEFAULT
@@ -405,8 +410,11 @@ export type UISlice = {
* problem. */
petSize: number
setPetSize: (size: number) => void
pendingRevealWorktreeId: string | null
revealWorktreeInSidebar: (worktreeId: string) => void
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
revealWorktreeInSidebar: (
worktreeId: string,
options?: { behavior?: PendingSidebarWorktreeReveal['behavior'] }
) => void
clearPendingRevealWorktreeId: () => void
// Why: lets the SourceControl sidebar request that the diff editor scroll
// to a specific note. Cleared by the diff decorator after it reveals the
@@ -919,9 +927,15 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
return partial
}),
pendingRevealWorktreeId: null,
revealWorktreeInSidebar: (worktreeId) => set({ pendingRevealWorktreeId: worktreeId }),
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktreeId: null }),
pendingRevealWorktree: null,
revealWorktreeInSidebar: (worktreeId, options) =>
set({
pendingRevealWorktree: {
worktreeId,
behavior: options?.behavior ?? 'smooth'
}
}),
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }),
scrollToDiffCommentId: null,
setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }),
persistedUIReady: false,