Skip to content

Sync thread and DM read state across a person's devices - #832

Merged
lihongmeng merged 1 commit into
developfrom
feat/cross-device-unread-sync
Oct 24, 2026
Merged

lihongmeng merged 1 commit into
developfrom
feat/cross-device-unread-sync

Conversation

@lihongmeng

Copy link
Copy Markdown
Collaborator

Summary

Reading a thread or DM on the desktop did not clear its unread badge on the phone. Read markers lived only in each client's local storage (web oa-read-<workspace>, mobile SharedPreferences) and were sent per request as read_at; the server computed counts from whatever the device sent and stored nothing. Each device therefore had its own read state.

The server now stores one read marker per person (by email, not per device) and per thread / DM, and computes unread counts from it.

Backend

  • read_markers table (migration 084): (workspace_id, user_email, session_id) → read_at, updated_at (unix ms). session_id is the client session id: the thread name, or the canonical dm:<a>,<b> (two addresses sorted and comma-joined). Markers only move forward, and future times are clamped to the server clock.
  • GET /v1/read-state?network=&since=: the caller's markers (hidden private threads left out). since returns only the markers changed after that server time.
  • POST /v1/read-state {network, markers: {session_id: read_at_ms | null}} (null = server now): moves the markers forward, marks the caller's DM / @mention signals up to the marker as read, and publishes workspace.read_state.updated on the workspace event stream for each marker that moved. Token-only callers get persisted: false and nothing is stored.
  • /v1/discover and /v1/events/conversations: merge the stored markers with any read_at the client still sends, and the newer one wins. read_at / unread_count in the response are based on the merged marker. Clients that send nothing get the stored markers, and older clients keep working unchanged.
  • SSE stream: read-state events go only to the person they belong to (signed in through a bearer header or access_token). Agents, machines and other members never get them. Channel-scoped streams do not carry them.

Web workspace (and the launcher, which embeds the same bundle)

  • Opening a thread / DM is reported as a read. So is keeping the open one read as messages arrive, but only while the window is visible and focused, and again when focus returns. Reports are batched for 1s. First-seen baselines are never reported, so a new device does not mark everything read for the others.
  • Newer markers that come back from discovery and DM listing are written into the local markers. A badge cleared on another device clears here on the next poll (5–15s).

Needs agreement before deploy

This adds a table (migration 084_read_markers). Per workspace/AGENTS.md, the migration has not been run anywhere. It needs the owner's agreement before it is applied, and preview shares the production database. The new code reads the table on every discovery / DM poll from a signed-in person, so the migration must be applied before this backend ships.

Test plan

  • workspace/backend: new tests/test_read_state.py (14 tests): per-person sharing, merge with markers the client sends, markers only moving forward, future-time clamp, DM counts, clearing signals up to the marker, event payload, stream delivery only to the owner, hidden threads, token-only callers, input validation.
  • workspace/backend full suite: 34 failed / 6 errors, the same set as on develop on this machine. That includes test_migration_schema, whose init SQL is still stamped 015.
  • workspace/frontend: tsc --noEmit; vitest run passes except 8 tests in browser-thumbnail / browser-view-keyboard, which also fail on develop; new lib/read-state.test.ts.
  • ruff adds no new findings in app/.
  • Manual: read a thread on web → the phone / a second browser clears it within one poll; read on the phone → the web badge clears; a thread left open in a background tab does not clear the phone's badge until the tab is focused.

Read markers lived only in each client's local storage and were sent as
`read_at` on discovery / DM listing, so reading a thread on the desktop
left it unread on the phone (and the other way round).

Backend
- New read_markers table (migration 084): one marker per workspace,
  person (email) and session id (thread name or canonical dm:<a>,<b>).
  Markers only move forward; future times are clamped to the server clock.
- GET /v1/read-state returns the caller's markers (optionally only those
  changed after `since`); POST /v1/read-state moves them forward, marks
  the caller's DM / mention signals up to the marker read, and publishes
  workspace.read_state.updated on the workspace event stream.
- /v1/discover and /v1/events/conversations merge the stored markers with
  any markers the client still sends (newer wins), so unread counts follow
  the server for every client and older clients keep working.
- The SSE stream delivers read-state events only to the person they belong
  to, never to agents, machines or other members.

Web workspace (also the launcher, which embeds it)
- Opening a thread / DM, and keeping the open one read while the window is
  focused, reports the read (batched). First-seen baselines are not reported.
- Newer markers from discovery and DM listing are adopted locally, so a
  badge cleared on another device clears here on the next poll.
@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
openagents-workspace Ready Ready Preview Oct 24, 2026 5:32pm UTC

Request Review

@lihongmeng
lihongmeng merged commit 9b1c5a5 into develop Oct 24, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview — 044c92ee Deployed Oct 24, 2026 by vercel[bot]
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