Skip to content

B4 — the Slack shape, affordances, virtualization - #46

Merged
jkrumm merged 11 commits into
masterfrom
feat/b4-slack-shape
Aug 4, 2026
Merged

B4 — the Slack shape, affordances, virtualization#46
jkrumm merged 11 commits into
masterfrom
feat/b4-slack-shape

Conversation

@jkrumm

@jkrumm jkrumm commented Aug 4, 2026

Copy link
Copy Markdown
Owner

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-shaped ThreadOutcomeCard, which is untouched. A second component, not a mode on the first. ThreadFeed gains variant and renderRow, 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. groupConsecutive gives the Slack rhythm and finally renders ChatMessage.createdAt, which has been stored and shown nowhere since 1.10.0.

Virtualization — the props union is the guard (virtualize requires height, forbids it otherwise). Variable-height rows measured dynamically, opening at the newest message by default via initialScroll.

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-container from warn to error in the shipped preset, one minor after the release that introduced the API they guard.

Behaviour changes worth reading before merge

  • groupConsecutive defaults on, so an existing transcript loses role labels and chrome on consecutive same-role messages within five minutes. Opt out with groupConsecutive={false}.
  • The four promoted rules will fail lint for a consumer on the preset who previously had warnings. That is deliberate — agent-no-raw-usechat and agent-resume-guard exist to stop a consumer hand-rolling what this package now provides.
  • stop() clearing the resume token stays terminal. Ruled deliberately, not an oversight.

Verification

Gate Result
make build 0
bun run pre (fmt + lint + typecheck + check-theme) 0
bun test from the repo root 1668 pass / 0 fail, 83 files
pack-test.sh 0, incl. agent-chat resolving with @tanstack/react-virtual absent
oxlint apps/playground/src 0
playground typecheck 0

Two review passes and three browser walks ran after the gates were green, and every serious defect came from the walks rather than the reads:

  • A render-path RangeError on any non-finite createdAt — no error boundary anywhere in agent/, so one bad timestamp blanked the whole transcript. Found by probing fourteen values.
  • An aborted run leaving a phantom that nothing could clear — a thread that claims to stream, cannot be stopped, cannot be typed into. Dev-only today via StrictMode, but the same cleanup path is reachable through React 19 <Activity>, which is what Mantine master's new Collapse defaults wrap children in.
  • A virtualizer cache poisoned by display: none — collapsing and re-expanding walked the transcript thirty messages while scrollTop sat still.
  • initialScroll firing, 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 initialScroll settles in two bounded attempts against a cap of five.

anchorTo: 'end', followOnAppend and scrollEndThreshold had shipped on a research note and were unobservable in happy-dom. They are now measured: following holds at zero distance while streaming, moves scrollTop by exactly zero once the reader scrolls up, and the 64px threshold is sharp at four probe distances.

Summary by CodeRabbit

  • New Features

    • Added expandable thread rows with lazy-loaded transcripts, composers, streaming controls, and custom rendering.
    • Added configurable transcript virtualization, initial scrolling, message grouping, timestamps, copy/regenerate actions, and custom affordances.
    • Added playground demos covering streaming, virtualization, scrolling, and feed interactions.
  • Bug Fixes

    • Improved hydration states, stream cleanup, failed-thread handling, and operation in limited crypto environments.
    • Strengthened scroll-container lint enforcement.
  • Documentation

    • Expanded agent chat feature and configuration documentation.

jkrumm added 5 commits August 4, 2026 10:19
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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📦 basalt-ui package modified

Trigger the Make Release workflow after merging to publish to npm.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The 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.

Changes

Agent chat UI

Layer / File(s) Summary
Transcript contracts and rendering
packages/basalt-ui/src/agent-chat/virtualize.ts, packages/basalt-ui/src/agent-chat/message-affordances.ts, packages/basalt-ui/src/agent-chat/thread-message.tsx, packages/basalt-ui/src/agent-chat/relative-time.ts
Adds virtualization contracts, message affordances, grouping, relative timestamps, lazy virtualization, guarded measurement, and initial-scroll handling.
Inline feed rows
packages/basalt-ui/src/agent-chat/thread-feed-row.tsx, packages/basalt-ui/src/agent-chat/thread-feed.tsx, packages/basalt-ui/src/agent-chat/*test.tsx
Adds persistent inline expansion, composer and stop handling, custom row rendering, and virtualization forwarding.
Workspace and package surface
packages/basalt-ui/src/agent-chat/thread-workspace.tsx, packages/basalt-ui/src/index.ts, packages/basalt-ui/src/agent-chat/index.ts, packages/basalt-ui/src/surfaces.ts, packages/basalt-ui/src/theme/shadow-surfaces.test.ts, packages/basalt-ui/README.md, packages/basalt-ui/AGENTS.md, packages/basalt-ui/llms.txt, packages/basalt-ui/package.json, packages/basalt-ui/src/data/*
Gates empty states on hydration and exports and documents the new agent-chat capabilities. The optional virtualizer version floor is set to 3.13.26.

Agent runtime reliability

Layer / File(s) Summary
Identifier generation
packages/basalt-ui/src/agent/id.ts, packages/basalt-ui/src/agent/id.test.ts, packages/basalt-ui/src/agent/ai-sdk-transport.ts, packages/basalt-ui/src/agent/use-agent-stream.ts, packages/basalt-ui/src/agent/use-agent-thread-runs.ts
Adds shared UUID generation with getRandomValues fallback. Message IDs fail when no usable cryptographic source exists.
Failed-create handling
packages/basalt-ui/src/agent/adapter.ts, packages/basalt-ui/src/agent/adapter.test.ts
Suppresses dependent writes after failed thread creation while preserving independent writes and the original error.
Stream lifecycle cleanup
packages/basalt-ui/src/agent/use-agent-thread-runs.ts, packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx, packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx
Guards stop persistence and settlement, removes stale runs, and handles phantom and hidden non-resumable streams.

Playground demonstrations

Layer / File(s) Summary
Agent-chat demos and data
apps/playground/src/demo/Agent*.tsx, apps/playground/src/demo/agent-long-thread.ts
Adds demonstrations for inline feeds, transcript virtualization, virtualized rows, and streaming anchor-to-end behavior.
Navigation and route wiring
apps/playground/src/demo/nav-model.tsx, apps/playground/src/routes/agent-*.tsx, apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts
Registers the four demos, adds routes, and validates virtualization prop combinations.

Lint enforcement

Layer / File(s) Summary
Rule promotion and validation
.oxlintrc.json, packages/basalt-ui/configs/oxlint.json, packages/basalt-ui/configs/oxlint-plugin.js, packages/basalt-ui/configs/oxlint-plugin.test.ts
Promotes raw-scroll-container, agent-resume-guard, agent-no-raw-usechat, and ai-sdk-major to error severity and updates integration tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • jkrumm/basalt-ui#29: Both PRs promote the raw-scroll-container guard from warning to error.
  • jkrumm/basalt-ui#43: This PR extends the agent-chat surface introduced there with virtualization, feed rows, affordances, and exports.
  • jkrumm/basalt-ui#44: This PR extends the transcript rendering introduced there with affordances, grouping, and virtualization.

Poem

A rabbit checks each scrolling row,
IDs fall back when crypto is low.
Streams stop clean and feeds expand,
Four demos hop across the land.
Lint errors guard the code in hand.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main Slack-style transcript, affordance, and virtualization changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

lastInputRef is mutated before the mint, so the "true no-op" guarantee is not exact.

Line 619 writes lastInputRef.current before mintMessageId() runs at line 623. If mintMessageId throws on rung 3, the input stays cached. The thread is then not "exactly as it was before the call" as the start JSDoc 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 value

Consider adding a wrapper for a throwing setResumeToken.

finalizeStop wraps setStatus and setResumeToken in separate try blocks at lines 391-400 of use-agent-thread-runs.ts. The doc at lines 356-358 states the split exists so a failing setStatus cannot skip clearing the resume token, because a surviving token is separately harmful. The suite covers a throwing appendMessage and a throwing setStatus, but not a throwing setResumeToken. A third wrapper, in the same shape as wrapStoreWithThrowingSetStatus, would pin that branch and prevent a future merge of the two try blocks 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 value

Consider exporting these crypto-swap helpers for reuse.

withRung2Crypto and withRung3Crypto are 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 same Object.defineProperty save/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 value

Three 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 inline style attribute. 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-testid on 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 win

Two 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. The virtualization describe block already shows the correct pattern for offsetHeight: capture in beforeEach, restore in afterEach.

  • packages/basalt-ui/src/agent-chat/thread-message.test.tsx#L58-L72: capture the original navigator.clipboard descriptor in stubClipboard, return a restore function, and call it from an afterEach.
  • packages/basalt-ui/src/agent-chat/thread-message.test.tsx#L1179-L1284: move spyOn(Element.prototype, 'scrollTo') into beforeEach and mockRestore() into afterEach, 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 win

Honor reduced motion in ChevronGlyph and source the timing from a motion token.

ChevronGlyph animates the chevron rotation with an inline CSS transition. It does not read reduced motion, so a user with prefers-reduced-motion still 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 useReducedMotion at the call site." and "Use shared MOTION_DURATION, MOTION_SPRING, and MOTION_EASE_STANDARD tokens 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 win

Announce the hydrating hold to assistive technology.

FeedHydratingState renders three Mantine Skeleton elements inside a plain Stack. Mantine Skeleton emits 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 win

Convert defaultRow to a single typed object parameter.

defaultRow takes seven positional parameters. Two adjacent parameters are string | 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0137d24 and 3502579.

📒 Files selected for processing (50)
  • .oxlintrc.json
  • apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx
  • apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx
  • apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx
  • apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx
  • apps/playground/src/demo/agent-long-thread.ts
  • apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts
  • apps/playground/src/demo/nav-model.tsx
  • apps/playground/src/routes/agent-anchor-to-end.tsx
  • apps/playground/src/routes/agent-inline-feed-virtualized.tsx
  • apps/playground/src/routes/agent-thread-feed-inline.tsx
  • apps/playground/src/routes/agent-transcript-virtualize.tsx
  • packages/basalt-ui/AGENTS.md
  • packages/basalt-ui/README.md
  • packages/basalt-ui/agent/rules/basalt-agent.md
  • packages/basalt-ui/configs/oxlint-plugin.js
  • packages/basalt-ui/configs/oxlint-plugin.test.ts
  • packages/basalt-ui/configs/oxlint.json
  • packages/basalt-ui/llms.txt
  • packages/basalt-ui/scripts/export-surface.json
  • packages/basalt-ui/src/agent-chat/index.ts
  • packages/basalt-ui/src/agent-chat/message-affordances.ts
  • packages/basalt-ui/src/agent-chat/relative-time.test.ts
  • packages/basalt-ui/src/agent-chat/relative-time.ts
  • packages/basalt-ui/src/agent-chat/thread-feed-row.test.tsx
  • packages/basalt-ui/src/agent-chat/thread-feed-row.tsx
  • packages/basalt-ui/src/agent-chat/thread-feed.test.tsx
  • packages/basalt-ui/src/agent-chat/thread-feed.tsx
  • packages/basalt-ui/src/agent-chat/thread-message.test.tsx
  • packages/basalt-ui/src/agent-chat/thread-message.tsx
  • packages/basalt-ui/src/agent-chat/thread-outcome-card.tsx
  • packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx
  • packages/basalt-ui/src/agent-chat/thread-workspace.tsx
  • packages/basalt-ui/src/agent-chat/virtualize.ts
  • packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts
  • packages/basalt-ui/src/agent/adapter.test.ts
  • packages/basalt-ui/src/agent/adapter.ts
  • packages/basalt-ui/src/agent/ai-sdk-transport.test.ts
  • packages/basalt-ui/src/agent/ai-sdk-transport.ts
  • packages/basalt-ui/src/agent/id.test.ts
  • packages/basalt-ui/src/agent/id.ts
  • packages/basalt-ui/src/agent/thread.ts
  • packages/basalt-ui/src/agent/use-agent-stream.test.tsx
  • packages/basalt-ui/src/agent/use-agent-stream.ts
  • packages/basalt-ui/src/agent/use-agent-thread-runs.test.tsx
  • packages/basalt-ui/src/agent/use-agent-thread-runs.ts
  • packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx
  • packages/basalt-ui/src/index.ts
  • packages/basalt-ui/src/surfaces.ts
  • packages/basalt-ui/src/theme/shadow-surfaces.test.ts

Comment on lines +82 to +93
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 } : {}),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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/demo

Repository: 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' || true

Repository: 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' || true

Repository: 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-L52
  • apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L223-L234
  • apps/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.

Comment thread apps/playground/src/demo/agent-long-thread.ts Outdated
// Mirrors the shipped `VirtualizeOptions.initialScroll` default — see the note above, same reasoning.
const DEFAULT_INITIAL_SCROLL = 'end'

export function AgentTranscriptVirtualizeDemoPage() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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: annotate AgentTranscriptVirtualizeDemoPage.
  • apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx#L26-L26: annotate AgentInlineFeedVirtualizedRowDemoPage.
  • apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193: annotate AgentThreadFeedInlineDemoPage.
  • apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx#L100-L100: annotate AgentAnchorToEndDemoPage.

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-L26
  • apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx#L193-L193
  • apps/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

Comment thread packages/basalt-ui/README.md Outdated
Comment on lines +22 to +32
// ── 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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.txt

Repository: 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.ts

Repository: 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

Comment thread packages/basalt-ui/src/agent-chat/relative-time.test.ts
Comment thread packages/basalt-ui/src/agent-chat/thread-feed.tsx
Comment on lines +346 to +351
return (
<Group
gap={8}
data-testid={`message-affordances-${message.id}`}
style={{ opacity: visible ? 1 : 0, transition: 'opacity 120ms ease' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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 useReducedMotion at the call site.
  • Use the shared MOTION_DURATION and MOTION_EASE_STANDARD tokens 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

Comment thread packages/basalt-ui/src/agent-chat/thread-message.tsx
Comment thread packages/basalt-ui/src/agent-chat/thread-workspace.tsx
jkrumm added 6 commits August 4, 2026 11:25
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Validate count before the generation loop.

Number.POSITIVE_INFINITY makes the loop run without end and append without bound. Fractional values also make slice(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

📥 Commits

Reviewing files that changed from the base of the PR and between 7a35f43 and 79d182e.

📒 Files selected for processing (12)
  • apps/playground/src/demo/agent-long-thread.ts
  • packages/basalt-ui/README.md
  • packages/basalt-ui/llms.txt
  • packages/basalt-ui/package.json
  • packages/basalt-ui/scripts/pack-test.sh
  • packages/basalt-ui/src/agent-chat/relative-time.test.ts
  • packages/basalt-ui/src/agent-chat/thread-feed.tsx
  • packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx
  • packages/basalt-ui/src/agent-chat/thread-workspace.tsx
  • packages/basalt-ui/src/data/index.ts
  • packages/basalt-ui/src/data/virtual-list.tsx
  • packages/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

@jkrumm
jkrumm merged commit 8c95326 into master Aug 4, 2026
3 checks passed
@jkrumm
jkrumm deleted the feat/b4-slack-shape branch August 4, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant