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: {
129129 // False on environments whose server predates thread.settle/unsettle:
130130 // the lifecycle affordances hide entirely rather than fail on click.
131131 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;
136132 isActive: boolean;
137133 jumpLabel: string | null;
138134 currentEnvironmentId: string | null;
......@@ -398,12 +394,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
398394 data-thread-item
399395 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
400396 >
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}
407397 <div
408398 role="button"
409399 tabIndex={0}
......@@ -1529,7 +1519,7 @@ export default function SidebarV2() {
15291519 ) : null}
15301520 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
15311521 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">
1532 {orderedThreads.map((thread, threadIndex) => {
1522 {orderedThreads.flatMap((thread, threadIndex) => {
15331523 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
15341524 const isSettledRow = settledThreadKeys.has(threadKey);
15351525 // Settled is the ONLY thing that collapses a row: every
......@@ -1544,10 +1534,14 @@ export default function SidebarV2() {
15441534 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),
15451535 );
15461536 const showSettledGap = !isCard && previousWasCard;
1547 return (
1537 const row = (
15481538 <SidebarV2Row
1549 showSettledGap={showSettledGap}
1550 key={threadKey}
1539 // Keyed per variant on purpose: when a thread settles, the
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"}`}
15511545 thread={thread}
15521546 variant={isCard ? "card" : "slim"}
15531547 // Every settled row can un-settle: explicit settles clear
......@@ -1582,6 +1576,28 @@ export default function SidebarV2() {
15821576 onChangeRequestState={handleChangeRequestState}
15831577 />
15841578 );
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 ];
15851601 })}
15861602 {hiddenSettledCount > 0 ? (
15871603 <li className="list-none">