From 288a8b423d6dee074285082801439733e333622d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 15:34:18 -0700 Subject: [PATCH] Improve current workspace reveal control (#2327) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeList.tsx | 1361 ++++++++++------- .../sidebar/reveal-sidebar-worktree.test.ts | 81 + .../sidebar/reveal-sidebar-worktree.ts | 24 + .../worktree-list-scroll-adjustment.test.ts | 161 +- src/renderer/src/store/slices/ui.ts | 24 +- 5 files changed, 1094 insertions(+), 557 deletions(-) create mode 100644 src/renderer/src/components/sidebar/reveal-sidebar-worktree.test.ts create mode 100644 src/renderer/src/components/sidebar/reveal-sidebar-worktree.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 630308581..024449153 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -6,7 +6,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import type { Range } from '@tanstack/react-virtual' -import { ChevronDown, CircleX, Ellipsis, Plus, Trash2, Workflow } from 'lucide-react' +import { ChevronDown, CircleX, Crosshair, Ellipsis, Plus, Trash2, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { getAllWorktreesFromState, @@ -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, @@ -97,6 +99,11 @@ const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { const GROUP_HEADER_ROW_HEIGHT = 28 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8 +type ScrollVisibilityItem = { + start: number + end: number +} + export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: { isScrolling: boolean now: number @@ -105,6 +112,73 @@ 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 shouldConsumeStartupRevealForPendingReveal(args: { + hasQueuedStartupReveal: boolean + workspaceSessionReady: boolean + persistedUIReady: boolean + pendingRevealWorktree: PendingSidebarWorktreeReveal | null +}): boolean { + return ( + !args.hasQueuedStartupReveal && + args.workspaceSessionReady && + args.persistedUIReady && + args.pendingRevealWorktree !== null + ) +} + +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' +} + +export function shouldShowFloatingCurrentWorkspaceButton(args: { + currentWorktreeId: string | null + currentRowIndex: number + currentItem: ScrollVisibilityItem | null + scrollTop: number + viewportHeight: number + pendingRevealWorktreeId: string | null +}): boolean { + if (!args.currentWorktreeId || args.pendingRevealWorktreeId === args.currentWorktreeId) { + return false + } + if (args.currentRowIndex === -1) { + return true + } + if (args.viewportHeight <= 0) { + return false + } + if (!args.currentItem) { + return true + } + + const viewportBottom = args.scrollTop + args.viewportHeight + return args.currentItem.start < args.scrollTop || args.currentItem.end > viewportBottom +} + function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false @@ -136,6 +210,30 @@ function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): event.stopPropagation() } +function FloatingCurrentWorkspaceButton({ onClick }: { onClick: () => void }): React.JSX.Element { + return ( +
+ + + + + + Scroll to the open workspace + + +
+ ) +} + function getWorktreeOptionId(worktreeId: string): string { return `worktree-list-option-${encodeURIComponent(worktreeId)}` } @@ -145,6 +243,7 @@ const LINEAGE_INDENT = 18 type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null + currentWorktreeId: string | null groupBy: WorktreeGroupBy repoGroupOrdering: RepoGroupOrdering toggleGroup: (key: string) => void @@ -152,7 +251,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 @@ -179,6 +278,7 @@ type VirtualizedWorktreeViewportProps = { onPinWorktree: (worktreeId: string) => void onPinWorktrees: (worktreeIds: readonly string[]) => void showInlineAgentCards: boolean + onRevealCurrentWorkspace: () => void // Why: broad grouping changes still remount the viewport, while add/delete // stays mounted for row-key anchoring and layout animation. These refs bridge // both paths so the virtualizer never falls back to scrollTop 0. @@ -329,6 +429,7 @@ function getActiveStickyHeaderIndex( const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ rows, activeWorktreeId, + currentWorktreeId, groupBy, repoGroupOrdering, toggleGroup, @@ -336,7 +437,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleCreateForRepo, handleRemoveRepo, activeModal, - pendingRevealWorktreeId, + pendingRevealWorktree, clearPendingRevealWorktreeId, worktrees, selectedWorktreeIds, @@ -356,12 +457,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onPinWorktree, onPinWorktrees, showInlineAgentCards, + onRevealCurrentWorkspace, scrollOffsetRef, scrollAnchorRef }: VirtualizedWorktreeViewportProps) { const scrollRef = useRef(null) + const scrollViewportFrameRef = useRef(null) const suppressMeasurementAdjustmentUntilRef = useRef(0) const directScrollInputUntilRef = useRef(0) + const [scrollViewport, setScrollViewport] = useState({ + scrollTop: scrollOffsetRef.current, + height: 0 + }) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState(null) @@ -389,6 +496,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), [renderRows, activeWorktreeId] ) + const currentWorktreeRowIndex = useMemo( + () => renderRows.findIndex((row) => renderRowContainsWorktree(row, currentWorktreeId)), + [renderRows, currentWorktreeId] + ) const activeLineageChildRow = useMemo(() => { if (activeWorktreeId === null) { return null @@ -490,6 +601,28 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp suppressMeasurementAdjustmentUntilRef.current = suppressUntil directScrollInputUntilRef.current = suppressUntil }, []) + const updateScrollViewport = useCallback(() => { + const element = scrollRef.current + if (!element) { + return + } + const next = { + scrollTop: element.scrollTop, + height: element.clientHeight + } + setScrollViewport((previous) => + previous.scrollTop === next.scrollTop && previous.height === next.height ? previous : next + ) + }, []) + const requestScrollViewportUpdate = useCallback(() => { + if (scrollViewportFrameRef.current !== null) { + return + } + scrollViewportFrameRef.current = window.requestAnimationFrame(() => { + scrollViewportFrameRef.current = null + updateScrollViewport() + }) + }, [updateScrollViewport]) const hasDirectScrollInput = useCallback( () => window.performance.now() < directScrollInputUntilRef.current, [] @@ -555,12 +688,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() let current: Worktree | undefined = targetWorktree @@ -607,22 +740,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, @@ -645,6 +789,48 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() + const currentWorktreeVirtualItem = useMemo(() => { + if (currentWorktreeRowIndex === -1) { + return null + } + const item = virtualItems.find((virtualItem) => virtualItem.index === currentWorktreeRowIndex) + return item ? { start: item.start, end: item.end } : null + }, [currentWorktreeRowIndex, virtualItems]) + const showFloatingCurrentWorkspaceButton = shouldShowFloatingCurrentWorkspaceButton({ + currentWorktreeId, + currentRowIndex: currentWorktreeRowIndex, + currentItem: currentWorktreeVirtualItem, + scrollTop: scrollViewport.scrollTop, + viewportHeight: scrollViewport.height, + pendingRevealWorktreeId: pendingRevealWorktree?.worktreeId ?? null + }) + const handleRevealCurrentWorkspace = useCallback(() => { + // Why: the reveal request hides this focused button while the list scrolls. + // Hand focus to the listbox first so keyboard users keep their context. + scrollRef.current?.focus({ preventScroll: true }) + onRevealCurrentWorkspace() + }, [onRevealCurrentWorkspace]) + useLayoutEffect(() => { + updateScrollViewport() + const element = scrollRef.current + if (!element || typeof ResizeObserver === 'undefined') { + return + } + + const observer = new ResizeObserver(requestScrollViewportUpdate) + observer.observe(element) + return () => observer.disconnect() + }, [requestScrollViewportUpdate, updateScrollViewport]) + + useEffect( + () => () => { + if (scrollViewportFrameRef.current !== null) { + window.cancelAnimationFrame(scrollViewportFrameRef.current) + scrollViewportFrameRef.current = null + } + }, + [] + ) const measureMountedRows = useCallback(() => { virtualizer.elementsCache.forEach((element) => { @@ -709,7 +895,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, @@ -841,6 +1027,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [markDirectScrollInput] ) + const handleScroll = useCallback(() => { + // Why: the floating "Current" affordance depends on scrollport visibility, + // not just TanStack's overscanned virtual row window. + markScrollMovement() + requestScrollViewportUpdate() + }, [markScrollMovement, requestScrollViewportUpdate]) const activeDescendantId = activeWorktreeId != null && @@ -924,500 +1116,533 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) return ( -
- {activeLineageChildConnectionId && activeLineageChildSshStatus ? ( - { - if (!open) { - setLineageReconnectWorktreeId(null) - } - }} - targetId={activeLineageChildConnectionId} - targetLabel={ - activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? '' - } - status={activeLineageChildSshStatus} - /> - ) : null} +
- {canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.dropIndicatorY !== null ? ( -
{ + if (!open) { + setLineageReconnectWorktreeId(null) + } + }} + targetId={activeLineageChildConnectionId} + targetLabel={ + activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? '' + } + status={activeLineageChildSshStatus} /> ) : null} - {virtualItems.map((vItem) => { - const row = renderRows[vItem.index] - if (!row) { - return null - } - - if (row.type === 'header') { - const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex, - isActiveStickyHeader - }) - const isRepoHeader = groupBy === 'repo' && row.repo !== undefined - const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined - const isDraggingThis = - canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.draggingRepoId === repoIdForHeader - const headerWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) - : null - const isPinnedHeader = row.key === PINNED_GROUP_KEY - const createState = row.repo - ? getRepoHeaderCreateState({ - repo: row.repo, - label: row.label, - sshStatus: row.repo.connectionId - ? (sshConnectionStates.get(row.repo.connectionId)?.status ?? null) - : null - }) - : null - return ( -
-
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) - : undefined - } - onDragLeave={ - isPinnedHeader - ? handleWorkspacePinDragLeave - : headerWorkspaceStatus - ? handleWorkspaceStatusDragLeave - : undefined - } - onDrop={ - headerWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) - : undefined - } - onClick={() => toggleGroupWithScrollAnchor(row.key)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggleGroupWithScrollAnchor(row.key) - } - }} - > - {row.icon ? ( -
repoDrag.onHandlePointerDown(e, repoIdForHeader) - : undefined - } - className={cn( - 'flex size-4 shrink-0 items-center justify-center rounded-[4px]', - row.repo ? 'text-muted-foreground' : row.tone - )} - > - -
- ) : null} - -
-
-
- {row.label} -
-
- {row.count} -
-
-
- -
- -
- - {row.repo && groupBy === 'repo' ? ( - - - - - - - - - Project actions - - - event.stopPropagation()} - > - { - if (row.repo) { - handleRemoveRepo(row.repo) - } - }} - > - - Remove Project - - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - {createState?.disabled ? ( - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > - - - ) : ( - - )} - - - {createState?.tooltip ?? `Create worktree for ${row.label}`} - - - ) : null} -
-
- ) - } - - const renderWorktreeRow = ( - itemRow: WorktreeItemRow, - nested: boolean, - lineageChildren?: React.ReactNode, - forceActiveSurface = false - ) => { - const lineageToggleGroupKey = itemRow.lineageGroupKey - // Why: child cards render inside the parent card body, so their - // first nested level starts flush with that inset. - const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth - return ( -
0 ? `${paddingDepth * LINEAGE_INDENT}px` : undefined - }} - > - onContextMenuSelect(event, itemRow.worktree)} - hideRepoBadge={groupBy === 'repo'} - parentLabel={ - itemRow.depth > 0 && itemRow.lineageState === 'valid' - ? undefined - : itemRow.parentLabel - } - lineageState={itemRow.lineageState} - lineageChildCount={itemRow.lineageChildCount} - lineageCollapsed={itemRow.lineageCollapsed} - lineageChildren={lineageChildren} - onLineageToggle={ - lineageToggleGroupKey - ? (event) => { - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(lineageToggleGroupKey) - } - : undefined - } - /> -
- ) - } - - const renderLineageChildCard = (child: WorktreeItemRow) => { - const isActive = activeWorktreeId === child.worktree.id - const handleClick = (event: React.MouseEvent) => { - event.preventDefault() - event.stopPropagation() - const selectionOnly = onSelectionGesture(event, child.worktree.id) - if (selectionOnly) { - return - } - activateAndRevealWorktree(child.worktree.id) - if (child.repo?.connectionId) { - const sshStatus = - useAppStore.getState().sshConnectionStates.get(child.repo.connectionId)?.status ?? - 'disconnected' - if (sshStatus !== 'connected') { - setLineageReconnectWorktreeId(child.worktree.id) - } - } +
+ {canReorderRepoHeaders && + repoDrag.state.draggingRepoId !== null && + repoDrag.state.dropIndicatorY !== null ? ( +
+ ) : null} + {virtualItems.map((vItem) => { + const row = renderRows[vItem.index] + if (!row) { + return null } - const lineageToggleGroupKey = child.lineageGroupKey - return ( -
- onContextMenuSelect(event, child.worktree)} + + if (row.type === 'header') { + const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: renderRows, + index: vItem.index, + firstHeaderIndex, + isActiveStickyHeader + }) + const isRepoHeader = groupBy === 'repo' && row.repo !== undefined + const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined + const isDraggingThis = + canReorderRepoHeaders && + repoDrag.state.draggingRepoId !== null && + repoDrag.state.draggingRepoId === repoIdForHeader + const headerWorkspaceStatus = + groupBy === 'workspace-status' + ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) + : null + const isPinnedHeader = row.key === PINNED_GROUP_KEY + const createState = row.repo + ? getRepoHeaderCreateState({ + repo: row.repo, + label: row.label, + sshStatus: row.repo.connectionId + ? (sshConnectionStates.get(row.repo.connectionId)?.status ?? null) + : null + }) + : null + return ( +
event.stopPropagation()} + onDragOver={ + isPinnedHeader + ? handleWorkspacePinDragOver + : headerWorkspaceStatus + ? (event) => handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) + : undefined + } + onDragLeave={ + isPinnedHeader + ? handleWorkspacePinDragLeave + : headerWorkspaceStatus + ? handleWorkspaceStatusDragLeave + : undefined + } + onDrop={ + headerWorkspaceStatus + ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) + : undefined + } + onClick={() => toggleGroupWithScrollAnchor(row.key)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleGroupWithScrollAnchor(row.key) + } + }} > - - - + {row.icon ? ( +
repoDrag.onHandlePointerDown(e, repoIdForHeader) + : undefined + } + className={cn( + 'flex size-4 shrink-0 items-center justify-center rounded-[4px]', + row.repo ? 'text-muted-foreground' : row.tone + )} + > + +
+ ) : null} +
-
- {child.worktree.displayName} -
-
- {child.repo && groupBy !== 'repo' ? ( - - - - {child.repo.displayName} - - - ) : null} - - {branchDisplayName(child.worktree.branch)} - -
- {child.worktree.linkedIssue || child.worktree.comment ? ( -
- {child.worktree.linkedIssue ? ( - - #{child.worktree.linkedIssue} - - ) : null} - {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null} - {child.worktree.comment} +
+
+ {row.label}
- ) : null} - {child.lineageChildCount > 0 && lineageToggleGroupKey ? ( -
- - +
+ {row.count} +
+
+
+ +
+ +
+ + {row.repo && groupBy === 'repo' ? ( + + + + - - - {child.lineageCollapsed - ? 'Show child workspaces' - : 'Hide child workspaces'} - - -
- ) : null} - {showInlineAgentCards ? ( - // Why: nested lineage children use this lightweight - // renderer instead of WorktreeCard, so their inline - // agent rows must be mounted here explicitly. - - ) : null} -
-
- -
- ) - } + + + + Project actions + + + event.stopPropagation()} + > + { + if (row.repo) { + handleRemoveRepo(row.repo) + } + }} + > + + Remove Project + + + + ) : null} + + {row.repo && groupBy === 'repo' ? ( + + + {createState?.disabled ? ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + + + ) : ( + + )} + + + {createState?.tooltip ?? `Create worktree for ${row.label}`} + + + ) : null} +
+
+ ) + } + + const renderWorktreeRow = ( + itemRow: WorktreeItemRow, + nested: boolean, + lineageChildren?: React.ReactNode, + forceActiveSurface = false + ) => { + const lineageToggleGroupKey = itemRow.lineageGroupKey + // Why: child cards render inside the parent card body, so their + // first nested level starts flush with that inset. + const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth + return ( +
0 ? `${paddingDepth * LINEAGE_INDENT}px` : undefined + }} + > + onContextMenuSelect(event, itemRow.worktree)} + hideRepoBadge={groupBy === 'repo'} + parentLabel={ + itemRow.depth > 0 && itemRow.lineageState === 'valid' + ? undefined + : itemRow.parentLabel + } + lineageState={itemRow.lineageState} + lineageChildCount={itemRow.lineageChildCount} + lineageCollapsed={itemRow.lineageCollapsed} + lineageChildren={lineageChildren} + onLineageToggle={ + lineageToggleGroupKey + ? (event) => { + event.preventDefault() + event.stopPropagation() + toggleGroupWithScrollAnchor(lineageToggleGroupKey) + } + : undefined + } + /> +
+ ) + } + + const renderLineageChildCard = (child: WorktreeItemRow) => { + const isActive = activeWorktreeId === child.worktree.id + const handleClick = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const selectionOnly = onSelectionGesture(event, child.worktree.id) + if (selectionOnly) { + return + } + activateAndRevealWorktree(child.worktree.id) + if (child.repo?.connectionId) { + const sshStatus = + useAppStore.getState().sshConnectionStates.get(child.repo.connectionId) + ?.status ?? 'disconnected' + if (sshStatus !== 'connected') { + setLineageReconnectWorktreeId(child.worktree.id) + } + } + } + const lineageToggleGroupKey = child.lineageGroupKey + return ( +
+ onContextMenuSelect(event, child.worktree)} + > +
event.stopPropagation()} + > + + + +
+
+ {child.worktree.displayName} +
+
+ {child.repo && groupBy !== 'repo' ? ( + + + + {child.repo.displayName} + + + ) : null} + + {branchDisplayName(child.worktree.branch)} + +
+ {child.worktree.linkedIssue || child.worktree.comment ? ( +
+ {child.worktree.linkedIssue ? ( + + #{child.worktree.linkedIssue} + + ) : null} + {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null} + {child.worktree.comment} +
+ ) : null} + {child.lineageChildCount > 0 && lineageToggleGroupKey ? ( +
+ + + + + + {child.lineageCollapsed + ? 'Show child workspaces' + : 'Hide child workspaces'} + + +
+ ) : null} + {showInlineAgentCards ? ( + // Why: nested lineage children use this lightweight + // renderer instead of WorktreeCard, so their inline + // agent rows must be mounted here explicitly. + + ) : null} +
+
+
+
+ ) + } + + if (row.type === 'lineage-group') { + const [parent, ...children] = row.rows + const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) + return ( +
+
+ {parent + ? renderWorktreeRow( + parent, + false, + children.length > 0 + ? children.map((child) => renderLineageChildCard(child)) + : undefined, + childIsActive + ) + : null} +
+
+ ) + } + + const itemWorkspaceStatus = + groupBy === 'workspace-status' + ? getWorkspaceStatus(row.worktree, workspaceStatuses) + : null - if (row.type === 'lineage-group') { - const [parent, ...children] = row.rows - const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) return (
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) + : undefined + } + onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} + onDrop={ + itemWorkspaceStatus + ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) + : undefined + } > -
- {parent - ? renderWorktreeRow( - parent, - false, - children.length > 0 - ? children.map((child) => renderLineageChildCard(child)) - : undefined, - childIsActive - ) - : null} -
+ {renderWorktreeRow(row, false)}
) - } - - const itemWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatus(row.worktree, workspaceStatuses) - : null - - return ( -
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) - : undefined - } - onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} - onDrop={ - itemWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) - : undefined - } - > - {renderWorktreeRow(row, false)} -
- ) - })} + })} +
+ {showFloatingCurrentWorkspaceButton ? ( + + ) : null}
) }) @@ -1510,8 +1707,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' @@ -1897,8 +2096,8 @@ const WorktreeList = React.memo(function WorktreeList({ const clearSelectionOutsideSidebar = (event: PointerEvent): void => { const target = event.target - const sidebar = document.querySelector('[data-worktree-sidebar]') - if (target instanceof Node && sidebar?.contains(target)) { + const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') + if (target instanceof Node && sidebarContainer?.contains(target)) { return } setSelectedWorktreeIds(new Set()) @@ -1946,6 +2145,49 @@ 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 ( + shouldConsumeStartupRevealForPendingReveal({ + hasQueuedStartupReveal: hasQueuedStartupRevealRef.current, + workspaceSessionReady, + persistedUIReady, + pendingRevealWorktree + }) + ) { + // Why: explicit activation/manual reveals should win startup. Treat them + // as consuming the one-shot startup pass so it cannot fire afterward. + hasQueuedStartupRevealRef.current = true + 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. @@ -2055,21 +2297,36 @@ const WorktreeList = React.memo(function WorktreeList({ } }, [setShowActiveOnly, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState]) + const activeWorktreeIsFilteredOut = + activeWorktreeId !== null && !worktrees.some((worktree) => worktree.id === activeWorktreeId) + const canRevealCurrentWorkspace = !activeWorktreeIsFilteredOut || !hasFilters + const revealCurrentWorkspaceFromFloatingButton = useCallback(() => { + if (!canRevealCurrentWorkspace) { + return + } + revealCurrentSidebarWorktree({ behavior: 'smooth' }) + }, [canRevealCurrentWorkspace]) + if (worktrees.length === 0) { return ( -
-
- No worktrees found - {hasFilters && ( - - )} +
+
+
+ No worktrees found + {hasFilters && ( + + )} +
+ {canRevealCurrentWorkspace && activeWorktreeIsFilteredOut ? ( + + ) : null}
) } @@ -2079,6 +2336,7 @@ const WorktreeList = React.memo(function WorktreeList({ key={viewportResetKey} rows={rows} activeWorktreeId={selectedSidebarWorktreeId} + currentWorktreeId={canRevealCurrentWorkspace ? activeWorktreeId : null} groupBy={groupBy} repoGroupOrdering={repoGroupOrdering} toggleGroup={toggleGroup} @@ -2086,7 +2344,7 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateForRepo={handleCreateForRepo} handleRemoveRepo={handleRemoveRepo} activeModal={activeModal} - pendingRevealWorktreeId={pendingRevealWorktreeId} + pendingRevealWorktree={pendingRevealWorktree} clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} worktrees={worktrees} selectedWorktreeIds={selectedWorktreeIds} @@ -2108,6 +2366,7 @@ const WorktreeList = React.memo(function WorktreeList({ onPinWorktree={pinWorktree} onPinWorktrees={pinWorktrees} showInlineAgentCards={cardProps.includes('inline-agents')} + onRevealCurrentWorkspace={revealCurrentWorkspaceFromFloatingButton} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> diff --git a/src/renderer/src/components/sidebar/reveal-sidebar-worktree.test.ts b/src/renderer/src/components/sidebar/reveal-sidebar-worktree.test.ts new file mode 100644 index 000000000..a9f98eb30 --- /dev/null +++ b/src/renderer/src/components/sidebar/reveal-sidebar-worktree.test.ts @@ -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)[]> +} = { + 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() + }) +}) diff --git a/src/renderer/src/components/sidebar/reveal-sidebar-worktree.ts b/src/renderer/src/components/sidebar/reveal-sidebar-worktree.ts new file mode 100644 index 000000000..9a2a03447 --- /dev/null +++ b/src/renderer/src/components/sidebar/reveal-sidebar-worktree.ts @@ -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) +} diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index ec88392be..2d3f67d3e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { shouldAdjustWorktreeSidebarMeasuredRowScroll } from './WorktreeList' +import { + shouldConsumeStartupRevealForPendingReveal, + resolvePendingSidebarReveal, + shouldAdjustWorktreeSidebarMeasuredRowScroll, + shouldShowFloatingCurrentWorkspaceButton, + shouldQueueStartupSidebarReveal +} from './WorktreeList' describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { it('suppresses measured-row scroll correction while TanStack is scrolling', () => { @@ -31,4 +37,157 @@ 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('consumes startup reveal when an explicit reveal is already pending', () => { + expect( + shouldConsumeStartupRevealForPendingReveal({ + hasQueuedStartupReveal: false, + workspaceSessionReady: true, + persistedUIReady: true, + pendingRevealWorktree: { worktreeId: 'wt-2', behavior: 'smooth' } + }) + ).toBe(true) + }) + + it('does not consume startup reveal before hydration is ready', () => { + expect( + shouldConsumeStartupRevealForPendingReveal({ + hasQueuedStartupReveal: false, + workspaceSessionReady: true, + persistedUIReady: false, + pendingRevealWorktree: { worktreeId: 'wt-2', behavior: 'smooth' } + }) + ).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') + }) + + it('shows the floating reveal action when the current workspace row is hidden', () => { + expect( + shouldShowFloatingCurrentWorkspaceButton({ + currentWorktreeId: 'wt-1', + currentRowIndex: -1, + currentItem: null, + scrollTop: 0, + viewportHeight: 400, + pendingRevealWorktreeId: null + }) + ).toBe(true) + }) + + it('shows the floating reveal action when the current workspace is outside the scrollport', () => { + expect( + shouldShowFloatingCurrentWorkspaceButton({ + currentWorktreeId: 'wt-1', + currentRowIndex: 10, + currentItem: { start: 500, end: 560 }, + scrollTop: 0, + viewportHeight: 400, + pendingRevealWorktreeId: null + }) + ).toBe(true) + }) + + it('hides the floating reveal action when the current workspace is visible', () => { + expect( + shouldShowFloatingCurrentWorkspaceButton({ + currentWorktreeId: 'wt-1', + currentRowIndex: 3, + currentItem: { start: 120, end: 180 }, + scrollTop: 100, + viewportHeight: 200, + pendingRevealWorktreeId: null + }) + ).toBe(false) + }) + + it('hides the floating reveal action while the current workspace reveal is pending', () => { + expect( + shouldShowFloatingCurrentWorkspaceButton({ + currentWorktreeId: 'wt-1', + currentRowIndex: -1, + currentItem: null, + scrollTop: 0, + viewportHeight: 400, + pendingRevealWorktreeId: 'wt-1' + }) + ).toBe(false) + }) }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index fc60260e0..40b739d70 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -47,6 +47,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 @@ -408,8 +413,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 @@ -924,9 +932,15 @@ export const createUISlice: StateCreator = (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,