feat(app): sync the unread read-position across clients via session metadata - #1580
Open
chphch wants to merge 4 commits into
Open
feat(app): sync the unread read-position across clients via session metadata#1580chphch wants to merge 4 commits into
chphch wants to merge 4 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Unread is local-only: an in-memory
unreadSessionIdsset, filled when a session goes thinking → idle and cleared on view. Two consequences:Approach
Derive unread from a synced read position instead of tracking a local set.
metadata.lastReadSeqSession.lastMessageSeqhasUnread = 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.seqcan't stand in forlastMessageSeq. Metadata writes bumpSession.seqtoo, 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.lastReadSeqcounts 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:
SessionActionIdtype, since this action carries no keyboard chord andSESSION_ACTION_SHORTCUTSstays keyed only by the ids that do (looked up throughgetSessionActionShortcut).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 nextapplySessions. Making it required turns that whole class of omission into a compile error.Verification
pnpm typecheckclean; 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