githubnext/chopinPublic

Let's compose rich plans together

Ease live Chat arrivals and keep the transcript anchored #350

MergedMaggieAppletonmerged 3 commits intomainfromrefs/pull/350/head
  1. MA

    ## Why

    Chat content arrived in a single frame. New messages, Planner rows and the 130–160px research suggestion card popped in and shoved the transcript up. When a follow-up moved an offer card below the newer message, the card teleported. Splitting a speaker's messages around a card repeated their name and time. When rows grew, the pane resized, or you sent a message on phone, the newest line could end up under the composer. The research Chat notice used the sign-in arrow, and a ready request announced itself both on its card and in a system line.

    Covers motion finding 5, research findings 6 and 13, and real-Planner finding 11 from the 2026-10-07 audit.

    ## What changed

    - **Live entry:** new rows (messages, Planner turns, notices, offer cards) and messages added to an existing group use one CSS preset through `@starting-style`: opacity 0→1 and `translateY(4px)`→0 over `--duration-base` with `--motion-smooth-out`. Only live arrivals animate. A render only marks rows after the current connection's history has arrived (`live` from Chat), and never when more than 3 new items arrive at once, so history, reconnects and document switches stay still. A mark stays on its row for the row's lifetime, so a re-render can't cut a fade short. - **One row per Planner turn:** a turn keeps the key `turn:<id>` from its placeholder through completion, so it enters once and doesn't remount when its first entry arrives. - **Offer moves:** when an offer's placement changes, the card slides from its last painted position to its new one (FLIP with WAAPI, `--acc-expand` and `--motion-move`). Positions are content-relative, so scrolling and bottom anchoring don't count as movement. - **No repeated header:** a speaker's messages that continue after an offer card no longer repeat the avatar, name and time. - **Bottom anchoring:** the transcript scrolls to the bottom before paint. A `ResizeObserver` on the stack and the scroller keeps it on the newest line as rows grow and the pane resizes, but only if you were within 48px of the bottom. Your own new message always scrolls into view. - **Research notices:** "Research is ready" uses `SparkleIcon`, and "Research could not be completed" uses `WarningIcon`, matching the document widget. When a visible offer card links to the same ready child document, the system line is hidden, so the request announces itself once. - Reduced motion: the entry transition is off, and the FLIP is skipped.

    Not changed here: the grey avatar placeholder. It's the `bg-selected` background on `Face`'s `<img>` while the photograph loads (`packages/editor/src/face.tsx`). #295 rewrites those exact lines with a tint computed synchronously from the handle, so I left it to that PR. The fade-in also hides most of the flash in the meantime. `research-offer.tsx` and the work-stage and tool-row code are untouched.

    ## Screenshots

    Message arrival, before (pops in) / after (fades and rises):

    ![Before: message arrival](https://github.com/user-attachments/assets/4960563b-7215-4c74-a1c0-d50d8e8cf300) ![After: message arrival](https://github.com/user-attachments/assets/abb6b334-75ed-4b9e-9d73-5c11151706c7)

    Research offer arrival, before / after:

    ![Before: offer card arrival](https://github.com/user-attachments/assets/19e07136-464b-4203-84de-0ab64af7e8ce) ![After: offer card arrival](https://github.com/user-attachments/assets/021e54d1-ce00-4802-ab6f-179873ab4c2a)

    Offer moving below a material follow-up, before (teleports) / after (slides):

    ![Before: offer move filmstrip](https://github.com/user-attachments/assets/e00cc7b6-04ac-4aca-8298-a7113aab85ba) ![After: offer move filmstrip](https://github.com/user-attachments/assets/126cb7d0-7227-49cc-ae86-bb267d02cee2) ![After: offer move](https://github.com/user-attachments/assets/ef31cad2-b546-47c6-bcec-3dcb3bb1e309)

    Message after an offer card, before (header repeated) / after:

    ![Continued group before and after](https://github.com/user-attachments/assets/326c3878-97ff-4333-98c9-bdd480e34c8d)

    Phone, after six sends (the newest line stays above the composer):

    ![Phone pinned to bottom](https://github.com/user-attachments/assets/a32d7781-8cd2-4206-8fd9-01d77eafa77f)

    ## Testing

    - `bun run types`: pass - `bun run ci` (dprint, oxlint, tokens, design contract, design record, Impeccable): pass - `bun test apps/web`: 502 pass. New tests cover the continued group without a repeated header, and the ready notice being hidden only when a visible linked card shows the same child. - Browser checks against the fake-GitHub server with fake Jev, using rAF sampling: the entry runs 0→1 opacity and 4→0px over about 200ms, including the first message after a reload; reduced motion gives opacity 1 with a 0s transition; reload shows no entry animation; the offer slides 44px over 250ms on a move; on phone at 390×844 the last message stays above the composer. - I couldn't reach a real ready or failed research state locally (web search is unavailable on the fake server), so the icon and suppression changes are covered by unit tests only. - Final rebased head `859f33f2` passed hosted validation, E2E, and container checks; local E2E was suspended for this audit run.

    🤖 Generated with [Claude Code](https://claude.com/claude-code)

  2. MA
    Author

    **Review:** Changes needed

    Visual and motion check done on a headless Playwright run (two users, 1440x900 and 390x844). Most of the PR works well; one stated goal (ResizeObserver bottom anchoring) does not work once the transcript is longer than the pane.

    **Blocking**

    1. `apps/web/src/chat/transcript.tsx` (stack div `flex min-h-full flex-col`, observed at the `ResizeObserver` effect): the observer watches `stack`, but `stack` is a shrinkable flex child of the `overflow-auto` flex-col scroller with `min-height: 100%`. Once content exceeds the pane, its box stays clamped at the pane height (measured 647px while `scrollHeight` grew 756 -> 1476) and the content overflows it, so the observer never fires. Repro: pinned at the bottom with ~16 messages, expand "Work details" on the Planner reply. `scrollHeight` grows 62px, the observer never fires, and the reply is left 62px below the fold (gap 62, screenshot shows the reply cut off). Only pane resizes (which the second `observe(element)` catches) and the pre-existing `[entries, queued]` effect anchor correctly, so row growth that is not an `entries` change (details expanding, offer card state changes, images) is not followed. Fix: add `shrink-0` to the stack so its box grows with its content. I verified at runtime that `style.flexShrink = "0"` on `[data-chat-stack]` makes the same repro end at gap 0.

    **Minor / non-blocking**

    2. `pinned` uses `distance < 48` and the observer then hard-sets `scrollTop = scrollHeight`. A reader who has scrolled up 1-47px is still treated as pinned and is pulled back when a row grows. Acceptable, but consider re-pinning only on an explicit scroll-to-bottom or using a tighter threshold for the observer. 2. `places` (offer positions) is never pruned when an offer leaves the transcript. Tiny, but it is an unbounded Map keyed by offer id for the lifetime of the Transcript. 3. Messages that arrive while the Chat tab is hidden on phone keep `data-chat-enter`, so they fade in when the tab is first opened (display none -> block resolves starting styles). Harmless and arguably nice, but not the "history does not animate" described. 4. Tests only cover the pure grouping and the SSR header/announcement behaviour. Nothing covers the motion gating or anchoring; per AGENTS.md those belong in a `*.e2e.ts` (a pinned-then-details-expand case would have caught item 1).

    **Verified working (no change needed)**

    - Single live message: opacity 0 -> 1 and translateY 4px -> 0 over ~150ms (about 18 frames at 60Hz), on both the sender's and the receiver's view, scroll gap 0 on every frame. - Streaming Planner reply (fake Planner replies instantly): the row keeps one key from placeholder to final, no re-trigger of the enter animation, scroll gap 0 throughout. - Scrolled-up reader is not yanked: scrollTop stayed at 300 across 3 incoming messages; on phone also unchanged. Sending your own message re-pins to the bottom. - Real reconnect (WebSocket dropped via `routeWebSocket`, 6 messages missed): 0 enter transitions on catch-up; next live message animates again. Initial load, reload, full navigation and in-app sidebar document switching: 0 enter attributes, 0 transitions. - Offer card re-placement: slides from its old painted position to the new one in ~250ms (translateY -35px -> 0, ease-out), simultaneous with the new row fade, no jump. The speaker's messages after the card continue without a repeated avatar/header and stay aligned to the text column. - Reduced motion: transitions are not created (0 transitionrun), rows and the card are at opacity 1 immediately. - Phone 390x844: opening the Chat tab lands at the bottom; shrinking the viewport to 390x480 (keyboard) keeps the last line visible (gap 0), live messages keep following, and a scrolled-up reading position is preserved when the keyboard opens and closes. - 260-message transcript (about 2500 chat DOM nodes): no ResizeObserver loop errors, no long tasks on load or on 10 live arrivals, p50 frame 8ms, p99 33ms, final gap 0. - Conventions: tabs, `let`, tokens only (`--duration-base`, `--motion-smooth-out`, `--acc-expand`, `--motion-move`), transform/opacity only, `prefers-reduced-motion` honoured.

  3. MA
    Author

    Thanks for the review. I've addressed it in 37f22525 and 8547a83f, and all checks pass, including the new e2e.

    1. **Blocker (stack clamped to the pane):** I added `shrink-0` to the stack, so the `ResizeObserver` now sees rows grow once the transcript overflows. The new e2e turned up a second cause. The initial `scrollIntoView` on the bottom spacer stopped above the scroller's 12px padding, so after a load the reader was never at the bottom. The transcript now sets `scrollTop = scrollHeight` on its own scroller instead. 2. **Readers 1–47px up:** growth now follows only a reader resting on the last line (≤2px, tracked as `flush`). The 48px `pinned` threshold now only applies when a new entry arrives, which is the existing behaviour. Your own message still re-pins. 3. **`places`:** it was already pruned. The rAF rebuilds the map from the offer cards in the DOM after every commit, so removed offers drop out. 4. **Hidden Chat tab:** when Chat is inactive or not rendered, entry marks are cleared and `data-chat-enter` is removed before it's shown. Rows that arrived while it was hidden no longer fade in when you open it. 5. **Tests:** new `e2e/chat-anchoring.e2e.ts`. It loads a 30-message transcript (taller than the pane), grows the last row, and checks it is still pinned. It then scrolls up 300px, grows the last row again, and checks the reading position doesn't change.

    I re-checked on the fake server: - Load gap is 0, and still 0 after a row grows. - A reader 20px up is not moved. - At 390px the last message stays above the composer. - The entry fade still runs.

  4. MaggieAppletonreferenced this in Show which messages went to Chopin #371last month on GitHub

  5. MaggieAppletonhead-ref-force-pushed 859f33f22361last month on GitHub

  6. MaggieAppletonmerged fbda05d96d93last month on GitHub

  7. MaggieAppletonclosedlast month on GitHub

  8. MaggieAppletonhead-ref-deletedlast month on GitHub