Skip to content

fix(chat): mount assistant avatar at conversation bottom#32583

Merged
dvargasfuertes merged 1 commit into
mainfrom
apollo/avatar-conversation-bottom
May 29, 2026
Merged

fix(chat): mount assistant avatar at conversation bottom#32583
dvargasfuertes merged 1 commit into
mainfrom
apollo/avatar-conversation-bottom

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

What

Move the assistant avatar out of LatestTurnRow and into a new latest-edge region at the bottom of Transcript. The avatar now renders independent of the latest-turn partition: it lives at the bottom of the conversation regardless of whether there is a trailing user message.

Why

The avatar slot was nested inside LatestTurnRow, which only renders when partitionLatestTurn finds a trailing user message. That meant:

  1. Recovered / assistant-only conversations (no trailing user message) had no avatar at all — partition.anchorMessage was null, so the whole LatestTurnRow tree (including the avatar slot) was skipped. This is the case Vargas hit in the Workspace tools (recovered) conversation.
  2. The avatar appeared visually tied to the latest turn rather than to the conversation, even though the design intent is conversation-level.

How

  • Pull the viewport-min-height wrapper, the flex-1 spacer, and the data-latest-edge sentinel out of LatestTurnRow and into a new region in Transcript. That region renders whenever there is an anchor OR a renderAvatar callback.
  • LatestTurnRow is now just the anchor + response cluster. No more avatarSlot / viewportMinHeight props.
  • DOM identity for the avatar is preserved across turn boundaries the same way as before (no remount across the user-send → response gap), so the ChatAvatar entrance-spring flicker comment stays accurate.

Tests

  • latest-turn-row.test.tsx simplified: covers the cluster shape (anchor → response order) and asserts the avatar / edge sentinel are NOT rendered here.
  • transcript.test.tsx gains 4 cases for renderAvatar:
    • assistant-only history (no anchor) → avatar still mounts at the bottom
    • anchor + response + avatar → ordering is anchor → response → avatar → edge sentinel
    • renderAvatar omitted → no avatar slot rendered
    • empty conversation + renderAvatar → avatar still renders

All 12 transcript tests pass locally; typecheck clean; eslint clean.

Fix triggered by the Workspace tools (Recovered) conversation Vargas surfaced where the avatar was missing on every historical message — the avatar was only mounting under the latest-turn cluster, which the recovery-migrated conversation effectively never produced for older messages.

…turn cluster

The avatar slot was nested inside LatestTurnRow, which meant it only
rendered when the partition produced an anchor user message. Two
visible failure modes:

1. Recovered/assistant-only conversations (no trailing user message)
   had no avatar at all — partition.anchorMessage is null, so the
   whole LatestTurnRow tree (including the avatar slot) was skipped.
2. When scrolled away from the latest turn, the avatar appeared to
   move with the latest turn rather than living at the conversation
   bottom, which is what Vargas asked for: the avatar should be
   present at the bottom regardless of message state.

Move the avatar out of LatestTurnRow into Transcript itself, inside
a new "latest-edge" wrapper that owns the viewport-min-height
pinning. The wrapper renders whenever there is an anchor OR a
renderAvatar callback, so:

- anchor + avatar: anchor pins to viewport top, avatar pins to
  viewport bottom (same visual layout as before).
- anchor, no avatar: same as before, just no avatar.
- no anchor + avatar: avatar still mounts at the bottom of history.
- empty conversation + avatar: avatar renders inside the wrapper.

DOM identity for the avatar is preserved across turn boundaries the
same way (no remount across user-send → response gap), so the
ChatAvatar entrance-spring flicker comment stays accurate.

Tests:
- latest-turn-row.test.tsx now covers the cluster shape only
  (anchor → response order, no avatar/edge sentinel here).
- transcript.test.tsx adds 4 cases for renderAvatar behavior
  (assistant-only history, anchor + response + avatar order,
  renderAvatar omitted, empty conversation w/ avatar).
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 764e9eeb2b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* whenever the chat has avatar data so DOM identity is
* preserved across turn boundaries (no ChatAvatar entrance-
* spring flicker). */}
{(partition.anchorMessage || rest.renderAvatar) && (
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid auto-pinning below assistant-only history

When renderAvatar is present but there is no latest user anchor, this condition still appends a viewport-height latest-edge region after all historyItems. The scroll coordinator auto-pins to the bottom on conversation switches, so assistant-only histories/onboarding content land just above the viewport and the user sees blank space plus the avatar instead of the latest message. Keep the viewport-height spacer tied to an actual anchor, or render the no-anchor avatar without adding a full bottom region after history.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed real. The style={{ minHeight: viewportMinHeight }} was applied whenever renderAvatar was present — including the no-anchor case (assistant-only history, recovered conversations). With #32239 snapping to bottom on container DOM attach, the viewport landed on the empty flex-1 spacer + avatar, pushing the actual latest message above the visible area.

Fix in #32596: gate both minHeight: viewportMinHeight and the flex-1 spacer on partition.anchorMessage. Avatar now renders inline directly below history when no anchor exists. Regression tests added in transcript.test.tsx covering all three states (no-anchor + avatar, anchor + avatar, anchor + no avatar). 👍

Comment on lines +291 to +296
{partition.anchorMessage && (
<LatestTurnRow
anchorMessage={partition.anchorMessage}
responseItems={partition.responseItems}
{...rowProps}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the avatar when the first turn is inserted

When the transcript starts with no anchor (empty or assistant-only) and then the first user message arrives, this unkeyed LatestTurnRow is inserted before the existing spacer/avatar <div> siblings. React reconciles the following <div>s by index, so the current avatar wrapper is reused as the spacer and ChatAvatar unmounts/remounts, replaying the entrance animation this change is trying to avoid. Add stable keys or keep a stable placeholder before the avatar so its DOM identity survives the no-anchor → anchor transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Investigated empirically — the avatar wrapper is not reused as the spacer and ChatAvatar does not remount across the no-anchor → anchor transition. React's reconcileChildrenArray tracks fiber.index (the previous render's position in the children array, including false-skipped slots), so the existing avatar fiber at fiber.index=2 still matches newIdx=2 after slot 0 transitions from false to <LatestTurnRow> — same <div> type, fiber reused, no entrance-spring replay.

Locked in with a DOM-lifecycle regression test in #32596 (transcript.test.tsx): mount-tracking avatar, three rerenders (no-anchor → anchor → no-anchor), asserts mount=1, unmount=0 throughout. Verified the test framework can detect remount by sanity-checking against a conversationId change (which re-keys the scroll container and forces remount): asserts pass as mount=2, unmount=1.

If we ever do reshape the conditionals (e.g. reorder siblings, change the conditional shape), the regression test will catch the resulting remount. 👎 with respect — but it's a great defense-in-depth nudge and the test wouldn't exist without it.

@dvargasfuertes dvargasfuertes merged commit 7645df6 into main May 29, 2026
7 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/avatar-conversation-bottom branch May 29, 2026 18:47
dvargasfuertes pushed a commit that referenced this pull request May 29, 2026
… on #32583) (#32596)

Two pieces of Codex feedback on #32583. One was a legitimate bug, the
other a misread of React reconciliation that this PR locks in as a
regression test.

## Codex #1 — Legitimate bug, fixed

The merged code applies `minHeight: viewportMinHeight` to the
latest-edge wrapper whenever `renderAvatar` is present, even when
`partition.anchorMessage` is null (assistant-only history: recovered
conversations whose user message was lost, onboarding before the
first submit).

`useViewportMinHeight` reports the scroll container's `clientHeight`
≈ a full viewport. `refactor(web): scroll to bottom on transcript
container DOM attach (#32239)` snaps the scroll to bottom on
conversation switch. With no anchor at the top of the spacer, the
bottom-pinned viewport lands on blank space + the avatar — the actual
latest assistant message sits one viewport above and is invisible
until the user scrolls up.

Fix: gate both `minHeight: viewportMinHeight` and the `flex-1`
spacer on `partition.anchorMessage`. Without an anchor, the avatar
renders inline directly below the last history item.

## Codex #2 — Misread, locked in as a regression test

Codex claimed that inserting `<LatestTurnRow>` at slot 0 (was
`false`) on the no-anchor → anchor transition would cause React to
reconcile the following `<div>` siblings by index, reusing the
current avatar wrapper as the spacer and remounting `ChatAvatar` —
replaying the entrance-spring animation #32583 is trying to avoid.

Empirically verified that this does not happen. React's
`reconcileChildrenArray` tracks `fiber.index` (the previous render's
position in the children array, INCLUDING `false`-skipped slots). When
slot 0 transitions from `false` to `<LatestTurnRow>`, the existing
avatar fiber at `fiber.index=2` still matches `newIdx=2` in the
next render — same `<div>` type, fiber reused, `ChatAvatar` is
not unmounted.

Locked in with a DOM-lifecycle regression test that:
  1. Renders the transcript with no anchor + a mount-tracking avatar.
  2. Asserts mount=1, unmount=0.
  3. Rerenders with the first user message inserted (no-anchor → anchor).
  4. Asserts mount=1, unmount=0 (no remount).
  5. Rerenders back to no-anchor.
  6. Asserts mount=1, unmount=0 again.

A sanity-check version of the test (forcing a conversationId change,
which re-keys the scroll container) was used to verify the mount
tracker actually detects remounts — confirmed mount=2/unmount=1
when remount really happens, so the assertions can fail.

## Tests

- 13 transcript.test.tsx tests pass.
- 3 new Codex #1 cases: no-anchor has no min-height; anchor has
  min-height; anchor-without-avatar still has min-height (regression
  guard for the original viewport-pinning behavior).
- 1 new Codex #2 case: the mount-tracker test above.
- Typecheck clean (`tsc --noEmit --skipLibCheck` in `apps/web`).
- ESLint clean on touched files.
- 4 pre-existing `transcript-message-body.test.tsx` failures in the
  directory-wide run are also present on `main` — caused by mock
  bleed between test files, not by this PR.

Co-authored-by: vellum-apollo-bot[bot] <242025090+vellum-apollo-bot[bot]@users.noreply.github.com>
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