Merge main (anchored-turn chat scrolling) into perf/switch-and-scroll

Main replaced the chat timeline scroll engine while this branch was in
flight, which obsoletes two of its subareas and reshapes a third:

- Chat timeline: main's LegendList-based MessageList/ChatContainer win;
  the activation-overscan staircase targeted the removed tanstack path
  (LegendList provides adaptive rendering natively) and is dropped along
  with its test.
- Scroll shadows: main's hook-based masks stay (the virtualized list owns
  its scroll element — there is no wrapper to hand the styling to); the
  viewport-wrapper ScrollShadow rewrite, its index.css replacement, its
  test, and the call-site viewportClassName adaptations are reverted to
  main. The chat OverlayScrollbar keeps this branch's disableHorizontal.
- OverlayScrollbar: the direct-DOM rewrite lands, but binding now follows
  the live container node instead of binding once per ref object — the
  chat scroller remounts on every session switch, and a bind-once
  contract left the scrollbar attached to a dead element.
- Markdown renderer: the detached-DOM cache and warm-block fast path
  merge with main's block-commit reveal (enter cascade), streaming code
  highlighting, and gutter reservation; the per-block reconcile keeps
  both the decoration-refresh path and the reveal cascade.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 00:59:45 +03:00
69 changed files with 3108 additions and 2677 deletions
+126 -58
View File
@@ -256,81 +256,91 @@ div[data-chat-input-footer="true"] {
-webkit-appearance: none;
}
/* Scroll edge fades live on the viewport, never on the scrolling content. */
[data-scroll-shadow-viewport]::before,
[data-scroll-shadow-viewport]::after {
content: "";
pointer-events: none;
position: absolute;
z-index: 10;
opacity: 0;
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] {
/* A concrete default, not var(--scroll-shadow-size, 48px): a custom
property referencing itself is a cycle and computes to invalid, which
silently killed every mask below for consumers that don't set the
variable inline (the hook-based chat scroller). Inline styles from the
ScrollShadow component still override this. */
--scroll-shadow-size: 48px;
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::before,
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
inset-inline: 0;
[data-scroll-shadow="true"][data-orientation="vertical"] {
mask-mode: alpha;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::before {
top: 0;
height: var(--scroll-shadow-top-size, var(--scroll-shadow-size));
background: linear-gradient(
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
mask-image: linear-gradient(
to bottom,
var(--scroll-shadow-color, var(--surface-background)) 0,
var(--scroll-shadow-color, var(--surface-background)) var(--scroll-shadow-top-clear-size, 0px),
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
[data-scroll-shadow-viewport][data-orientation="vertical"]::after {
bottom: 0;
height: var(--scroll-shadow-size);
background: linear-gradient(to top, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before,
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
inset-block: 0;
width: var(--scroll-shadow-size);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::before {
left: 0;
background: linear-gradient(to right, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-orientation="horizontal"]::after {
right: 0;
background: linear-gradient(to left, var(--scroll-shadow-color, var(--surface-background)), transparent);
}
[data-scroll-shadow-viewport][data-top-scroll="true"]::before,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::before,
[data-scroll-shadow-viewport][data-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-top-bottom-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-scroll="true"]::before,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::before,
[data-scroll-shadow-viewport][data-right-scroll="true"]::after,
[data-scroll-shadow-viewport][data-left-right-scroll="true"]::after {
opacity: 1;
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent var(--scroll-shadow-top-clear-size, 0px),
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
#000 100%
);
}
/* Sticky-fade overlay: the crisp duplicate identity stays
mounted and its visibility is driven synchronously by the viewport's
top-scroll data attribute, so the overlay appears in the exact frame the
edge fade covers the real header (no blink).
mounted and its visibility is driven synchronously by the scroller's own
top-scroll data attribute the same signal that engages the mask — so the
overlay appears in the exact frame the mask hides the real header (no blink).
ScrollShadow sets data-top-scroll when nothing is below the viewport and
data-top-bottom-scroll when content is both above and below. */
.oc-sticky-fade-overlay {
opacity: 0;
}
.oc-sticky-fade-root:has(
[data-scroll-shadow-viewport]:is([data-top-scroll="true"], [data-top-bottom-scroll="true"])
) .oc-sticky-fade-overlay {
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-scroll="true"]) .oc-sticky-fade-overlay,
.oc-sticky-fade-root:has(.oc-sticky-fade-scroller[data-top-bottom-scroll="true"]) .oc-sticky-fade-overlay {
opacity: 1;
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
@keyframes spin-once {
from {
transform: rotate(0deg);
@@ -885,15 +895,15 @@ html:not(.dark) .chat-scroll {
}
/* Hide the long active todo before it can collide with the changed-files summary. */
@container status-row (max-width: 38rem) {
.status-row__active-todo {
@container composer-status-bar (max-width: 38rem) {
.composer-status-bar__active-todo {
display: none;
}
}
/* Hide the secondary changed-files label on narrow mobile layouts. */
@container status-row (max-width: 30rem) {
.status-row__changed-label {
@container composer-status-bar (max-width: 30rem) {
.composer-status-bar__changed-label {
display: none;
}
}
@@ -1353,6 +1363,64 @@ html:not(.dark) .chat-scroll {
min-width: 100%;
}
/* First uncached open of a session shows a hydration skeleton; the real
timeline replacing it fades in once instead of popping. Cached session
switches never carry this class and stay instant. */
@keyframes oc-chat-hydration-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.oc-chat-hydration-reveal {
animation: oc-chat-hydration-reveal 180ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.oc-chat-hydration-reveal {
animation: none;
}
}
/* A block committed mid-stream enters with a fade and a gentle rise —
compositor-only properties, deliberately nothing heavier: during a long
stream the follow scroll masks the entrance anyway, so the effect only
really shows on short replies, and those must not pay for a GPU filter.
Blocks committed in the same tick cascade via --oc-md-enter-delay set
inline by the renderer. */
@keyframes oc-md-block-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.oc-md-block-enter {
animation: oc-md-block-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--oc-md-enter-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.oc-md-block-enter {
animation: none;
}
}
/* While streaming defers the per-line gutter markup, hold its horizontal
footprint (2rem column + 0.75rem gap) so the finished pass only fills in
the numbers instead of shifting every code line. */
.markdown-content pre[data-md-gutter-reserved] > code {
display: block;
padding-left: 2.75rem;
}
.markdown-content [data-md-code-line-number] {
align-self: stretch;
padding-right: 0.75rem;