Skip to content

fix(surface): an agent's answer marks a channel unread live, like any message#563

Merged
gbasin merged 1 commit into
masterfrom
fix/unread-rule-one-list
Jul 17, 2026
Merged

fix(surface): an agent's answer marks a channel unread live, like any message#563
gbasin merged 1 commit into
masterfrom
fix/unread-rule-one-list

Conversation

@gbasin

@gbasin gbasin commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #562, which unified the two SQL copies of the unread rule. There was a third copy — on the client — and it had drifted.

The bug

isNewMessage in the reducer was:

const isNewMessage = (ev.type === 'message.posted' || ev.type === 'session.spawned') && !alreadySeen;

session.replied is missing, but the server's latest_event_id counts it. So when an agent posts a broadcast answer, the client doesn't bump latestEventId live and no badge appears. On the next /sync or channel-list load, channels-loaded recomputes coldUnreadLevel from the server's counter — which does count it — and the badge materializes out of nowhere.

An agent's answer is an ordinary channel message and should mark a channel unread like one. That's the entire point of the product.

The fix

One list, CHANNEL_UNREAD_EVENT_TYPES, living in shared/. The server's SQL builder and the client reducer both read it. Neither can drift, because there's no second copy to drift from — structurally stronger than a contract test.

The generated SQL is byte-unchanged; readCursors tests confirm it against real Postgres.

Import boundary

The server reaches the list via @atrium/surface-client/timeline — the subpath convention every other shared import here follows (/handle, /prefs, /agentProfiles). The barrel is deliberately avoided: it carries a React peer dependency. That required adding the ./timeline subpath to shared's exports map.

Since deploy/Dockerfile.server bundles the server with esbuild and CI never builds that image, a bad import here would break deploy and nothing else. So the real bundle was run locally against this change — it resolves and links clean.

Verification

The new reducer test fails without the fix — expected 10 to be 21, the live counter not moving on a broadcast session.replied.

Local: pnpm lint · typecheck (7/7) · shared 453 · web 775 · server readCursors 6/6 · esbuild bundle.

🤖 Generated with Claude Code

… message

The rule for which events make a channel unread was written down twice: once
in the server SQL behind latest_event_id, once in the client reducer as
isNewMessage. They disagreed. The client's copy was missing session.replied,
so an agent's broadcast answer did not bump the badge live — then the next
/sync recomputed the count from the server, which does count it, and the badge
materialized out of nowhere.

There is now one list, in shared, that the SQL builder and the reducer both
read. Neither side can drift from the other, because there is no other side to
drift from. The generated SQL is unchanged.

The server reaches it through the @atrium/surface-client/timeline subpath, the
convention every other shared import here follows; the barrel is deliberately
avoided because it carries a React peer dependency. Verified through the real
esbuild bundle from deploy/Dockerfile.server, which CI never builds.
@gbasin
gbasin added this pull request to the merge queue Jul 17, 2026
Merged via the queue into master with commit 6cdf466 Jul 17, 2026
16 checks passed
@gbasin
gbasin deleted the fix/unread-rule-one-list branch July 17, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant