Running sessions previously suppressed the ordinary unread dot, hiding answers the user had not seen. Unread is now manualUnread || contentRevision > readRevision on web, iOS and Android. Web rows draw the running/background spinner beside the unread dot, and unread outranks background status. Because session patches arrive before the message they count, a chat following its visible tail hides its own ordinary dot until the rendered acknowledgement lands (display-only; web and iPad split view).
4.7 KiB
Session read state
Sources: shared/src/sessionReadState.ts, shared/src/messages.ts,
hub/src/store/sessionReadState.ts, hub/src/sync/messageService.ts.
Read state is shared by Web, iOS and Android within one Hub and JWT namespace.
CLI terminal activity does not acknowledge reading. updatedAt, message seq,
permission state and transport heartbeat are independent of unread status.
Revisions and unread
Session, SessionSummary and session SSE patches carry:
contentRevision: number
readState: { readRevision: number; manualUnread: boolean; version: number }
The hub increments contentRevision atomically with a new readable assistant
message: answer text, rendered media or an error. User prompts, reasoning, tool
traffic, usage, metadata and heartbeats do not advance it. Imported/copied
history starts at zero; live delayed output counts. A replay with the same
localId keeps its original revision. Meaningful message rows carry their
contentRevision in REST and SSE.
Unread is manualUnread || contentRevision > readRevision, including while the
session is running; clients show running activity beside the unread dot. Input
and permission attention remain independent. Selecting a session does not make it read.
Session patches can arrive before the message they count, so a client may hide the
ordinary dot of a session whose visible chat follows its tail (the same conditions as
an automatic read below) until that output renders and is acknowledged. This is
display-only and never sends a read; manual unread stays visible.
Clients merge revisions monotonically and use the highest read-state version
for the manual flag, including when REST and SSE arrive out of order.
Read acknowledgement
PUT /api/sessions/:id/read-state accepts one of:
{"type":"read","readRevision":12,"expectedVersion":4}
{"type":"unread","expectedVersion":4}
The response includes {status, contentRevision, readState}. Counters must be
nonnegative safe integers. An invalid/future revision returns 400. Manual unread
uses compare-and-set: a mismatched version returns 409 with authoritative state.
Read requests advance by maximum; they clear manual unread only when
expectedVersion matches. Thus an older automatic read cannot erase a newer
manual choice, and an acknowledgement of revision 12 cannot consume revision 13.
An old session ID resolving to a replacement returns 409: revisions cannot be
replayed against a different session. Namespace access is checked before mutation.
Read writes emit session-updated patches without advancing updatedAt.
Automatic reads require a visible foreground chat, successful message loading, and the latest tail laid out on screen. Background, history browsing, hidden inspectors/terminals, failed loads and list navigation cannot acknowledge reading. A read-state-only update is not a new viewing action. Explicit “mark all read” uses the content revisions observed in that list action.
Latest message pages carry page.readThroughRevision. A completed forward
catch-up carries it only if its snapshot still reaches the current head. Before
pages and partial/bounded catch-ups cannot certify a later head. Clients retain
the receipt with the corresponding message window; they can advance it with
rendered incoming message revisions after initial tail synchronization. Never
substitute the summary’s head revision for a rendered window receipt.
Offline and lifecycle
Persist the exact observed revision and expected version in a durable outbox before presenting a successful local read. Retry transient failures, including after restart/foregrounding. Each operation has an ID so an old response cannot remove a newer queued operation. Authoritative 400/403/404/409 rejection rolls back the optimistic mark; do not blindly rebase onto a newer manual choice. Storage keys include Hub origin and JWT namespace; never derive namespace by splitting the pairing access token. Switching Hub must finish pending disk writes.
Upgrade and history changes
Database v30 adds persistent read fields with zero defaults. Existing history becomes a read baseline once; old client timestamp/manual-marker files are ignored. This clears old unread dots on upgrade, while pending approvals/input remain. Update Hub and clients together; clients do not fall back to timestamp-based reads.
Fork/import copies are baseline history. A session merge translates unread source revisions above the destination head, transfers manual unread, and retires the source state. Client windows seeded across IDs discard old message revisions and fetch a fresh tail. Rewind never reuses revisions; if all unread output is removed, it retires that output while preserving an explicit manual-unread choice.