diff --git a/surface/server/src/event-types.ts b/surface/server/src/event-types.ts index 79b6e6e1..21e3fe1e 100644 --- a/surface/server/src/event-types.ts +++ b/surface/server/src/event-types.ts @@ -1,3 +1,5 @@ +import { CHANNEL_UNREAD_EVENT_TYPES } from '@atrium/surface-client/timeline'; + export const MODIFIER_EVENT_TYPES = [ 'message.edited', 'message.deleted', @@ -100,14 +102,6 @@ export const SYNC_CATCHUP_RAW_EVENT_TYPES = [ ...SYNC_EVENT_TYPES.filter((type) => !TIMELINE_EVENT_TYPE_SET.has(type)), ]; -// === the unread rule === - -// Events that make a channel unread. A thread reply counts only when it is -// broadcast: the agent's answer is an ordinary channel message and marks the -// channel unread like one, or the very thing you asked for lands below the fold -// with nothing to say it arrived. -const UNREAD_EVENT_TYPES = ['message.posted', 'session.spawned', 'session.replied'] as const; - /** * Per-channel `latest_event_id` — the newest event that should draw attention. * Correlates against `c.id`, so it belongs inside a LATERAL over `channels c`. @@ -127,6 +121,6 @@ export const CHANNEL_LATEST_EVENT_ID_SQL = ` FROM events e LEFT JOIN message_state ms ON ms.event_id = e.id WHERE e.channel_id = c.id - AND e.type IN ${sqlTypeList(UNREAD_EVENT_TYPES)} + AND e.type IN ${sqlTypeList(CHANNEL_UNREAD_EVENT_TYPES)} AND (e.thread_root_event_id IS NULL OR (e.payload->>'broadcast')::boolean IS TRUE) AND NOT COALESCE(ms.is_deleted, false)`; diff --git a/surface/shared/package.json b/surface/shared/package.json index 40989a7a..395be87c 100644 --- a/surface/shared/package.json +++ b/surface/shared/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./src/index.ts", "./handle": "./src/handle.ts", + "./timeline": "./src/timeline.ts", "./mentions": "./src/mentions.ts", "./reactions": "./src/reactions.ts", "./prefs": "./src/prefs.ts", diff --git a/surface/shared/src/appState.ts b/surface/shared/src/appState.ts index 7413a450..7ef39c10 100644 --- a/surface/shared/src/appState.ts +++ b/surface/shared/src/appState.ts @@ -7,6 +7,7 @@ import { applyLocalEditOverlay, applyLocalReactionOverlay, applyEvent, + CHANNEL_UNREAD_EVENT_TYPES, confirmLocalOverlay, emptyTimeline, markFailed, @@ -340,6 +341,8 @@ function isMainTimelineVisibleEvent(ev: WireEvent): boolean { return ev.threadRootEventId == null || ev.broadcast === true || ev.payload?.broadcast === true; } +const CHANNEL_UNREAD_EVENT_TYPE_SET = new Set(CHANNEL_UNREAD_EVENT_TYPES); + export function appReducer(state: AppState, action: AppAction): AppState { switch (action.type) { case 'init-me': @@ -517,7 +520,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { for (const ev of action.events) { const alreadySeen = seenIds.has(ev.id); seenIds.add(ev.id); - const isNewMessage = (ev.type === 'message.posted' || ev.type === 'session.spawned') && !alreadySeen; + const isNewMessage = CHANNEL_UNREAD_EVENT_TYPE_SET.has(ev.type) && !alreadySeen; if (!isNewMessage || !ev.channelId) continue; if (isMainTimelineVisibleEvent(ev)) { next = { @@ -644,7 +647,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { applyEvent(t, ev, action.catchupCursor !== undefined ? { catchupCursor: action.catchupCursor } : {}), ); } - const isNewMessage = (ev.type === 'message.posted' || ev.type === 'session.spawned') && !alreadySeen; + const isNewMessage = CHANNEL_UNREAD_EVENT_TYPE_SET.has(ev.type) && !alreadySeen; if (isNewMessage && isMainTimelineVisibleEvent(ev)) { // Live events must advance the cold counter — the unread divider and // unmute re-derivation compare latestEventId against lastReadEventId. diff --git a/surface/shared/src/read-cursor.test.ts b/surface/shared/src/read-cursor.test.ts index 86469f24..180f7ade 100644 --- a/surface/shared/src/read-cursor.test.ts +++ b/surface/shared/src/read-cursor.test.ts @@ -207,6 +207,29 @@ describe('monotonic channel snapshots', () => { }); }); +describe('live unread counters', () => { + it('advances latestEventId and unread for a broadcast session reply', () => { + const state = appReducer(seed(10, 10), { type: 'select-channel', channelId: null }); + const reply: WireEvent = { + id: 21, + workspaceId: 'w1', + channelId: 'c1', + threadRootEventId: 7, + type: 'session.replied', + actorId: null, + payload: { session_id: 's1', text: 'Done', broadcast: true }, + createdAt: '2026-07-16T12:00:00.000Z', + author: { id: 'agent:s1', handle: 'agent', displayName: 'Agent' }, + broadcast: true, + }; + + const next = appReducer(state, { type: 'server-event', event: reply }); + + expect(next.channels[0]?.latestEventId).toBe(21); + expect(next.unread.c1).toBe(true); + }); +}); + describe('newestConfirmedMainEventId', () => { it('ignores a trailing invisible modifier and unconfirmed rows', () => { let state = appReducer(seed(), { diff --git a/surface/shared/src/timeline.ts b/surface/shared/src/timeline.ts index b563e23e..00a9f9f3 100644 --- a/surface/shared/src/timeline.ts +++ b/surface/shared/src/timeline.ts @@ -295,6 +295,19 @@ export type TimelineOverlay = previousHad: boolean; }; +/** + * Events that make a channel unread. The one list: the server's `latest_event_id` + * SQL and the live client reducer both read it, so the cold counter and the live + * badge cannot disagree about what counts. + * + * `session.replied` is here because an agent's answer is an ordinary channel + * message and marks the channel unread like one — leave it out and the very thing + * you asked for lands below the fold with nothing to say it arrived. A thread-rooted + * event still only counts when it is broadcast; that clause is applied separately by + * each caller. + */ +export const CHANNEL_UNREAD_EVENT_TYPES = ['message.posted', 'session.spawned', 'session.replied'] as const; + /** Event types that produce a timeline row. */ function isRowEvent(type: string): boolean { return (