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
12 changes: 3 additions & 9 deletions surface/server/src/event-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CHANNEL_UNREAD_EVENT_TYPES } from '@atrium/surface-client/timeline';

export const MODIFIER_EVENT_TYPES = [
'message.edited',
'message.deleted',
Expand Down Expand Up @@ -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`.
Expand All @@ -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)`;
1 change: 1 addition & 0 deletions surface/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions surface/shared/src/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
applyLocalEditOverlay,
applyLocalReactionOverlay,
applyEvent,
CHANNEL_UNREAD_EVENT_TYPES,
confirmLocalOverlay,
emptyTimeline,
markFailed,
Expand Down Expand Up @@ -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<string>(CHANNEL_UNREAD_EVENT_TYPES);

export function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'init-me':
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions surface/shared/src/read-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
13 changes: 13 additions & 0 deletions surface/shared/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading