Skip to content

feat(app): sync the unread read-position across clients via session metadata - #1580

Open
chphch wants to merge 4 commits into
slopus:mainfrom
chphch:feat/unread-read-position-sync
Open

feat(app): sync the unread read-position across clients via session metadata#1580
chphch wants to merge 4 commits into
slopus:mainfrom
chphch:feat/unread-read-position-sync

Conversation

@chphch

@chphch chphch commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Depends on #1374. GitHub can't base a PR on a branch outside this repo, so this one carries #1374's three commits underneath. Only the last commit (feat(app): sync the unread read-position across clients via session metadata) is new here. Merge #1374 first and this collapses to that single commit; happy to rebase whenever it lands.

Problem

Unread is local-only: an in-memory unreadSessionIds set, filled when a session goes thinking → idle and cleared on view. Two consequences:

  • Reading a session on the phone leaves it unread on the desktop, and vice versa.
  • Every unread mark disappears on app restart — the set is never persisted.

Approach

Derive unread from a synced read position instead of tracking a local set.

Value Meaning Lives in
metadata.lastReadSeq highest message seq the user has read, on any device session metadata (synced)
Session.lastMessageSeq highest message seq that has arrived local, mirrored from the sync engine's cursor

hasUnread = lastMessageSeq > lastReadSeq, minus the session currently open.

It rides the existing session-metadata channel — the same one the permission/model/effort picks use (#1492) — so no server change is needed. Writes go through sync/ops (sessionMarkRead, sessionMarkUnread) with the same optimistic-push + inbound-reconcile handling: the local value applies immediately, and while our push is in flight an echoed metadata update can't roll it back (isAgentModePushPending).

Two details worth calling out, both of which caused real bugs while building this:

  • Session.seq can't stand in for lastMessageSeq. Metadata writes bump Session.seq too, so storing a read position would itself look like a new message and immediately re-mark the session unread. Hence the separate message-only mirror.
  • Absent lastReadSeq counts as read. Otherwise the first run after upgrading flags all of history unread. This also matches the previous behavior, where a restart cleared unread.

Also in this commit

Both fall out of the model change rather than being separate features:

  • "Mark as unread" in the session long-press menu. It only becomes meaningful once the read position is synced — as a local flag it wouldn't survive a restart or reach another device. Menu action ids get their own SessionActionId type, since this action carries no keyboard chord and SESSION_ACTION_SHORTCUTS stays keyed only by the ids that do (looked up through getSessionActionShortcut).
  • buildSessionListViewData's second parameter is now required. It used to be an optional unread set, and three reducers (applyMachines, deleteMachine, and the draft update) omitted it — so any rebuild through those paths silently dropped every unread mark until the next applySessions. Making it required turns that whole class of omission into a compile error.

Verification

pnpm typecheck clean; app suite 779/779. Running on a self-hosted build with multiple clients (phone + desktop web): reading on one device clears the unread mark on the other, and "Mark as unread" brings it back on both.

Generated with Claude Code
via Happy

chphch and others added 4 commits July 26, 2026 18:55
The session-list status dot is overloaded: a session with unread results
was overridden to a solid blue dot — the same color as the "thinking"
(running) state, differing only by a pulse. A finished, idle session
therefore looked like it was still running, and only reverted to its true
green "online" dot after you opened it (which clears the unread flag).

Decouple the two signals: the status dot now always reflects true liveness
(disconnected/thinking/waiting/permission), and unread is shown separately
as a bold title plus a small trailing accent dot. Blue is once again
reserved exclusively for "running". Applies to both the session list rows
and the compact active-sessions group.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Unread rows showed a trailing accent dot plus a bold title, and on web the
bold never rendered: the title uses the IBMPlexSans-SemiBold family and the
app sets `font-synthesis: none` with no Bold(700) face bundled, so an
overriding `fontWeight: 700` had no real face to resolve to. Native
synthesized a heavier weight; web did not.

Render unread purely as a heavier title, and only once the agent has stopped:
- bold via the SemiBold *family* (read titles now Regular) so the weight
  difference renders identically on web and native;
- gate on `hasUnread && state !== 'thinking'` so a running session never bolds;
- drop the trailing unread marker dot.

Applies to both SessionsList and the compact active-sessions group.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Put the bold-unread-title session-list layout behind a new
`expUnreadBoldTitle` setting (off by default) so it ships as an opt-in
experiment rather than changing every user's unread presentation. When off,
the upstream behavior is preserved exactly: unread is a solid blue status
dot plus the "unread" status text, and titles keep their default weight.
When on, unread shows as a bold title (once the agent has stopped) and the
status dot reflects true liveness only.

- settings: add `expUnreadBoldTitle` (default false)
- SessionsList / ActiveSessionsGroupCompact: branch status color, leading
  indicator, status text, and title weight on the setting
- settings/features: add the toggle + i18n strings for all languages

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…etadata

Unread was local-only: an in-memory `unreadSessionIds` set, populated when a
session went thinking -> idle and cleared on view. Two consequences — reading a
session on the phone left it unread on the desktop (and vice versa), and every
unread mark vanished on app restart because the set was never persisted.

Derive unread from a synced read position instead:

- `metadata.lastReadSeq` (new, in MetadataSchema) is the highest message seq the
  user has read, on any device. It rides the same session-metadata channel as
  the permission/model/effort picks (slopus#1492), so no server change is needed.
- `Session.lastMessageSeq` mirrors the sync engine's per-session seq cursor
  (`trackSessionLastSeq`). `Session.seq` cannot stand in for it: metadata writes
  bump that too, so pushing a read position would itself look like a new message
  and re-mark the session unread.
- unread = `lastMessageSeq > lastReadSeq`, minus the session currently open.
  Absent `lastReadSeq` counts as read, so a first run after upgrading does not
  flag all of history unread — matching the old restart-clears-unread behavior.

Writes go through `sync/ops` (`sessionMarkRead`, `sessionMarkUnread`) using the
same optimistic-push + inbound-reconcile mechanism as the mode picks: the local
value is applied immediately, and while our push is in flight an echoed metadata
update cannot roll it back (`isAgentModePushPending`).

Also here, because they fall out of the model change:

- "Mark as unread" in the session long-press menu — now meaningful, since it
  pushes the read position back on every device rather than a local-only flag.
  Menu action ids get their own `SessionActionId` type: the action carries no
  keyboard chord, and `SESSION_ACTION_SHORTCUTS` stays keyed only by the ids
  that do (looked up via `getSessionActionShortcut`).
- `buildSessionListViewData`'s second parameter becomes required. It used to be
  an optional unread set, and three reducers (`applyMachines`, `deleteMachine`,
  and the draft update) omitted it — every rebuild from those paths silently
  dropped all unread marks until the next `applySessions`. Making it required
  turns that class of omission into a compile error.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
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