Skip to content

feat(dashboard): Keep each reader's place in a Conversation - #2513

Merged
BYK merged 8 commits into
mainfrom
feat/dashboard-read-position
Nov 2, 2026
Merged

BYK merged 8 commits into
mainfrom
feat/dashboard-read-position

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Like Slack, the dashboard now remembers how far each person has read a Conversation. It opens there and marks the spot with a "New" line.

Screenshots

Desktop Mobile
Desktop Conversation opened at the New line, with read messages above it and unread messages below Mobile Conversation opened at the New line, with the Jump to latest button

Settings page with the Open conversations at choice: Where I left off or Latest message

Server

  • Adds the junior_conversation_read_positions table (migration 0071). It keeps one row per user and Conversation.
  • Adds PUT /api/conversations/:id/read-position. The stored value only moves forward (greatest(existing, new)). It is also capped at the last event, so devices can report in any order, and a bad client cannot hide messages that come later.
  • The detail response now includes readPosition for the signed-in viewer. Other viewers never see it.

Dashboard

  • Rows built from stored events now carry data-transcript-seq. While the page is visible, the transcript reports the last row that reached the viewport, 750 ms after scrolling or layout changes stop. It reports only forward. A hidden tab and search results do not report. Leaving the page or hiding the tab sends the pending report at once.
  • When a Conversation opens, the page saves the read position from the first detail. The last unread row comes from the first detail fetched in that visit, so a cached copy cannot hide messages that arrived while the reader was away. Rows that were unread then get a "New" separator (role="separator"). Rows that arrive later get no marker, so a reader who is following live replies does not see one.
  • The detail is fetched on each visit. The cached copy shows at once, and the view moves to the marker when the fetch brings it, unless the reader already scrolled.
  • By default, the transcript opens at the marker with a little context above it, and "Jump to latest" is shown. If all unread rows fit on screen, it opens at the bottom as before.
  • Settings has a new per-browser choice, "Open conversations at": Where I left off or Latest message. The read position itself stays synced on the server.
  • A successful report updates the cached detail, so reopening the Conversation in the same session uses the new position.
  • Mock reporting stores read positions per process, so local QA works.

Known limits

  • If the saved position comes before the first loaded history page, the marker shows at the top of the loaded rows. It moves up when older pages load, and the scroll anchor from fix(dashboard): Hold the reader's place when transcript layout changes #2511 keeps the view steady.
  • Other devices get the new position on their next detail fetch. A transcript that is already open keeps the state from when it opened, like Slack.

Validation

  • read-position, detail, and archive integration tests: 10 passed.
  • Dashboard unit tests: 48 files, 324 tests passed.
  • Dashboard e2e (Chromium): full suite 63 passed. conversation-send "hands web and external messages… without gaps" failed once under full-suite load, then passed alone and with --repeat-each 2.
  • New mobile e2e: "opens where the reader left off and reports what they read" and "opens at new messages after a cached reopen and keeps a quick exit".
  • Typecheck, lint, migration metadata, file length, test architecture, and dashboard style checks pass.
  • WebKit/iOS Safari was not tested in the sandbox.

via @BYK.

--

View Session in Sentry

@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
junior-docs Ready Ready Preview Nov 2, 2026 10:19pm UTC

Request Review

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@sentry-junior
sentry-junior Bot force-pushed the feat/dashboard-read-position branch from 7faecc3 to 87a442c Compare November 1, 2026 22:18
@sentry-junior
sentry-junior Bot force-pushed the fix/dashboard-transcript-scroll-anchor branch from 3dc1599 to 21b49b6 Compare November 2, 2026 10:41
Base automatically changed from fix/dashboard-transcript-scroll-anchor to main November 2, 2026 10:45
@sentry-junior
sentry-junior Bot force-pushed the feat/dashboard-read-position branch from 2ebbecc to 7ac0f0b Compare November 2, 2026 10:46
sentry-junior Bot and others added 2 commits November 2, 2026 10:47
Store one forward-only read position per user and Conversation, so all
their devices share it. Detail returns the viewer's position, and the
transcript reports the last row the reader saw.

When a Conversation opens, rows that were unread get a "New" marker and
the transcript opens there. A browser setting can open at the latest
message instead.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Search results skip rows, so the transcript does not report reads while
a search is active.

A detail poll that started before a read report finished could put an
older read position back in the cache. Keep the higher position, since
read positions only move forward.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

4 screenshot changes — 3 changed · 1 added · 0 removed

Review screenshots in Frameshift

Conversation Child Agent Drawer · Desktop
Conversation Child Agent Drawer · Desktop
Changed
Conversation Child Agent Drawer · Mobile
Conversation Child Agent Drawer · Mobile
Changed
Settings · Desktop
Settings · Desktop
Changed

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

A reopen showed the cached detail and froze the unread range from it, so
messages that arrived while the reader was away got no New marker. Fetch
the detail on each visit, take the last unread row from that fetch, and
move to the marker when it appears before the reader scrolls.

Leaving the page right after a scroll also dropped the pending read
report. Send the measured row when the page unmounts or hides.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

The unread range waited for the deferred render to catch up with the
fetched detail. A poll could land first and add replies that arrived
after open, so a reader at the latest message jumped to a late New
marker. Take the last unread row from the first fetched detail, not the
deferred copy.
Transcript entries are in seq order. Find the first seq after the read
position once, then put the marker before the first row that reaches it,
instead of checking each row and each activity group entry against the
whole unread range.

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a few optimizations we can have and am not sure are already in this code:

  1. On initial conversation loads, we can cap how far into the present we fetch initially based on the known "last read state" and then load piece meal in the background after the initial load/paint. We should also cap how much further back into the past we load and do virtual lists.
  2. To track positions we probably should leverage IntersectionObserver instead of relying on scroll events etc.
  3. When updating last read position, we should at least check locally that if it is larger than the last value we read from the server before making the request, right?

The read reporter measured rows on each scroll. An IntersectionObserver
now keeps the set of rows in the viewport, and a MutationObserver adds
rows as they mount. Reports send the highest seq in that set.

A report at or before the cached server position is not sent. The New
marker seq uses a binary search over the sorted transcript entries.
@sentry-junior

sentry-junior Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Replies to the review, in order. The code changes are in da0d25b.

  1. Fetch around the read position, background loads, virtual list. Not in this PR. Today the detail returns the latest 500 events, and older pages load only when the reader comes near the top (an IntersectionObserver on a sentinel). Nothing loads further back in the background. To start at the read position, the API needs an "around this seq" page and forward pages. The client then needs a virtual list. Both change the scroll anchoring from fix(dashboard): Hold the reader's place when transcript layout changes #2511, the bottom pinning, and in-page search (it searches only loaded rows). I suggest a separate PR for this, so this one stays a read-position change.
  2. IntersectionObserver. Done. The reporter no longer listens to scroll events or measures rows. An IntersectionObserver keeps the set of rows in the viewport, a MutationObserver observes rows as they mount and unmount, and a report sends the highest seq in that set. The 750 ms delay, forward-only rule, pause for search, and send on leave or hide are the same.
  3. Check locally before the request. Done. The reporter already skipped seqs at or before the value at open and its own last report. useMarkConversationRead now also reads the cached detail's readPosition (from polls and other devices) and does not send a seq at or before it. The server greatest() stays, because two devices can still race.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

An activity group row keeps its node and raises data-transcript-seq as
tools arrive. Watch that attribute so the read reporter sees the new seq
while the row stays in view.
@BYK
BYK enabled auto-merge (squash) November 2, 2026 22:03
…osition

# Conflicts:
#	packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts
#	packages/junior-dashboard/src/client/pages/settings/SettingsPage.tsx
#	packages/junior/migrations/meta/0071_snapshot.json
#	packages/junior/migrations/meta/_journal.json
#	packages/junior/src/api/conversations/AGENTS.md

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f3f9504. Configure here.

@BYK
BYK merged commit dfc2e59 into main Nov 2, 2026
64 of 66 checks passed
@BYK
BYK deleted the feat/dashboard-read-position branch November 2, 2026 22:30

This branch was successfully deployed

1 active deployment
Preview – junior-docs — f3f9504c Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant