a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(graph): prioritize expanded stack membership over collapsed stack logic

When expanding a collapsed stack, edges were incorrectly still rendered as
dotted because revisions could match two overlapping conditions:
- Part of an expanded stack (should take priority)
- Top of a collapsed nested stack (was incorrectly winning)

The fix checks expanded stack membership first, then applies collapsed stack
logic only if the revision is not in an expanded stack. This ensures edges
within expanded stacks render as solid lines.

+188 -158
+188 -158
apps/desktop/src/components/RevisionGraph.tsx
··· 3 3 import { useNavigate, useSearch } from "@tanstack/react-router"; 4 4 import { useVirtualizer } from "@tanstack/react-virtual"; 5 5 import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; 6 - import { expandedStacksAtom, inlineJumpQueryAtom } from "@/atoms"; 6 + import { expandedStacksAtom, focusPanelAtom, hoveredStackIdAtom, inlineJumpQueryAtom, viewModeAtom } from "@/atoms"; 7 7 import { ChangedFilesList } from "@/components/ChangedFilesList"; 8 8 import { 9 9 reorderForGraph, ··· 335 335 targetRevision: Revision | null; 336 336 stackTopY?: number; 337 337 stackBottomY?: number; 338 + hoveredStackId: string | null; 339 + onHoverStack: (stackId: string | null) => void; 338 340 onToggleStack?: (stackId: string) => void; 339 341 } 340 342 ··· 347 349 targetRevision, 348 350 stackTopY, 349 351 stackBottomY, 352 + hoveredStackId, 353 + onHoverStack, 350 354 onToggleStack, 351 355 }: GraphEdgeProps) { 352 356 const { ··· 359 363 collapsedCount, 360 364 expandedStackId, 361 365 } = binding; 366 + 367 + // Check if this edge's stack is currently hovered 368 + const isStackHovered = expandedStackId !== undefined && hoveredStackId === expandedStackId; 362 369 363 370 const sourceX = laneToX(sourceLane); 364 371 const targetX = laneToX(targetLane); ··· 448 455 // Use full stack bounds if available, otherwise fall back to node bounds 449 456 const hitboxY1 = stackTopY !== undefined ? stackTopY : y1; 450 457 const hitboxY2 = stackBottomY !== undefined ? stackBottomY : y2; 458 + 459 + // Apply hover styling reactively based on atom state 460 + const hoverStrokeWidth = isStackHovered ? 3 : strokeWidth; 461 + const hoverStrokeOpacity = isStackHovered ? 1 : strokeOpacity; 462 + 451 463 return ( 452 464 <g 453 465 aria-label={expandedLabel} 454 - className="cursor-pointer stack-group" 455 - data-stack-id={expandedStackId} 466 + className="cursor-pointer" 456 467 style={{ pointerEvents: "auto" }} 457 468 onClick={() => onToggleStack?.(expandedStackId)} 469 + onMouseEnter={() => onHoverStack(expandedStackId)} 470 + onMouseLeave={() => onHoverStack(null)} 458 471 > 459 472 <title>{expandedLabel}</title> 460 473 {/* Invisible wider hitbox covering full stack height */} ··· 472 485 x2={targetX} 473 486 y2={y2} 474 487 stroke={isDeemphasized ? strokeColor : sourceColor} 475 - strokeWidth={strokeWidth} 476 - strokeOpacity={strokeOpacity} 488 + strokeWidth={hoverStrokeWidth} 489 + strokeOpacity={hoverStrokeOpacity} 477 490 strokeDasharray={isDashed ? "4 4" : undefined} 478 - className="stack-edge transition-[stroke-width,stroke-opacity] duration-150" 491 + className="transition-[stroke-width,stroke-opacity] duration-150" 479 492 data-edge-type={edgeType} 480 - data-stack-id={expandedStackId} 481 493 data-source-revision={sourceRevision.change_id} 482 494 data-target-revision={targetRevision?.change_id} 483 495 /> ··· 558 570 changeIdToCommitId, 559 571 onToggleStack, 560 572 }: EdgeLayerProps) { 561 - const svgRef = useRef<SVGSVGElement>(null); 573 + // Use atom for hover state - automatically syncs with stack toggling and view mode changes 574 + const [hoveredStackId, setHoveredStackId] = useAtom(hoveredStackIdAtom); 562 575 563 576 // Add overscan for edges that might span across viewport boundary 564 577 // Use larger overscan to handle collapsed stack edges that span many rows ··· 584 597 const maxRow = Math.max(sourceRow, targetRow); 585 598 return maxRow >= startRow && minRow <= endRow; 586 599 }); 587 - 588 - // Handle hover for stack edges - make all edges in the same stack respond together 589 - // Use event delegation on the SVG to handle dynamically added groups 590 - useEffect(() => { 591 - const svg = svgRef.current; 592 - if (!svg) return; 593 - 594 - let hoveredStackId: string | null = null; 595 - 596 - const handleMouseOver = (e: Event) => { 597 - const target = e.target as HTMLElement; 598 - // Check if the event originated from a stack group 599 - const group = target.closest("g.stack-group[data-stack-id]") as HTMLElement; 600 - if (!group) return; 601 - 602 - const stackId = group.getAttribute("data-stack-id"); 603 - if (!stackId || stackId === hoveredStackId) return; 604 - 605 - hoveredStackId = stackId; 606 - // Find all edges with the same stack-id and add hover class 607 - const edges = svg.querySelectorAll(`line.stack-edge[data-stack-id="${stackId}"]`); 608 - edges.forEach((edge) => { 609 - edge.classList.add("stack-edge-hovered"); 610 - }); 611 - }; 612 - 613 - const handleMouseOut = (e: Event) => { 614 - const target = e.target as HTMLElement; 615 - const relatedTarget = (e as MouseEvent).relatedTarget as HTMLElement; 616 - 617 - // Check if we're leaving a stack group 618 - const group = target.closest("g.stack-group[data-stack-id]") as HTMLElement; 619 - if (!group) return; 620 - 621 - // Check if we're moving to another element within the same stack group 622 - if (relatedTarget && group.contains(relatedTarget)) return; 623 - 624 - const stackId = group.getAttribute("data-stack-id"); 625 - if (!stackId || stackId !== hoveredStackId) return; 626 - 627 - hoveredStackId = null; 628 - // Remove hover class from all edges with the same stack-id 629 - const edges = svg.querySelectorAll(`line.stack-edge[data-stack-id="${stackId}"]`); 630 - edges.forEach((edge) => { 631 - edge.classList.remove("stack-edge-hovered"); 632 - }); 633 - }; 634 - 635 - // Use event delegation - attach listeners to the SVG element 636 - // mouseover/mouseout bubble, unlike mouseenter/mouseleave 637 - svg.addEventListener("mouseover", handleMouseOver, true); 638 - svg.addEventListener("mouseout", handleMouseOut, true); 639 - 640 - return () => { 641 - svg.removeEventListener("mouseover", handleMouseOver, true); 642 - svg.removeEventListener("mouseout", handleMouseOut, true); 643 - }; 644 - }, []); 645 600 646 601 return ( 647 602 <svg 648 - ref={svgRef} 649 603 width={width} 650 604 height={totalHeight} 651 605 className="shrink-0 absolute top-0 left-0 z-20" ··· 687 641 } 688 642 } 689 643 644 + // Use a key that captures the edge's structural identity: 645 + // source, target (which changes when collapsed), and stack state 646 + const edgeKey = `${binding.sourceRevisionId}->${binding.targetRevisionId}:${binding.collapsedStackId ?? binding.expandedStackId ?? "none"}`; 647 + 690 648 return ( 691 649 <GraphEdge 692 - key={binding.id} 650 + key={edgeKey} 693 651 binding={binding} 694 652 sourceY={getRowCenter(sourceRow)} 695 653 targetY={ ··· 701 659 targetRevision={targetRevision} 702 660 stackTopY={stackTopY} 703 661 stackBottomY={stackBottomY} 662 + hoveredStackId={hoveredStackId} 663 + onHoverStack={setHoveredStackId} 704 664 onToggleStack={onToggleStack} 705 665 /> 706 666 ); ··· 1048 1008 1049 1009 function handleSelectFile(filePath: string) { 1050 1010 navigate({ 1011 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 1051 1012 search: { ...search, file: filePath } as any, 1052 1013 }); 1053 1014 } ··· 1080 1041 } text-card-foreground shadow-sm hover:shadow hover:cursor-pointer ${ 1081 1042 revision.is_immutable ? "opacity-60" : "" 1082 1043 } ${isDimmed ? "opacity-40" : ""}`} 1044 + data-focused={isFocused || undefined} 1045 + data-selected={isSelected || undefined} 1046 + data-checked={isChecked || undefined} 1047 + data-expanded={isExpanded || undefined} 1048 + data-change-id={revision.change_id} 1083 1049 onClick={(e) => { 1084 1050 // Prevent text selection on shift+click 1085 1051 if (e.shiftKey) { ··· 1236 1202 const navigate = useNavigate(); 1237 1203 const [inlineJumpQuery, setInlineJumpQuery] = useAtom(inlineJumpQueryAtom); 1238 1204 const inlineJumpMode = inlineJumpQuery !== null; 1205 + const [viewMode] = useAtom(viewModeAtom); 1206 + const [, setFocusPanel] = useAtom(focusPanelAtom); 1239 1207 1240 1208 // Detect collapsible stacks 1241 1209 const stacks = useMemo(() => detectStacks(revisions), [revisions]); ··· 1251 1219 1252 1220 // Track which stacks are expanded (empty = all collapsed by default) 1253 1221 const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); 1222 + // Track hovered stack for coordinated edge highlighting 1223 + const [, setHoveredStackId] = useAtom(hoveredStackIdAtom); 1254 1224 1255 1225 // Read focused stack and selection from URL params 1256 1226 const focusedStackId = useSearch({ strict: false, select: (s) => s.stack ?? null }); ··· 1272 1242 }); 1273 1243 } 1274 1244 1275 - // Update URL with focused stack 1276 - function setFocusedStackId(stackId: string | null) { 1277 - navigate({ 1278 - search: { 1279 - ...search, 1280 - stack: stackId ?? undefined, 1281 - rev: stackId ? undefined : search.rev, 1282 - } as any, 1283 - replace: true, 1284 - }); 1285 - } 1286 - 1287 1245 // Toggle a revision's checked state 1288 1246 function toggleRevisionCheck(changeId: string) { 1289 1247 const next = new Set(selectedRevisions); ··· 1386 1344 1387 1345 // Toggle stack expansion 1388 1346 function toggleStackExpansion(stackId: string) { 1347 + // Clear hover state since stack structure is changing 1348 + setHoveredStackId(null); 1389 1349 setExpandedStacks((prev) => { 1390 1350 const next = new Set(prev); 1391 1351 if (next.has(stackId)) { ··· 1397 1357 }); 1398 1358 } 1399 1359 1360 + // Toggle stack expansion and focus the top of newly revealed revisions when expanding 1361 + function handleToggleStack(stackId: string) { 1362 + const stack = stackById.get(stackId); 1363 + const isCurrentlyExpanded = expandedStacks.has(stackId); 1364 + toggleStackExpansion(stackId); 1365 + 1366 + // If expanding (not currently expanded), focus the first intermediate revision 1367 + // (the top of the newly revealed revisions, not the already-visible top of the stack) 1368 + if (!isCurrentlyExpanded && stack && stack.intermediateChangeIds.length > 0) { 1369 + navigate({ 1370 + search: { 1371 + ...search, 1372 + stack: undefined, 1373 + rev: stack.intermediateChangeIds[0], 1374 + selected: undefined, 1375 + selectionAnchor: undefined, 1376 + } as any, 1377 + replace: true, 1378 + }); 1379 + } 1380 + } 1381 + 1400 1382 // Maps for lookups - by change_id for UI, by commit_id for graph edges 1401 1383 const revisionMapByChangeId = new Map(revisions.map((r) => [r.change_id, r])); 1402 1384 const revisionMapByCommitId = new Map(revisions.map((r) => [r.commit_id, r])); 1403 - const relatedRevisions = getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 1385 + 1386 + // Compute related revisions for dimming logic 1387 + // When a stack is focused, use the stack's top and bottom as the "selected" revisions 1388 + const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 1389 + const relatedRevisions = useMemo(() => { 1390 + if (focusedStack) { 1391 + // When stack is focused, highlight the stack endpoints and their ancestors/descendants 1392 + const topRelated = getRelatedRevisions(revisions, focusedStack.topChangeId); 1393 + const bottomRelated = getRelatedRevisions(revisions, focusedStack.bottomChangeId); 1394 + // Union of both sets 1395 + return new Set([...topRelated, ...bottomRelated]); 1396 + } 1397 + return getRelatedRevisions(revisions, selectedRevision?.change_id ?? null); 1398 + }, [revisions, focusedStack, selectedRevision?.change_id]); 1404 1399 1405 1400 // Build change_id -> displayRow index map for scrolling and edge positioning 1406 1401 // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning ··· 1415 1410 } 1416 1411 1417 1412 // Create a mapping of change_id -> commit_id for edge remapping 1418 - const changeIdToCommitId = new Map<string, string>(); 1419 - for (const rev of revisions) { 1420 - changeIdToCommitId.set(rev.change_id, rev.commit_id); 1421 - } 1413 + const changeIdToCommitId = useMemo(() => { 1414 + const map = new Map<string, string>(); 1415 + for (const rev of revisions) { 1416 + map.set(rev.change_id, rev.commit_id); 1417 + } 1418 + return map; 1419 + }, [revisions]); 1422 1420 1423 1421 // Filter edge bindings to handle collapsed/expanded stacks 1424 1422 // When a stack is collapsed, edges from/to intermediates should be remapped 1425 1423 // When a stack is expanded, edges within it should be clickable to collapse 1426 1424 const filteredEdgeBindings = useMemo(() => { 1427 - // Build mapping: hidden commit_id -> { visible commit_id, stack info } 1425 + // Maps for collapsed stacks: intermediate commits -> bottom commit 1428 1426 const hiddenToVisible = new Map<string, { targetCommitId: string; stack: RevisionStack }>(); 1429 - // Build mapping: top commit_id -> stack (for marking edges as collapsed) 1430 1427 const topCommitToStack = new Map<string, RevisionStack>(); 1431 - // Build mapping: commit_id -> stack (for edges within expanded stacks) 1428 + // Map for expanded stacks: all commits in expanded stacks 1432 1429 const commitToExpandedStack = new Map<string, RevisionStack>(); 1433 1430 1434 1431 for (const stack of stacks) { 1435 - if (!expandedStacks.has(stack.id)) { 1436 - // Stack is collapsed - map all intermediates to bottom revision 1437 - const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 1438 - const topCommitId = changeIdToCommitId.get(stack.topChangeId); 1432 + const isExpanded = expandedStacks.has(stack.id); 1439 1433 1440 - if (bottomCommitId && topCommitId) { 1441 - topCommitToStack.set(topCommitId, stack); 1442 - 1443 - for (const intermediateChangeId of stack.intermediateChangeIds) { 1444 - const intermediateCommitId = changeIdToCommitId.get(intermediateChangeId); 1445 - if (intermediateCommitId) { 1446 - hiddenToVisible.set(intermediateCommitId, { 1447 - targetCommitId: bottomCommitId, 1448 - stack, 1449 - }); 1450 - } 1451 - } 1452 - } 1453 - } else { 1454 - // Stack is expanded - mark all commits in the stack for clickable edges 1434 + if (isExpanded) { 1455 1435 for (const changeId of stack.changeIds) { 1456 1436 const commitId = changeIdToCommitId.get(changeId); 1457 - if (commitId) { 1458 - commitToExpandedStack.set(commitId, stack); 1437 + if (commitId) commitToExpandedStack.set(commitId, stack); 1438 + } 1439 + } else { 1440 + const bottomCommitId = changeIdToCommitId.get(stack.bottomChangeId); 1441 + const topCommitId = changeIdToCommitId.get(stack.topChangeId); 1442 + if (!bottomCommitId || !topCommitId) continue; 1443 + 1444 + topCommitToStack.set(topCommitId, stack); 1445 + for (const intermediateChangeId of stack.intermediateChangeIds) { 1446 + const intermediateCommitId = changeIdToCommitId.get(intermediateChangeId); 1447 + if (intermediateCommitId) { 1448 + hiddenToVisible.set(intermediateCommitId, { targetCommitId: bottomCommitId, stack }); 1459 1449 } 1460 1450 } 1461 1451 } 1462 1452 } 1463 1453 1464 - // Remap edge bindings 1465 1454 const remapped: EdgeBinding[] = []; 1466 - const seen = new Set<string>(); // Deduplicate edges 1455 + const seen = new Set<string>(); 1467 1456 1468 1457 for (const binding of edgeBindings) { 1469 - let targetId = binding.targetRevisionId; 1458 + const { sourceRevisionId, targetRevisionId } = binding; 1459 + const sourceExpandedStack = commitToExpandedStack.get(sourceRevisionId); 1460 + 1461 + // Skip hidden intermediates (unless in an expanded stack) 1462 + if (hiddenToVisible.has(sourceRevisionId) && !sourceExpandedStack) continue; 1463 + 1464 + let targetId = targetRevisionId; 1470 1465 let collapsedStackId: string | undefined; 1471 1466 let collapsedCount: number | undefined; 1472 1467 let expandedStackId: string | undefined; 1473 1468 1474 - // Check if this edge originates from a collapsed stack top 1475 - const stackFromTop = topCommitToStack.get(binding.sourceRevisionId); 1476 - if (stackFromTop && hiddenToVisible.has(targetId)) { 1477 - // This is the edge from top to first intermediate - remap to bottom 1478 - const info = hiddenToVisible.get(targetId)!; 1479 - targetId = info.targetCommitId; 1480 - collapsedStackId = info.stack.id; 1481 - collapsedCount = info.stack.intermediateChangeIds.length; 1482 - } else if (hiddenToVisible.has(targetId)) { 1483 - // Remap target if it's a hidden intermediate 1484 - targetId = hiddenToVisible.get(targetId)!.targetCommitId; 1469 + if (sourceExpandedStack) { 1470 + // Source is in expanded stack - check if edge is within same stack 1471 + const targetExpandedStack = commitToExpandedStack.get(targetRevisionId); 1472 + if (targetExpandedStack?.id === sourceExpandedStack.id) { 1473 + expandedStackId = sourceExpandedStack.id; 1474 + } 1485 1475 } else { 1486 - // Check if both source and target are in the same expanded stack 1487 - const sourceStack = commitToExpandedStack.get(binding.sourceRevisionId); 1488 - const targetStack = commitToExpandedStack.get(targetId); 1489 - if (sourceStack && targetStack && sourceStack.id === targetStack.id) { 1490 - expandedStackId = sourceStack.id; 1476 + // Source not in expanded stack - apply collapsed stack remapping 1477 + const hiddenInfo = hiddenToVisible.get(targetId); 1478 + if (hiddenInfo) { 1479 + const isFromStackTop = topCommitToStack.has(sourceRevisionId); 1480 + targetId = hiddenInfo.targetCommitId; 1481 + if (isFromStackTop) { 1482 + collapsedStackId = hiddenInfo.stack.id; 1483 + collapsedCount = hiddenInfo.stack.intermediateChangeIds.length; 1484 + } 1491 1485 } 1492 1486 } 1493 1487 1494 - // Skip edges where source is a hidden intermediate 1495 - if (hiddenToVisible.has(binding.sourceRevisionId)) { 1496 - continue; 1497 - } 1498 - 1499 1488 // Deduplicate 1500 - const key = `${binding.sourceRevisionId}->${targetId}`; 1489 + const key = `${sourceRevisionId}->${targetId}`; 1501 1490 if (seen.has(key)) continue; 1502 1491 seen.add(key); 1503 1492 ··· 1530 1519 onPress: () => setDebugEnabled((prev) => !prev), 1531 1520 }); 1532 1521 1533 - // Expand selected revision with 'l' key (only expands, doesn't collapse) 1522 + // Expand selected revision with 'l' or ArrowRight key 1523 + // In overview mode: expands revision inline 1524 + // In split mode: moves focus to diff panel 1534 1525 useKeyboardShortcut({ 1535 1526 key: "l", 1536 1527 modifiers: {}, 1537 1528 onPress: () => { 1529 + if (viewMode === 2) { 1530 + // Split mode: move focus to diff panel 1531 + setFocusPanel("diff"); 1532 + return; 1533 + } 1534 + // Overview mode: expand inline 1538 1535 if (!selectedRevision) return; 1536 + if (isSelectedExpanded) return; 1537 + navigate({ 1538 + search: { ...search, expanded: true } as any, 1539 + }); 1540 + }, 1541 + }); 1539 1542 1540 - // Check if already expanded 1541 - if (isSelectedExpanded) return; // Do nothing if already expanded 1542 - 1543 - // Expand the revision by setting expanded=true in URL 1543 + useKeyboardShortcut({ 1544 + key: "ArrowRight", 1545 + modifiers: {}, 1546 + onPress: () => { 1547 + if (viewMode === 2) { 1548 + setFocusPanel("diff"); 1549 + return; 1550 + } 1551 + if (!selectedRevision) return; 1552 + if (isSelectedExpanded) return; 1544 1553 navigate({ 1545 1554 search: { ...search, expanded: true } as any, 1546 1555 }); 1547 1556 }, 1548 1557 }); 1549 1558 1550 - // Collapse selected revision with 'h' key (only collapses, doesn't expand) 1559 + // Collapse selected revision with 'h' or ArrowLeft key 1560 + // In overview mode: collapses revision inline 1561 + // In split mode: moves focus to revision list 1551 1562 useKeyboardShortcut({ 1552 1563 key: "h", 1553 1564 modifiers: {}, 1554 1565 onPress: () => { 1566 + if (viewMode === 2) { 1567 + // Split mode: move focus back to revisions (but don't do anything if already there) 1568 + // Note: This handler runs in RevisionGraph, so focus is already here 1569 + return; 1570 + } 1571 + // Overview mode: collapse inline 1555 1572 if (!selectedRevision) return; 1556 - 1557 - // Check if already collapsed 1558 - if (!isSelectedExpanded) return; // Do nothing if already collapsed 1573 + if (!isSelectedExpanded) return; 1574 + navigate({ 1575 + search: { ...search, expanded: undefined } as any, 1576 + }); 1577 + }, 1578 + }); 1559 1579 1560 - // Collapse the revision by removing expanded from URL 1561 - const { expanded: _expanded, ...restSearch } = search; 1580 + useKeyboardShortcut({ 1581 + key: "ArrowLeft", 1582 + modifiers: {}, 1583 + onPress: () => { 1584 + if (viewMode === 2) { 1585 + return; 1586 + } 1587 + if (!selectedRevision) return; 1588 + if (!isSelectedExpanded) return; 1562 1589 navigate({ 1563 - search: restSearch as any, 1590 + search: { ...search, expanded: undefined } as any, 1564 1591 }); 1565 1592 }, 1566 1593 }); ··· 1760 1787 enabled: !inlineJumpMode, 1761 1788 }); 1762 1789 1763 - // Space/Enter on collapsed stack: expand it 1790 + // Space/Enter on collapsed stack: expand it and focus the top revision 1764 1791 useKeyboardShortcut({ 1765 1792 key: " ", 1766 1793 modifiers: {}, 1767 1794 onPress: () => { 1768 1795 if (focusedStackId) { 1769 - toggleStackExpansion(focusedStackId); 1770 - setFocusedStackId(null); 1796 + handleToggleStack(focusedStackId); 1771 1797 } else if (selectedRevision) { 1772 1798 toggleRevisionCheck(selectedRevision.change_id); 1773 1799 } ··· 1780 1806 modifiers: {}, 1781 1807 onPress: () => { 1782 1808 if (focusedStackId) { 1783 - toggleStackExpansion(focusedStackId); 1784 - setFocusedStackId(null); 1809 + handleToggleStack(focusedStackId); 1785 1810 } 1786 1811 }, 1787 1812 enabled: !!focusedStackId && !inlineJumpMode, ··· 1958 1983 scrollToIndexIfNeededRef.current = (index: number) => { 1959 1984 const scrollEl = parentRef.current; 1960 1985 if (!scrollEl) return; 1961 - 1986 + 1962 1987 const scrollTop = scrollEl.scrollTop; 1963 1988 const clientHeight = scrollEl.clientHeight; 1964 - 1989 + 1965 1990 // Calculate which rows are fully visible (not just rendered with overscan) 1966 1991 // Use ceil for start (first fully visible) and floor-1 for end (last fully visible) 1967 1992 const visibleStart = Math.ceil(scrollTop / ROW_HEIGHT); 1968 1993 const visibleEnd = Math.floor((scrollTop + clientHeight) / ROW_HEIGHT) - 1; 1969 - 1994 + 1970 1995 const shouldScroll = index < visibleStart || index > visibleEnd; 1971 - 1996 + 1972 1997 // Only scroll if the item is outside the fully visible range 1973 1998 if (shouldScroll) { 1974 1999 rowVirtualizer.scrollToIndex(index, { align: "auto" }); ··· 2143 2168 }} 2144 2169 > 2145 2170 {/* Edge layer - semantic edge components positioned absolutely */} 2171 + {/* Key includes expandedStacks to force remount when stack state changes */} 2146 2172 <EdgeLayer 2173 + key={`edges-${[...expandedStacks].sort().join(",")}`} 2147 2174 bindings={filteredEdgeBindings} 2148 2175 revisionMap={revisionMapByCommitId} 2149 2176 getRowCenter={getRowCenter} ··· 2154 2181 visibleEndRow={visibleEndRow} 2155 2182 stackById={stackById} 2156 2183 changeIdToCommitId={changeIdToCommitId} 2157 - onToggleStack={toggleStackExpansion} 2184 + onToggleStack={handleToggleStack} 2158 2185 /> 2159 2186 2160 2187 {/* Virtualized rows with inline graph nodes */} ··· 2192 2219 <div className="shrink-0" style={{ width: nodeAreaWidth }} /> 2193 2220 <button 2194 2221 type="button" 2195 - onClick={() => toggleStackExpansion(stack.id)} 2222 + onClick={() => handleToggleStack(stack.id)} 2196 2223 className={`relative flex-1 mr-2 min-w-0 my-2 mx-1 cursor-pointer group ${isStackDimmed ? "opacity-40" : ""}`} 2197 2224 style={{ height: 40 }} 2225 + data-focused={isStackFocused || undefined} 2226 + data-stack-id={stack.id} 2198 2227 > 2199 2228 {/* Stacked card layers */} 2200 2229 {Array.from({ length: layers }).map((_, i) => { ··· 2256 2285 const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 2257 2286 const isFlashing = flash?.changeId === row.revision.change_id; 2258 2287 const isDimmed = 2259 - selectedRevision !== null && !relatedRevisions.has(row.revision.change_id); 2288 + (selectedRevision !== null || focusedStackId !== null) && 2289 + !relatedRevisions.has(row.revision.change_id); 2260 2290 // Only show focus if no stack is focused 2261 2291 const isFocused = 2262 2292 !focusedStackId && selectedRevision?.change_id === row.revision.change_id;