diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 399001225..fc1b2752c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -40,6 +40,7 @@ type WorktreeCardProps = { worktree: Worktree repo: Repo | undefined isActive: boolean + isActiveSurface?: boolean isMultiSelected?: boolean selectedWorktrees?: readonly Worktree[] hideRepoBadge?: boolean @@ -64,6 +65,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree, repo, isActive, + isActiveSurface = isActive, isMultiSelected = false, selectedWorktrees, onActivate, @@ -381,12 +383,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ className={cn( 'group relative flex items-start gap-1.5 px-1.5 py-1.5 cursor-pointer transition-all duration-200 outline-none select-none ml-1', isMultiSelected ? 'rounded-sm' : 'rounded-lg', - isActive + isActiveSurface ? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]' : isMultiSelected ? 'border border-sidebar-ring/35 bg-sidebar-accent/70 ring-1 ring-sidebar-ring/30' : 'border border-transparent hover:bg-sidebar-accent/40', - isActive && isMultiSelected && 'ring-1 ring-sidebar-ring/35', + isActiveSurface && isMultiSelected && 'ring-1 ring-sidebar-ring/35', !nativeDragEnabled && !isDeleting && '!cursor-grab', isDeleting && 'opacity-50 grayscale cursor-not-allowed', isSshDisconnected && !isDeleting && 'opacity-60' diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3acce188a..e78001c9e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -14,6 +14,7 @@ import { } from '@/store/selectors' import WorktreeCard from './WorktreeCard' import WorktreeCardAgents from './WorktreeCardAgents' +import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -126,6 +127,10 @@ function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): } } +function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { + event.stopPropagation() +} + function getWorktreeOptionId(worktreeId: string): string { return `worktree-list-option-${encodeURIComponent(worktreeId)}` } @@ -136,7 +141,6 @@ type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null groupBy: WorktreeGroupBy - showInlineAgentCards: boolean repoGroupOrdering: RepoGroupOrdering toggleGroup: (key: string) => void collapsedGroups: Set @@ -169,6 +173,7 @@ type VirtualizedWorktreeViewportProps = { onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void onPinWorktree: (worktreeId: string) => void onPinWorktrees: (worktreeIds: readonly string[]) => void + showInlineAgentCards: boolean // 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. @@ -262,7 +267,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rows, activeWorktreeId, groupBy, - showInlineAgentCards, repoGroupOrdering, toggleGroup, collapsedGroups, @@ -288,6 +292,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onMoveWorktreesToStatus, onPinWorktree, onPinWorktrees, + showInlineAgentCards, scrollOffsetRef, scrollAnchorRef }: VirtualizedWorktreeViewportProps) { @@ -295,6 +300,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressMeasurementAdjustmentUntilRef = useRef(0) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState(null) const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual' // Drag is only meaningful when repo headers are using manual order. The @@ -309,6 +315,33 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), [renderRows, activeWorktreeId] ) + const activeLineageChildRow = useMemo(() => { + if (activeWorktreeId === null) { + return null + } + for (const row of renderRows) { + if (row.type !== 'lineage-group') { + continue + } + const child = row.rows.slice(1).find((item) => item.worktree.id === activeWorktreeId) + if (child) { + return child + } + } + return null + }, [activeWorktreeId, renderRows]) + const activeLineageChildWorktreeId = activeLineageChildRow?.worktree.id ?? null + const activeLineageChildConnectionId = activeLineageChildRow?.repo?.connectionId ?? null + const activeLineageChildSshStatus = useAppStore((s) => + activeLineageChildConnectionId + ? (s.sshConnectionStates.get(activeLineageChildConnectionId)?.status ?? 'disconnected') + : null + ) + const activeLineageChildTargetLabel = useAppStore((s) => + activeLineageChildConnectionId ? s.sshTargetLabels.get(activeLineageChildConnectionId) : null + ) + const activeLineageChildSshDisconnected = + activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected' const renderRowsRef = useRef(renderRows) renderRowsRef.current = renderRows const getVirtualItemKey = useCallback( @@ -639,6 +672,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) }, [activeModal, navigateWorktree]) + // Why: lightweight nested cards do not mount WorktreeCard, so the viewport + // owns the SSH reconnect prompt for an active lineage child. + useEffect(() => { + if (activeLineageChildWorktreeId && activeLineageChildSshDisconnected) { + setLineageReconnectWorktreeId(activeLineageChildWorktreeId) + } + }, [activeLineageChildWorktreeId, activeLineageChildSshDisconnected]) + const handleContainerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { @@ -762,6 +803,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className="worktree-sidebar-scrollbar flex-1 overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px" style={WORKTREE_SIDEBAR_SCROLL_STYLE} > + {activeLineageChildConnectionId && activeLineageChildSshStatus ? ( + { + if (!open) { + setLineageReconnectWorktreeId(null) + } + }} + targetId={activeLineageChildConnectionId} + targetLabel={ + activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? '' + } + status={activeLineageChildSshStatus} + /> + ) : 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) { - activateAndRevealWorktree(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 (
) : null} - {child.lineageChildCount > 0 && child.lineageGroupKey ? ( + {child.lineageChildCount > 0 && lineageToggleGroupKey ? (
@@ -1055,7 +1186,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onClick={(event) => { event.preventDefault() event.stopPropagation() - toggleGroup(child.lineageGroupKey!) + toggleGroupWithScrollAnchor(lineageToggleGroupKey) }} > @@ -1080,7 +1211,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
) : null} {showInlineAgentCards ? ( - // Why: nested lineage children use this compact + // Why: nested lineage children use this lightweight // renderer instead of WorktreeCard, so their inline // agent rows must be mounted here explicitly. { - const lineageToggleGroupKey = itemRow.lineageGroupKey - return ( -
0 - ? `${Math.max(0, itemRow.depth - 1) * LINEAGE_INDENT}px` - : itemRow.depth > 0 - ? `${itemRow.depth * 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 - } - /> -
- ) - } - if (row.type === 'lineage-group') { const [parent, ...children] = row.rows const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) @@ -1170,7 +1245,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? renderWorktreeRow( parent, false, - children.length > 0 ? children.map(renderLineageChildPreview) : undefined, + children.length > 0 + ? children.map((child) => renderLineageChildCard(child)) + : undefined, childIsActive ) : null} @@ -1814,7 +1891,6 @@ const WorktreeList = React.memo(function WorktreeList({ rows={rows} activeWorktreeId={selectedSidebarWorktreeId} groupBy={groupBy} - showInlineAgentCards={cardProps.includes('inline-agents')} repoGroupOrdering={repoGroupOrdering} toggleGroup={toggleGroup} collapsedGroups={collapsedGroups} @@ -1842,6 +1918,7 @@ const WorktreeList = React.memo(function WorktreeList({ onMoveWorktreesToStatus={moveWorktreesToStatus} onPinWorktree={pinWorktree} onPinWorktrees={pinWorktrees} + showInlineAgentCards={cardProps.includes('inline-agents')} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index b2b23d2ba..5c192b47e 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -23,7 +23,6 @@ async function seedLineageScenario(page: Page): Promise { state.setSidebarOpen(true) state.setGroupBy('none') state.setSortBy('recent') - state.setShowWorkspaceLineage(true) const worktrees = Object.values(state.worktreesByRepo) .flat()