From 891a5dc76376d7b45c137914e356cb02a1261a12 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:22:12 +0530 Subject: [PATCH] Draw the unread line on load The line never appeared. In the commit where ready turned true, the effect that follows the end scrolled to the bottom and the one that notes the scroll position stored the head as read, and the marker, deferred to a microtask, then read back that head. So it was always caught up. The stored mark is now captured on mount, in an effect declared ahead of everything that advances it, and the line is drawn from that capture once the transcript is ready. It is still read after mount, never during render, so the server and client renders still agree, and it is still frozen for the visit. Fixing that surfaced a line on every first visit: with nothing stored the mark is 0, and the divider went above the first item. A line with nothing read above it marks nothing, so it is not drawn there. That rule is pure, in unreadLineBefore, and tested; the effect ordering is not reachable from the node test environment and was checked on the dev server: mark 2 of 4 draws the line before seq 3, no mark draws none, and a message arriving mid-visit leaves the line where it was. Co-Authored-By: Claude Opus 5.5 --- app/components/channel/use-read-marker.ts | 17 +++++---- app/components/transcript.test.tsx | 45 +++++++++++++++++++++++ app/components/transcript.tsx | 10 ++++- 3 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 app/components/transcript.test.tsx diff --git a/app/components/channel/use-read-marker.ts b/app/components/channel/use-read-marker.ts index 09a96a1..10494d1 100644 --- a/app/components/channel/use-read-marker.ts +++ b/app/components/channel/use-read-marker.ts @@ -50,18 +50,21 @@ export function useReadMarker(channelId: string, items: Item[], ready: boolean, // The mark as it was when this visit started: the line is drawn from it and // stays put, however far the reader gets afterwards. Read after paint, not // during render — the server has no localStorage, and a marker that differed - // between the two renders would be a hydration mismatch. + // between the two renders would be a hydration mismatch. Captured on mount, + // ahead of the effects below that advance it. + const markAtStart = useRef(null) + useEffect(() => { + const stored = storedSeq(channelId) + markAtStart.current = stored + readUpTo.current = Math.max(readUpTo.current, stored) + }, [channelId]) + useEffect(() => { if (!ready) return let cancelled = false void Promise.resolve().then(() => { if (cancelled) return - setMarkerAt((current) => { - if (current !== null) return current - const stored = storedSeq(channelId) - readUpTo.current = stored - return stored - }) + setMarkerAt((current) => current ?? markAtStart.current ?? storedSeq(channelId)) }) return () => { cancelled = true diff --git a/app/components/transcript.test.tsx b/app/components/transcript.test.tsx new file mode 100644 index 0000000..5d4c16c --- /dev/null +++ b/app/components/transcript.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { Transcript, unreadLineBefore, type TranscriptItem } from './transcript' + +const items: TranscriptItem[] = [ + { seq: 1, type: 'system', text: 'agent-p joined' }, + { seq: 2, type: 'message', from: { name: 'agent-p', role: 'agent' }, time: '01:14', text: 'first' }, + { seq: 3, type: 'system', text: 'agent-q joined' }, + { seq: 4, type: 'message', from: { name: 'agent-q', role: 'agent' }, time: '01:15', text: 'second' }, +] + +describe('unreadLineBefore', () => { + it('puts the line before the first item past the mark', () => { + expect(unreadLineBefore(items, 2)).toBe(3) + }) + + it('draws nothing while the mark is unknown', () => { + expect(unreadLineBefore(items, null)).toBeUndefined() + }) + + it('draws nothing when everything has been read', () => { + expect(unreadLineBefore(items, 4)).toBeUndefined() + }) + + it('draws nothing when nothing has been, since a line at the top marks nothing', () => { + expect(unreadLineBefore(items, 0)).toBeUndefined() + }) + + it('skips items without a seq when looking for the first unread one', () => { + const drafts: TranscriptItem[] = [ + ...items, + { type: 'message', from: { name: 'me', role: 'human' }, time: '', text: 'x' }, + ] + expect(unreadLineBefore(drafts, 4)).toBeUndefined() + }) +}) + +describe('Transcript', () => { + it('draws the line once, directly before the first unread item', () => { + const html = renderToStaticMarkup() + expect(html.match(/New messages below/g)).toHaveLength(1) + expect(html.indexOf('New messages below')).toBeGreaterThan(html.indexOf('first')) + expect(html.indexOf('New messages below')).toBeLessThan(html.indexOf('agent-q joined')) + }) +}) diff --git a/app/components/transcript.tsx b/app/components/transcript.tsx index 410d923..92beb08 100644 --- a/app/components/transcript.tsx +++ b/app/components/transcript.tsx @@ -174,6 +174,13 @@ function UnreadLine() { ) } +/** Nothing above the first item has been read, so a line there would mark nothing. */ +export function unreadLineBefore(items: TranscriptItem[], unreadAfter: number | null): number | undefined { + if (unreadAfter === null) return undefined + const index = items.findIndex((item) => item.seq !== undefined && item.seq > unreadAfter) + return index > 0 ? items[index].seq : undefined +} + export function Transcript({ items, animate = false, @@ -201,8 +208,7 @@ export function Transcript({ name: person.name, colour: colorFor(person.name, person.role), })) - const firstUnread = - unreadAfter === null ? undefined : items.find((item) => item.seq !== undefined && item.seq > unreadAfter)?.seq + const firstUnread = unreadLineBefore(items, unreadAfter) return (
    {items.map((item, i) => {