Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions surface/shared/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ export function isRenderableMessage(m: ChatMessage): boolean {
return !(m.deleted === true && m.replyCount === 0);
}

/**
* Does this message contribute to unread state? Unlike row rendering, deletion
* always removes the unread content; a tombstone may remain to host replies,
* which contribute to unread state through their own broadcast events.
*/
export function countsAsUnread(m: ChatMessage): boolean {
return m.deleted !== true;
}

export interface ChannelTimeline {
/** Root messages: confirmed sorted by id asc, then pending/failed in send order. */
main: ChatMessage[];
Expand Down
17 changes: 16 additions & 1 deletion surface/web/src/components/Timeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ describe('Timeline anchored agent answers', () => {
// unread, scroll landing, and mark-read has to agree it isn't there — otherwise
// deleting the newest message in a channel strands the read cursor forever.
describe('Timeline deleted tail message', () => {
const deletedTail = () => message({ id: 3, text: '', deleted: true, replyCount: 0 });
const deletedTail = (replyCount = 0) => message({ id: 3, text: '', deleted: true, replyCount });

function mockRowVisibility(visibleEid: string) {
return vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
Expand Down Expand Up @@ -512,6 +512,21 @@ describe('Timeline deleted tail message', () => {
expect(screen.queryByTestId('jump-to-unread')).toBeNull();
});

it('does not count a deleted tombstone with replies as unread', () => {
const view = renderTimeline({
messages: [message({ id: 1, text: 'Message 1' }), message({ id: 2, text: 'Message 2' }), deletedTail(1)],
unreadDividerAfterId: 2,
});

expect(view.container.querySelector('[data-eid="3"]')).not.toBeNull();
const log = screen.getByRole('log', { name: 'Messages' });
setScrollMetrics(log, { scrollHeight: 1000, clientHeight: 200 });
log.scrollTop = 0;
fireEvent.scroll(log);

expect(screen.queryByTestId('jump-to-unread')).toBeNull();
});

it('lands at the bottom when the only unread message is a deleted one', () => {
const rect = mockRowVisibility('2');
Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', { configurable: true, value: 1000 });
Expand Down
14 changes: 9 additions & 5 deletions surface/web/src/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ChatMessage, UserRef } from '@atrium/surface-client';
import type { Session } from '../sessions/types';
import { buildTimelineItems, isAgentVoiceBroadcast, isRenderableMessage } from '@atrium/surface-client';
import { buildTimelineItems, countsAsUnread, isAgentVoiceBroadcast, isRenderableMessage } from '@atrium/surface-client';
import { ChevronDownIcon } from './icons';
import { MessageRow } from './MessageRow';
import type { MentionContext } from './useMentionTypeahead';
Expand Down Expand Up @@ -144,6 +144,7 @@ function TimelineImpl({
const anchoredUnread = messages.find(
(message) =>
(message.id ?? 0) > unreadDividerAfterId &&
countsAsUnread(message) &&
isRenderableMessage(message) &&
(message.sessionEventType === 'replied' ||
message.sessionEventType === 'question_requested' ||
Expand All @@ -152,15 +153,18 @@ function TimelineImpl({
loadedRootIds.has(message.threadRootEventId),
);
if (anchoredUnread?.threadRootEventId != null) return anchoredUnread.threadRootEventId;
return visibleMessages.find((message) => (message.id ?? 0) > unreadDividerAfterId)?.id ?? null;
return (
visibleMessages.find((message) => countsAsUnread(message) && (message.id ?? 0) > unreadDividerAfterId)?.id ?? null
);
}, [loadedRootIds, messages, unreadDividerAfterId, visibleMessages]);
// Counted over `messages`, not `visibleMessages`: an anchored answer is
// presented inside its root's cluster, so it is genuinely something new to
// see. A message that paints nothing is not, and counting one strands the
// pill at "1 new" pointing at a row that does not exist.
// see. `countsAsUnread` subsumes the renderability question here — every
// message that paints no row is a deleted one, and a deleted message never
// counts, so the pill can't point at a row that isn't there.
const unreadCount = useMemo(() => {
if (unreadDividerAfterId == null || unreadDividerAfterId <= 0) return 0;
return messages.filter((m) => isRenderableMessage(m) && (m.id ?? 0) > unreadDividerAfterId).length;
return messages.filter((message) => countsAsUnread(message) && (message.id ?? 0) > unreadDividerAfterId).length;
}, [messages, unreadDividerAfterId]);

const isAtBottom = useCallback((el: HTMLElement) => {
Expand Down
Loading