B4 — the Slack shape, affordances, virtualization - #46
Conversation
ThreadFeedRow is the inline-expanding variant alongside the inbox-shaped ThreadOutcomeCard, which is unchanged — a second component, not a mode on the first. ThreadFeed gains variant and renderRow. Both its reduced-motion and animated branches carry the new path. The row mounts its transcript lazily and keeps it mounted, hiding it with CSS on collapse. That guarantee is owned here rather than delegated to Mantine Collapse: on the installed 9.3.0 a bare Collapse happens to keep children mounted, but Mantine master has flipped its defaults to keepMounted plus keepMountedMode activity, which wraps children in React 19 Activity — and a hidden Activity destroys the subtree's effects. Delegating would silently reintroduce a double stream replay on the next Mantine bump. Per-message affordances add a relative timestamp, copy, and regenerate on the last assistant message only, plus consumer actions. groupConsecutive suppresses the role label and chrome for a same-role message within five minutes, so ChatMessage.createdAt is finally rendered. It defaults on, so an existing transcript loses those labels on upgrade; pass groupConsecutive false to keep them. ThreadTranscript can virtualize. The props union is the guard: virtualize requires height and forbids it otherwise, because a virtualizer needs a measured scroll container — and a virtualized transcript owns that node, so it must not be nested inside BasaltStickToBottom. Rows are variable height and measured dynamically; a zero measurement is treated as unreliable and the last good size kept, because a row inside a display:none ancestor measures zero and would otherwise poison the cache permanently. initialScroll defaults to end, so a transcript opens at the newest message. Firing the jump is not enough: virtual-core writes a stale anchor offset back over it on the following commit, so the jump is confirmed by a real scrollTop read and re-fired if clobbered, bounded by an attempt cap. estimateSize defaults to 160 rather than 96, against a measured mean row height of about 145. The old value grew total size by 46 percent across one descent and shrank the scrollbar thumb the whole way down.
…cause ThreadWorkspace rendered its "no threads yet" empty state while the store was still hydrating. ThreadsStore exposes hydrated for exactly this and nothing in the Mantine layer read it, so a server-backed store with real threads showed an empty inbox and then swapped. It held only because the playground's in-memory adapter seeds an empty map, so there was nothing to flash to. A hydrating store that already carries threads still renders them — hydrating is not empty — and the synchronous store pins hydrated true, so it never sees the new branch. The adapter's write queue drained dependent writes after their thread had rolled away, and each failure overwrote the error before it. A failed create surfaced as "setStatus: unknown thread" more than a second later, with the real cause gone. Writes for a rolled-back thread are now dropped rather than sent; independent failures still surface, and other threads' queues are untouched. That record is kept for the store's lifetime rather than cleared when the queue drains. The cascade is not confined to the synchronous send path — the completion path fires when a stream ends, long after the chain has drained, and reproduced the same overwrite. It is bounded because create mints its own id, so an id in the set can never be created again.
crypto.randomUUID exists only in secure contexts, so a consumer served over plain HTTP on a LAN hostname threw a TypeError on ordinary actions. mintThreadId covered the two store create sites; the rest of the agent layer did not. Ids are classified rather than blanket-replaced, because the two cases differ in kind. A thread id may fall back to a non-random rung: a collision costs a locally duplicated thread, which is visible and recoverable. A message id may not. appendMessage is idempotent on it, so a collision is not two rows but one — the second write silently no-ops and its content is gone with nothing downstream able to tell. mintMessageId therefore throws rather than degrading, which is the one place in this layer where a loud failure beats a quiet one. That throw is a write-path decision, not a render-path one, and the render path still degrades: spliceText clamps, coalesceParts degrades, a fence renderer that throws does not take the message down. finalizeStop could leave a thread streaming forever. It appended before setting status, and any throw in that window — the new mint, or the consumer's own store call — skipped the teardown. Since stop had already dropped the controller, a second stop was a silent no-op and the run was unrecoverable: a thread that claims to be streaming, cannot be stopped, and cannot be typed into. Each store call is guarded individually and the teardown is unconditional. The unmount cleanup aborted every controller without clearing the runs it had just killed. On a fiber that survives its effects re-running — StrictMode's double invoke, or an Activity hide and show — that left the same phantom. It now tears down exactly the threads it aborts, and stop settles a thread whose controller is already gone instead of returning. consumeAndFinalize's abort guard is deliberately untouched: it cannot tell an abort from a supersede, so a teardown there would clobber a resumed run.
The three agent rules were already error in this repo and only warn in the preset consumers extend; raw-scroll-container was warn in both. All four are now error, one minor after the release that introduced the API they guard, which is the grace period this repo has used before. raw-scroll-container's own documentation said it was warning-level by design. That is no longer true, so the documentation changed with it rather than being left contradicting the code. theme-allow remains the opt-out, and the legitimate scroll owners already carry it. This will fail lint for a consumer on the preset who had warnings. That is the point of the rules: agent-no-raw-usechat and agent-resume-guard exist to stop a consumer hand-rolling what this package now provides, and leaving them at warn while shipping the replacement would waste them.
Four demo pages for the release gate: the inline feed against the inbox variant side by side, a 500-message virtualized transcript, a virtualized transcript inside a collapsible row, and a streaming turn into a virtualized transcript. The last one exists because nothing had ever put streaming and virtualization together. anchorTo end, followOnAppend and scrollEndThreshold shipped on a research note, unobservable in happy-dom, which has no layout or scroll engine — a grep of the whole test tree for them returned one hit and it was a comment. Driving these pages measured all three: following holds at zero distance while streaming, moves scrollTop by exactly zero when the reader has scrolled up, and the 64px threshold is sharp at four probe distances. The pages carry their own instrumentation because the invariants are not visible otherwise: per-row mount and stream-start counters make a duplicated replay legible rather than something to take on trust, and overscan, estimateSize and initialScroll are exposed as controls so the options are exercisable from outside the package. Setting estimateSize back to 96 reproduces the pre-1.13.0 scrollbar growth on demand, which is what turns "the symptom is gone" into knowing what caused it.
📦 basalt-ui package modifiedTrigger the Make Release workflow after merging to publish to npm. |
WalkthroughThe PR adds inline thread-feed rows, transcript affordances and virtualization, hydration-aware workspace rendering, resilient agent identifiers and stream cleanup, four playground demonstrations, expanded package exports and documentation, and stricter Basalt lint rules. ChangesAgent chat UI
Agent runtime reliability
Playground demonstrations
Lint enforcement
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/basalt-ui/src/agent/use-agent-thread-runs.ts (1)
619-628: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
lastInputRefis mutated before the mint, so the "true no-op" guarantee is not exact.Line 619 writes
lastInputRef.currentbeforemintMessageId()runs at line 623. IfmintMessageIdthrows on rung 3, the input stays cached. The thread is then not "exactly as it was before the call" as thestartJSDoc states at lines 112-114:retry(threadId)changes from a documented no-op into a call that throws, for a thread that never had a turn.Move the cache write after the message is constructed. That makes the throw a true no-op and keeps the JSDoc accurate.
🛠️ Proposed fix
if (controllersRef.current.has(threadId)) return - lastInputRef.current.set(threadId, input) - const userMessage: ChatMessage<TPart> = { // Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId. id: mintMessageId(), role: 'user', parts: toUserPartsRef.current(input), createdAt: Date.now(), } + // After the mint deliberately: a rung-3 throw from mintMessageId must leave nothing behind, + // including a cached input that would make retry() reachable for a turn that never started. + lastInputRef.current.set(threadId, input) storeRef.current.appendMessage(threadId, userMessage)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent/use-agent-thread-runs.ts` around lines 619 - 628, Move the lastInputRef.current.set(threadId, input) mutation in the start flow to after userMessage is successfully constructed, while preserving the existing appendMessage sequence. Ensure mintMessageId failures leave the thread state and retry behavior unchanged as documented.
🧹 Nitpick comments (7)
packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx (1)
900-930: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider adding a wrapper for a throwing
setResumeToken.
finalizeStopwrapssetStatusandsetResumeTokenin separatetryblocks at lines 391-400 ofuse-agent-thread-runs.ts. The doc at lines 356-358 states the split exists so a failingsetStatuscannot skip clearing the resume token, because a surviving token is separately harmful. The suite covers a throwingappendMessageand a throwingsetStatus, but not a throwingsetResumeToken. A third wrapper, in the same shape aswrapStoreWithThrowingSetStatus, would pin that branch and prevent a future merge of the twotryblocks from going unnoticed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx` around lines 900 - 930, Add a `wrapStoreWithThrowingSetResumeToken` helper alongside `wrapStoreWithThrowingSetStatus`, delegating all store members while throwing selectively from `setResumeToken`. Add coverage for `finalizeStop` using this wrapper to verify the failure is isolated and the existing stop-finalization behavior remains intact, preserving the separate `try` blocks for `setStatus` and `setResumeToken`.packages/basalt-ui/src/agent/id.test.ts (1)
27-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider exporting these crypto-swap helpers for reuse.
withRung2CryptoandwithRung3Cryptoare re-implemented inline in four other test files in this PR (ai-sdk-transport.test.ts,adapter.test.ts,use-agent-thread-runs.test.tsx,use-agent-stream.test.tsx). Each copy repeats the sameObject.definePropertysave/restore pair. A shared test helper module would keep the restore logic in one place.This is a test-only cleanup and can be deferred.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent/id.test.ts` around lines 27 - 50, Consolidate the duplicated crypto environment helpers by exporting withRung2Crypto and withRung3Crypto from a shared test utility module, then update the four other test files to import and reuse them instead of maintaining inline copies. Preserve each helper’s existing save, override, and restore behavior.packages/basalt-ui/src/agent-chat/thread-message.test.tsx (2)
1117-1121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThree tests locate the scroll node by inline-style substring.
Lines 1117, 1224, and 1278 use
div[style*="overflow: auto"]. This couples the tests to the exact serialization order and spacing of the inlinestyleattribute. A Mantine or React change to style serialization breaks all three at once, with a failure message that does not explain the cause.Consider a stable
data-testidon the virtualizer scroll node and the fallback pane, then select on that.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent-chat/thread-message.test.tsx` around lines 1117 - 1121, Update the virtualizer scroll node and fallback pane to expose stable data-testid attributes, then replace the style-substring selectors in the three tests around the scroll handling cases with those test IDs. Preserve the existing null checks and scroll behavior while removing dependence on inline-style serialization.
58-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTwo test globals are restored inside the test body instead of an
afterEach. In both places a shared global is replaced and the restore is not guaranteed, so a single failing assertion leaks the replacement into every later test in the module. Thevirtualizationdescribe block already shows the correct pattern foroffsetHeight: capture inbeforeEach, restore inafterEach.
packages/basalt-ui/src/agent-chat/thread-message.test.tsx#L58-L72: capture the originalnavigator.clipboarddescriptor instubClipboard, return arestorefunction, and call it from anafterEach.packages/basalt-ui/src/agent-chat/thread-message.test.tsx#L1179-L1284: movespyOn(Element.prototype, 'scrollTo')intobeforeEachandmockRestore()intoafterEach, replacing the four in-body restore calls.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent-chat/thread-message.test.tsx` around lines 58 - 72, Ensure shared test globals are always restored through lifecycle hooks: in packages/basalt-ui/src/agent-chat/thread-message.test.tsx lines 58-72, update stubClipboard to capture the original navigator.clipboard descriptor, return a restore function, and invoke it from afterEach; in lines 1179-1284, move the Element.prototype.scrollTo spy into beforeEach and call mockRestore() from afterEach, removing the four in-body restore calls.packages/basalt-ui/src/agent-chat/thread-feed-row.tsx (1)
57-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winHonor reduced motion in
ChevronGlyphand source the timing from a motion token.
ChevronGlyphanimates the chevron rotation with an inline CSS transition. It does not read reduced motion, so a user withprefers-reduced-motionstill gets the rotation animation. The duration and easing are also hardcoded rather than taken from the shared motion tokens.Read reduced motion at this call site and drop the transition when it is set.
♻️ Proposed change
-import { Box, Group, Stack, Text, UnstyledButton } from '`@mantine/core`' +import { Box, Group, Stack, Text, UnstyledButton } from '`@mantine/core`' +import { useReducedMotion } from '`@mantine/hooks`'function ChevronGlyph({ expanded }: { expanded: boolean }): JSX.Element { + const reduceMotion = useReducedMotion() return ( <svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', - transition: 'transform 120ms ease', + ...(reduceMotion + ? {} + : { transition: `transform ${MOTION_DURATION.fast}ms ${MOTION_EASE_STANDARD}` }), flexShrink: 0, }} >Adjust the token names to the shapes exported from
../motion.As per coding guidelines: "Every animated component must honor reduced motion and render an unanimated, instant equivalent; read reduced motion with Mantine's
useReducedMotionat the call site." and "Use sharedMOTION_DURATION,MOTION_SPRING, andMOTION_EASE_STANDARDtokens instead of hardcoded animation durations, springs, or eases."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent-chat/thread-feed-row.tsx` around lines 57 - 80, Update the call site that renders ChevronGlyph to read Mantine’s useReducedMotion hook, pass the reduced-motion state into ChevronGlyph, and omit its transition when motion is reduced so rotation remains instant. Replace the hardcoded duration and easing in ChevronGlyph with the corresponding MOTION_DURATION and MOTION_EASE_STANDARD tokens imported from ../motion, using the exported token shapes.Source: Coding guidelines
packages/basalt-ui/src/agent-chat/thread-workspace.tsx (1)
89-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAnnounce the hydrating hold to assistive technology.
FeedHydratingStaterenders three MantineSkeletonelements inside a plainStack. MantineSkeletonemits a plain element with no accessible role or name. A screen-reader user therefore gets no signal between mount and hydration, and the feed pane reads as empty.Add a status role and an accessible label to the wrapper.
♿ Proposed change
function FeedHydratingState(): JSX.Element { return ( - <Stack gap="sm" p="sm" data-testid="thread-workspace-hydrating"> + <Stack + gap="sm" + p="sm" + role="status" + aria-live="polite" + aria-label="Loading threads" + data-testid="thread-workspace-hydrating" + > <Skeleton height={54} radius="sm" /> <Skeleton height={54} radius="sm" /> <Skeleton height={54} radius="sm" /> </Stack> ) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent-chat/thread-workspace.tsx` around lines 89 - 97, Update the FeedHydratingState wrapper Stack to expose a status role and an accessible label indicating that the thread feed is loading or hydrating. Preserve the existing skeleton layout and data-testid while adding the accessibility attributes to the wrapper.packages/basalt-ui/src/agent-chat/thread-feed.tsx (1)
70-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConvert
defaultRowto a single typed object parameter.
defaultRowtakes seven positional parameters. Two adjacent parameters arestring | null(activeId,collapsedId) and two adjacent parameters are(id: string) => void(onToggle,onSelect). A transposed argument at the call site on Line 146 type-checks and produces silently wrong expansion or selection behavior.♻️ Proposed change
-function defaultRow( - thread: AgentThread, - variant: 'outcome' | 'inline', - activeId: string | null, - collapsedId: string | null, - onToggle: (id: string) => void, - onSelect: (id: string) => void, - onSend: ((thread: AgentThread, payload: ComposerSubmit) => void) | undefined, -): ReactNode { +type DefaultRowArgs = { + readonly thread: AgentThread + readonly variant: 'outcome' | 'inline' + readonly activeId: string | null + readonly collapsedId: string | null + readonly onToggle: (id: string) => void + readonly onSelect: (id: string) => void + readonly onSend?: (thread: AgentThread, payload: ComposerSubmit) => void +} + +function defaultRow({ + thread, + variant, + activeId, + collapsedId, + onToggle, + onSelect, + onSend, +}: DefaultRowArgs): ReactNode {Then update the call site:
- : defaultRow(thread, variant, activeId, collapsedId, handleInlineToggle, onSelect, onSend) + : defaultRow({ + thread, + variant, + activeId, + collapsedId, + onToggle: handleInlineToggle, + onSelect, + ...(onSend !== undefined && { onSend }), + })As per coding guidelines: "Use strict TypeScript without
any, explicit types on public exports, typed object parameters, low nesting, and early returns."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/basalt-ui/src/agent-chat/thread-feed.tsx` around lines 70 - 78, Convert defaultRow to accept one explicitly typed options object containing thread, variant, activeId, collapsedId, onToggle, onSelect, and onSend, then destructure or access those fields inside the function. Update every defaultRow call site, including the call around line 146, to use named properties so active/collapsed state and toggle/select callbacks cannot be transposed.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/playground/src/demo/agent-long-thread.ts`:
- Around line 82-93: Replace direct crypto.randomUUID() usage in textPart and
makeMessage with the exported degraded-crypto helpers from
packages/basalt-ui/src/agent/id.ts, using mintThreadId() for low-collision
non-idempotent identifiers and mintMessageId() for values used as write or
idempotency keys. Apply the same classification and replacement in
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx lines 44-52,
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx lines 223-234, and
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx lines 75-85.
- Around line 106-146: Update buildLongThread so the completed generated
sequence is shifted after generation, ensuring its newest message createdAt is
near Date.now() regardless of how many messages each turn produces. Replace the
count-based initial backdating with a post-generation adjustment based on the
latest generated timestamp, while preserving message ordering and the existing
count limit.
In `@apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx`:
- Line 63: Annotate the exported AgentTranscriptVirtualizeDemoPage function in
apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx#L63-L63 with the
JSX.Element return type, importing type JSX from react if needed. Apply the same
annotation to AgentInlineFeedVirtualizedRowDemoPage in
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L26-L26,
AgentThreadFeedInlineDemoPage in
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193, and
AgentAnchorToEndDemoPage in
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L100-L100; add the
type-only JSX import in any file that lacks it.
In `@packages/basalt-ui/README.md`:
- Line 239: Update the ./agent-chat entry in the README package table to name
`@tanstack/react-virtual` as the optional peer required for ThreadTranscript
virtualize mode, and update the Requirements table and optional-peer notes to
include it. Document that virtualized transcripts fall back to an unwindowed
pane when the package is unavailable, while preserving the existing motion
requirement.
In `@packages/basalt-ui/src/agent-chat/index.ts`:
- Around line 22-32: Register the agent-chat exports ThreadFeedRowProps,
MessageAffordances, VirtualizeOptions, and VirtualizeProps in
scripts/export-surface.json and src/surfaces.ts, then add the same names to
llms.txt, README.md, and AGENTS.md using the existing surface and documentation
conventions; keep runtime exports unchanged.
In `@packages/basalt-ui/src/agent-chat/relative-time.test.ts`:
- Around line 14-18: Stabilize the boundary-minus-1ms assertion in the
“sub-minute past → just now” test by widening its margin below MINUTE_MS, or
otherwise freezing the clock for the assertion. Keep the other past and future
relative-time cases unchanged.
In `@packages/basalt-ui/src/agent-chat/thread-feed.tsx`:
- Around line 118-128: Update the ThreadFeedProps.variant documentation to
remove the promise that re-selecting the same activeId externally resets the
manual-collapse override; document reset only when activeId changes to a
different thread, while preserving handleInlineToggle’s local reset behavior.
In `@packages/basalt-ui/src/agent-chat/thread-message.tsx`:
- Around line 346-351: Update MessageAffordanceRow to read reduced-motion state
via Mantine’s useReducedMotion, and conditionally remove the opacity transition
when reduced motion is enabled. Replace the hardcoded timing in the Group style
with the shared MOTION_DURATION and MOTION_EASE_STANDARD tokens, confirming
their existing key and value shape before use.
- Around line 848-895: The `@tanstack/react-virtual` peer requirement is too low
for the end-anchoring APIs used by VirtualizedRowsInner. Raise the minimum
supported version through the surfaces.ts configuration, the ./agent-chat peer
and documentation entries, and packages/basalt-ui/llms.txt (line 127), ensuring
anchorTo, followOnAppend, scrollEndThreshold, and virtualizer.scrollToEnd({
behavior: 'auto' }) are guaranteed; update the referenced VirtualizedRowsInner
site (thread-message.tsx, lines 848-895) only as needed to align with that
version requirement.
In `@packages/basalt-ui/src/agent-chat/thread-workspace.tsx`:
- Around line 155-160: Update the empty-thread rendering branch in the thread
workspace to check store.error when store.hydrated is false; render the existing
load-failure message and retry affordance for a rejected initial listThreads
load, while preserving FeedHydratingState for pending loads and the existing
empty state for hydrated stores.
---
Outside diff comments:
In `@packages/basalt-ui/src/agent/use-agent-thread-runs.ts`:
- Around line 619-628: Move the lastInputRef.current.set(threadId, input)
mutation in the start flow to after userMessage is successfully constructed,
while preserving the existing appendMessage sequence. Ensure mintMessageId
failures leave the thread state and retry behavior unchanged as documented.
---
Nitpick comments:
In `@packages/basalt-ui/src/agent-chat/thread-feed-row.tsx`:
- Around line 57-80: Update the call site that renders ChevronGlyph to read
Mantine’s useReducedMotion hook, pass the reduced-motion state into
ChevronGlyph, and omit its transition when motion is reduced so rotation remains
instant. Replace the hardcoded duration and easing in ChevronGlyph with the
corresponding MOTION_DURATION and MOTION_EASE_STANDARD tokens imported from
../motion, using the exported token shapes.
In `@packages/basalt-ui/src/agent-chat/thread-feed.tsx`:
- Around line 70-78: Convert defaultRow to accept one explicitly typed options
object containing thread, variant, activeId, collapsedId, onToggle, onSelect,
and onSend, then destructure or access those fields inside the function. Update
every defaultRow call site, including the call around line 146, to use named
properties so active/collapsed state and toggle/select callbacks cannot be
transposed.
In `@packages/basalt-ui/src/agent-chat/thread-message.test.tsx`:
- Around line 1117-1121: Update the virtualizer scroll node and fallback pane to
expose stable data-testid attributes, then replace the style-substring selectors
in the three tests around the scroll handling cases with those test IDs.
Preserve the existing null checks and scroll behavior while removing dependence
on inline-style serialization.
- Around line 58-72: Ensure shared test globals are always restored through
lifecycle hooks: in packages/basalt-ui/src/agent-chat/thread-message.test.tsx
lines 58-72, update stubClipboard to capture the original navigator.clipboard
descriptor, return a restore function, and invoke it from afterEach; in lines
1179-1284, move the Element.prototype.scrollTo spy into beforeEach and call
mockRestore() from afterEach, removing the four in-body restore calls.
In `@packages/basalt-ui/src/agent-chat/thread-workspace.tsx`:
- Around line 89-97: Update the FeedHydratingState wrapper Stack to expose a
status role and an accessible label indicating that the thread feed is loading
or hydrating. Preserve the existing skeleton layout and data-testid while adding
the accessibility attributes to the wrapper.
In `@packages/basalt-ui/src/agent/id.test.ts`:
- Around line 27-50: Consolidate the duplicated crypto environment helpers by
exporting withRung2Crypto and withRung3Crypto from a shared test utility module,
then update the four other test files to import and reuse them instead of
maintaining inline copies. Preserve each helper’s existing save, override, and
restore behavior.
In `@packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx`:
- Around line 900-930: Add a `wrapStoreWithThrowingSetResumeToken` helper
alongside `wrapStoreWithThrowingSetStatus`, delegating all store members while
throwing selectively from `setResumeToken`. Add coverage for `finalizeStop`
using this wrapper to verify the failure is isolated and the existing
stop-finalization behavior remains intact, preserving the separate `try` blocks
for `setStatus` and `setResumeToken`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 51377260-d906-4b17-ba71-41d0c596f4f8
📒 Files selected for processing (50)
.oxlintrc.jsonapps/playground/src/demo/AgentAnchorToEndDemoPage.tsxapps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsxapps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsxapps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsxapps/playground/src/demo/agent-long-thread.tsapps/playground/src/demo/agent-transcript-virtualize.type-guard.tsapps/playground/src/demo/nav-model.tsxapps/playground/src/routes/agent-anchor-to-end.tsxapps/playground/src/routes/agent-inline-feed-virtualized.tsxapps/playground/src/routes/agent-thread-feed-inline.tsxapps/playground/src/routes/agent-transcript-virtualize.tsxpackages/basalt-ui/AGENTS.mdpackages/basalt-ui/README.mdpackages/basalt-ui/agent/rules/basalt-agent.mdpackages/basalt-ui/configs/oxlint-plugin.jspackages/basalt-ui/configs/oxlint-plugin.test.tspackages/basalt-ui/configs/oxlint.jsonpackages/basalt-ui/llms.txtpackages/basalt-ui/scripts/export-surface.jsonpackages/basalt-ui/src/agent-chat/index.tspackages/basalt-ui/src/agent-chat/message-affordances.tspackages/basalt-ui/src/agent-chat/relative-time.test.tspackages/basalt-ui/src/agent-chat/relative-time.tspackages/basalt-ui/src/agent-chat/thread-feed-row.test.tsxpackages/basalt-ui/src/agent-chat/thread-feed-row.tsxpackages/basalt-ui/src/agent-chat/thread-feed.test.tsxpackages/basalt-ui/src/agent-chat/thread-feed.tsxpackages/basalt-ui/src/agent-chat/thread-message.test.tsxpackages/basalt-ui/src/agent-chat/thread-message.tsxpackages/basalt-ui/src/agent-chat/thread-outcome-card.tsxpackages/basalt-ui/src/agent-chat/thread-workspace.test.tsxpackages/basalt-ui/src/agent-chat/thread-workspace.tsxpackages/basalt-ui/src/agent-chat/virtualize.tspackages/basalt-ui/src/agent-chat/virtualize.type-guard.test.tspackages/basalt-ui/src/agent/adapter.test.tspackages/basalt-ui/src/agent/adapter.tspackages/basalt-ui/src/agent/ai-sdk-transport.test.tspackages/basalt-ui/src/agent/ai-sdk-transport.tspackages/basalt-ui/src/agent/id.test.tspackages/basalt-ui/src/agent/id.tspackages/basalt-ui/src/agent/thread.tspackages/basalt-ui/src/agent/use-agent-stream.test.tsxpackages/basalt-ui/src/agent/use-agent-stream.tspackages/basalt-ui/src/agent/use-agent-thread-runs.test.tsxpackages/basalt-ui/src/agent/use-agent-thread-runs.tspackages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsxpackages/basalt-ui/src/index.tspackages/basalt-ui/src/surfaces.tspackages/basalt-ui/src/theme/shadow-surfaces.test.ts
| function textPart(text: string): { id: string; type: 'text'; text: string } { | ||
| return { id: crypto.randomUUID(), type: 'text', text } | ||
| } | ||
|
|
||
| function makeMessage(role: ChatMessage['role'], createdAt: number, text: string): ChatMessage { | ||
| return { | ||
| id: crypto.randomUUID(), | ||
| role, | ||
| parts: [textPart(text)], | ||
| createdAt, | ||
| ...(role === 'assistant' ? { finish: 'complete' as const } : {}), | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the shared ID implementation and its public-use pattern.
ast-grep outline packages/basalt-ui/src/agent/id.ts --items all
sed -n '1,260p' packages/basalt-ui/src/agent/id.ts
rg -n -i -C 4 'randomUUID|uuid|generate.*id|create.*id' packages/basalt-ui/src/agent
# Confirm every raw UUID call in the affected demos is replaced.
rg -n -C 3 '\bcrypto\.randomUUID\s*\(' apps/playground/src/demoRepository: jkrumm/basalt-ui
Length of output: 50373
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '--- affected files ---\n'
for f in \
apps/playground/src/demo/agent-long-thread.ts \
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx \
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx \
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx
do
echo "## $f"
wc -l "$f"
rg -n -C 4 '\bcrypto\.randomUUID\s*\(' "$f" || true
done
printf '\n--- id.ts exports/usages near public exports ---\n'
sed -n '1,130p' packages/basalt-ui/src/agent/id.ts
printf '\n--- playground imports including id helpers ---\n'
rg -n 'from ['"'"'"][^'"'"'"]*(agent[/-]id|id)["'"'"']+|mintThreadId|mintMessageId|randomUUID' apps/playground/src/demo -g '*.ts' -g '*.tsx' || trueRepository: jkrumm/basalt-ui
Length of output: 245
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- affected files ---'
for f in \
apps/playground/src/demo/agent-long-thread.ts \
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx \
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx \
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx
do
echo "## $f"
wc -l "$f"
rg -n -C 4 '\bcrypto\.randomUUID\s*\(' "$f" || true
done
echo
echo '--- id.ts exports/usages near public exports ---'
sed -n '1,130p' packages/basalt-ui/src/agent/id.ts
echo
echo '--- playground imports including id helpers ---'
rg -n "from ['\"][^'\"]*(agent[/-]id|id)['\"]+|mintThreadId|mintMessageId|randomUUID" apps/playground/src/demo -g '*.ts' -g '*.tsx' || trueRepository: jkrumm/basalt-ui
Length of output: 16973
Use the shared degraded-crypto ID generators.
Replace the direct crypto.randomUUID() calls in the affected demos with the exported fallback-compatible helpers from packages/basalt-ui/src/agent/id.ts: use mintThreadId() for low-collision non-idempotent IDs and mintMessageId() where the generated value serves as the write/idempotency key.
📍 Affects 4 files
apps/playground/src/demo/agent-long-thread.ts#L82-L93(this comment)apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L44-L52apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L223-L234apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L75-L85
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/playground/src/demo/agent-long-thread.ts` around lines 82 - 93, Replace
direct crypto.randomUUID() usage in textPart and makeMessage with the exported
degraded-crypto helpers from packages/basalt-ui/src/agent/id.ts, using
mintThreadId() for low-collision non-idempotent identifiers and mintMessageId()
for values used as write or idempotency keys. Apply the same classification and
replacement in
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx lines 44-52,
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx lines 223-234, and
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx lines 75-85.
| // Mirrors the shipped `VirtualizeOptions.initialScroll` default — see the note above, same reasoning. | ||
| const DEFAULT_INITIAL_SCROLL = 'end' | ||
|
|
||
| export function AgentTranscriptVirtualizeDemoPage() { |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Add explicit return types to the exported page components.
Add : JSX.Element to each exported page function. Import type { JSX } from react where the file does not already import it.
apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx#L63-L63: annotateAgentTranscriptVirtualizeDemoPage.apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L26-L26: annotateAgentInlineFeedVirtualizedRowDemoPage.apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193: annotateAgentThreadFeedInlineDemoPage.apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L100-L100: annotateAgentAnchorToEndDemoPage.
As per coding guidelines, “provide explicit types on public exports.”
📍 Affects 4 files
apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx#L63-L63(this comment)apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L26-L26apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L100-L100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx` at line 63,
Annotate the exported AgentTranscriptVirtualizeDemoPage function in
apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx#L63-L63 with the
JSX.Element return type, importing type JSX from react if needed. Apply the same
annotation to AgentInlineFeedVirtualizedRowDemoPage in
apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L26-L26,
AgentThreadFeedInlineDemoPage in
apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193, and
AgentAnchorToEndDemoPage in
apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L100-L100; add the
type-only JSX import in any file that lacks it.
Source: Coding guidelines
| // ── ThreadFeedRow ───────────────────────────────────────────────────────────── | ||
| export { ThreadFeedRow } from './thread-feed-row' | ||
| export type { ThreadFeedRowProps } from './thread-feed-row' | ||
|
|
||
| // ── Shared transcript/row contracts ─────────────────────────────────────────── | ||
| // Type-only. Both are named in the PUBLIC props of components exported above | ||
| // (`ThreadTranscriptProps.affordances`, `ThreadFeedRowProps`'s virtualize union), so a consumer | ||
| // that wants to hold one in a typed variable needs to be able to name it. No runtime export here — | ||
| // the resolution/defaults live inside the components. | ||
| export type { MessageAffordances } from './message-affordances' | ||
| export type { VirtualizeOptions, VirtualizeProps } from './virtualize' |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check that the newly exported agent-chat names are registered in the surface manifest and docs.
set -euo pipefail
for name in ThreadFeedRow ThreadFeedRowProps MessageAffordances VirtualizeOptions VirtualizeProps; do
echo "===== $name"
rg -n "$name" \
packages/basalt-ui/scripts/export-surface.json \
packages/basalt-ui/src/surfaces.ts \
packages/basalt-ui/llms.txt \
packages/basalt-ui/README.md \
packages/basalt-ui/AGENTS.md 2>/dev/null || echo " (not found in surface manifest/docs)"
done
# The agent-chat subpath entry in llms.txt
rg -n -A6 'agent-chat' packages/basalt-ui/llms.txtRepository: jkrumm/basalt-ui
Length of output: 13657
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "===== export-surface JSON agent-chat section ====="
python3 - <<'PY'
import json
from pathlib import Path
p=Path('packages/basalt-ui/scripts/export-surface.json')
data=json.loads(p.read_text())
entries=data.get('exports') or []
for i, e in enumerate(entries):
if e.get('specifier') == 'basalt-ui/agent-chat':
print(f'index {i}')
for k,v in e.items():
print(f'{k}: {v!r}')
break
else:
print('agent-chat entry not found')
PY
echo
echo "===== surface names containing proposed exports ====="
rg -n "ThreadFeedRowProps|MessageAffordances|VirtualizeOptions|VirtualizeProps|ThreadFeedRow" \
packages/basalt-ui/scripts/export-surface.json \
packages/basalt-ui/src/surfaces.ts \
packages/basalt-ui/llms.txt \
packages/basalt-ui/README.md \
packages/basalt-ui/AGENTS.md
echo
echo "===== barrel exports around target lines ====="
sed -n '1,50p' packages/basalt-ui/src/agent-chat/index.tsRepository: jkrumm/basalt-ui
Length of output: 7355
Register the remaining new agent-chat surface names
ThreadFeedRowProps and VirtualizeProps are still missing from the package surface manifest and docs. The barrel also exposes MessageAffordances, VirtualizeOptions, and VirtualizeProps; update scripts/export-surface.json, src/surfaces.ts, llms.txt, README.md, and AGENTS.md to register these consistently with the barrel.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/src/agent-chat/index.ts` around lines 22 - 32, Register
the agent-chat exports ThreadFeedRowProps, MessageAffordances,
VirtualizeOptions, and VirtualizeProps in scripts/export-surface.json and
src/surfaces.ts, then add the same names to llms.txt, README.md, and AGENTS.md
using the existing surface and documentation conventions; keep runtime exports
unchanged.
Source: Coding guidelines
| return ( | ||
| <Group | ||
| gap={8} | ||
| data-testid={`message-affordances-${message.id}`} | ||
| style={{ opacity: visible ? 1 : 0, transition: 'opacity 120ms ease' }} | ||
| > |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
The affordance row animates opacity without honoring reduced motion, and hardcodes the timing.
Line 350 sets transition: 'opacity 120ms ease'. Two package guidelines apply:
- Every animated component must honor reduced motion and render an unanimated, instant equivalent. Read reduced motion with Mantine's
useReducedMotionat the call site. - Use the shared
MOTION_DURATIONandMOTION_EASE_STANDARDtokens for transition timing rather than a hardcoded duration and easing literal.
Read useReducedMotion inside MessageAffordanceRow and drop the transition when it returns true.
♿ Proposed fix
+ const reducedMotion = useReducedMotion()
+
if (!showTimestamp && !showCopy && !showRegenerate && customActions === undefined) return null
@@
<Group
gap={8}
data-testid={`message-affordances-${message.id}`}
- style={{ opacity: visible ? 1 : 0, transition: 'opacity 120ms ease' }}
+ style={{
+ opacity: visible ? 1 : 0,
+ ...(reducedMotion
+ ? {}
+ : { transition: `opacity ${MOTION_DURATION.fast}s ${MOTION_EASE_STANDARD}` }),
+ }}
>Import useReducedMotion from @mantine/hooks and the motion tokens from the shared token module. Confirm the exact MOTION_DURATION key and MOTION_EASE_STANDARD shape before applying.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/basalt-ui/src/agent-chat/thread-message.tsx` around lines 346 - 351,
Update MessageAffordanceRow to read reduced-motion state via Mantine’s
useReducedMotion, and conditionally remove the opacity transition when reduced
motion is enabled. Replace the hardcoded timing in the Group style with the
shared MOTION_DURATION and MOTION_EASE_STANDARD tokens, confirming their
existing key and value shape before use.
Source: Coding guidelines
The scratch consumer installed @tanstack/react-table@>=8 and react-virtual@>=3, both open-ended, while the package declares >=8 <9 and >=3 <4. react-table 9.0.0 published today, so CI installed a major outside the supported range and export-surface died on an export that major had removed — a red build on every PR, caused by an upstream release rather than by anything in the diff. The ranges now mirror the declared peers, so this harness tests what consumers are actually allowed to resolve. Pinning to exact versions would be the wrong fix: the point of the scratch install is to catch a peer range that has drifted from reality, and an exact pin would hide exactly that.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/playground/src/demo/agent-long-thread.ts (1)
106-147: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winValidate
countbefore the generation loop.
Number.POSITIVE_INFINITYmakes the loop run without end and append without bound. Fractional values also makeslice(0, count)return a different number of messages than requested. Reject values that are not non-negative safe integers.Proposed guard
export function buildLongThread(count: number): ChatMessage[] { + if (!Number.isSafeInteger(count) || count < 0) { + throw new RangeError('count must be a non-negative safe integer') + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/playground/src/demo/agent-long-thread.ts` around lines 106 - 147, Validate count at the start of buildLongThread before allocating messages or entering the generation loop, accepting only non-negative safe integers and rejecting Infinity, fractional values, negatives, and other invalid numbers. Preserve the requested count exactly so the generated array and any downstream slice behavior remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/playground/src/demo/agent-long-thread.ts`:
- Around line 106-147: Validate count at the start of buildLongThread before
allocating messages or entering the generation loop, accepting only non-negative
safe integers and rejecting Infinity, fractional values, negatives, and other
invalid numbers. Preserve the requested count exactly so the generated array and
any downstream slice behavior remain consistent.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 99fef11d-65e2-4ae4-a93e-467aed8a8a07
📒 Files selected for processing (12)
apps/playground/src/demo/agent-long-thread.tspackages/basalt-ui/README.mdpackages/basalt-ui/llms.txtpackages/basalt-ui/package.jsonpackages/basalt-ui/scripts/pack-test.shpackages/basalt-ui/src/agent-chat/relative-time.test.tspackages/basalt-ui/src/agent-chat/thread-feed.tsxpackages/basalt-ui/src/agent-chat/thread-workspace.test.tsxpackages/basalt-ui/src/agent-chat/thread-workspace.tsxpackages/basalt-ui/src/data/index.tspackages/basalt-ui/src/data/virtual-list.tsxpackages/basalt-ui/src/data/virtual.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/basalt-ui/src/agent-chat/relative-time.test.ts
- packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx
- packages/basalt-ui/scripts/pack-test.sh
- packages/basalt-ui/src/agent-chat/thread-feed.tsx
Release 1.13.0, the last before this package's agent API freezes. Five commits, rebase-merged so each lands on master individually.
What ships
ThreadFeedRow— the inline-expanding Slack variant beside the inbox-shapedThreadOutcomeCard, which is untouched. A second component, not a mode on the first.ThreadFeedgainsvariantandrenderRow, wired through both its reduced-motion and animated branches.Per-message affordances — relative timestamp, copy, regenerate on the last assistant message only, plus consumer
actions.groupConsecutivegives the Slack rhythm and finally rendersChatMessage.createdAt, which has been stored and shown nowhere since 1.10.0.Virtualization — the props union is the guard (
virtualizerequiresheight, forbids it otherwise). Variable-height rows measured dynamically, opening at the newest message by default viainitialScroll.Carried fixes — id minting off a secure context, a stopped turn that could wedge unrecoverably, a hydrating store flashing its empty state, and a rolled-back create burying its own cause under cascade errors.
Guard promotion — the three agent rules and
raw-scroll-containerfromwarntoerrorin the shipped preset, one minor after the release that introduced the API they guard.Behaviour changes worth reading before merge
groupConsecutivedefaults on, so an existing transcript loses role labels and chrome on consecutive same-role messages within five minutes. Opt out withgroupConsecutive={false}.agent-no-raw-usechatandagent-resume-guardexist to stop a consumer hand-rolling what this package now provides.stop()clearing the resume token stays terminal. Ruled deliberately, not an oversight.Verification
make buildbun run pre(fmt + lint + typecheck + check-theme)bun testfrom the repo rootpack-test.sh@tanstack/react-virtualabsentoxlint apps/playground/srcTwo review passes and three browser walks ran after the gates were green, and every serious defect came from the walks rather than the reads:
RangeErroron any non-finitecreatedAt— no error boundary anywhere inagent/, so one bad timestamp blanked the whole transcript. Found by probing fourteen values.<Activity>, which is what Mantine master's newCollapsedefaults wrap children in.display: none— collapsing and re-expanding walked the transcript thirty messages whilescrollTopsat still.initialScrollfiring, landing, and being reverted a millisecond later by virtual-core's own anchor write. The unit tests asserted the scroll was called, which was true, while the feature did not work.All re-verified in a browser: the phantom could not be recreated by eight separate routes, the virtualizer holds identical position across thirteen collapse cycles, and
initialScrollsettles in two bounded attempts against a cap of five.anchorTo: 'end',followOnAppendandscrollEndThresholdhad shipped on a research note and were unobservable in happy-dom. They are now measured: following holds at zero distance while streaming, movesscrollTopby exactly zero once the reader scrolls up, and the 64px threshold is sharp at four probe distances.Summary by CodeRabbit
New Features
Bug Fixes
Documentation