authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-22 16:21:45+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 16:21:45+02:00
log18b468871e6a3bfad207109898011ea330afeadb
tree680cf3ad5504db62dc0d320779be2acb0678c99d
parent020179c19a0073d130c30083c7623cf1196688e7
signature Signed by PGP key B5690EEEBB952194

Stabilize sidebar settling animations (#4280)


1 files changed, 30 insertions(+), 14 deletions(-)

apps/web/src/components/SidebarV2.tsx+30-14
...@@ -129,10 +129,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -129,10 +129,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
129 // False on environments whose server predates thread.settle/unsettle:129 // False on environments whose server predates thread.settle/unsettle:
130 // the lifecycle affordances hide entirely rather than fail on click.130 // the lifecycle affordances hide entirely rather than fail on click.
131 settlementSupported: boolean;131 settlementSupported: boolean;
132 // Marks where active work transitions into history: a quiet labeled
133 // rule above the first settled row, so the tail reads as a named zone
134 // rather than an unexplained gap.
135 showSettledGap?: boolean;
136 isActive: boolean;132 isActive: boolean;
137 jumpLabel: string | null;133 jumpLabel: string | null;
138 currentEnvironmentId: string | null;134 currentEnvironmentId: string | null;
...@@ -398,12 +394,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -398,12 +394,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
398 data-thread-item394 data-thread-item
399 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"395 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
400 >396 >
401 {props.showSettledGap ? (
402 <div aria-hidden className="mb-1 mt-3 flex items-center gap-2 px-2.5">
403 <span className="text-[10px] font-medium text-muted-foreground/50">Settled</span>
404 <span className="h-px flex-1 bg-border/60" />
405 </div>
406 ) : null}
407 <div397 <div
408 role="button"398 role="button"
409 tabIndex={0}399 tabIndex={0}
...@@ -1529,7 +1519,7 @@ export default function SidebarV2() {...@@ -1529,7 +1519,7 @@ export default function SidebarV2() {
1529 ) : null}1519 ) : null}
1530 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">1520 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
1531 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">1521 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">
1532 {orderedThreads.map((thread, threadIndex) => {1522 {orderedThreads.flatMap((thread, threadIndex) => {
1533 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));1523 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1534 const isSettledRow = settledThreadKeys.has(threadKey);1524 const isSettledRow = settledThreadKeys.has(threadKey);
1535 // Settled is the ONLY thing that collapses a row: every1525 // Settled is the ONLY thing that collapses a row: every
...@@ -1544,10 +1534,14 @@ export default function SidebarV2() {...@@ -1544,10 +1534,14 @@ export default function SidebarV2() {
1544 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),1534 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),
1545 );1535 );
1546 const showSettledGap = !isCard && previousWasCard;1536 const showSettledGap = !isCard && previousWasCard;
1547 return (1537 const row = (
1548 <SidebarV2Row1538 <SidebarV2Row
1549 showSettledGap={showSettledGap}1539 // Keyed per variant on purpose: when a thread settles, the
1550 key={threadKey}1540 // card fades out in place and the slim row fades in at its
1541 // settled position instead of one element FLIP-sliding
1542 // through every row in between (rows here are translucent,
1543 // so a crossing row reads as text painted over text).
1544 key={`${threadKey}:${isCard ? "card" : "slim"}`}
1551 thread={thread}1545 thread={thread}
1552 variant={isCard ? "card" : "slim"}1546 variant={isCard ? "card" : "slim"}
1553 // Every settled row can un-settle: explicit settles clear1547 // Every settled row can un-settle: explicit settles clear
...@@ -1582,6 +1576,28 @@ export default function SidebarV2() {...@@ -1582,6 +1576,28 @@ export default function SidebarV2() {
1582 onChangeRequestState={handleChangeRequestState}1576 onChangeRequestState={handleChangeRequestState}
1583 />1577 />
1584 );1578 );
1579 if (!showSettledGap) return [row];
1580 // The divider is its own keyed list item (not part of the first
1581 // settled row): it keeps one stable DOM node at the boundary,
1582 // so settling a thread slides it instead of teleporting it
1583 // along with whichever row happens to be first in the tail —
1584 // and row heights stay independent of neighbor classification.
1585 return [
1586 <li
1587 key="settled-divider"
1588 aria-hidden
1589 data-thread-selection-safe
1590 className="list-none"
1591 >
1592 <div className="mb-1 mt-3 flex items-center gap-2 px-2.5">
1593 <span className="text-[10px] font-medium text-muted-foreground/50">
1594 Settled
1595 </span>
1596 <span className="h-px flex-1 bg-border/60" />
1597 </div>
1598 </li>,
1599 row,
1600 ];
1585 })}1601 })}
1586 {hiddenSettledCount > 0 ? (1602 {hiddenSettledCount > 0 ? (
1587 <li className="list-none">1603 <li className="list-none">