Repository navigation
perf(dashboard): Keep the detail version when the reader marks a Conversation read - #2720
Conversation
…ersation read Co-Authored-By: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
9 screenshot changes — 9 changed · 0 added · 0 removed Review screenshots in Frameshift |
…#2731) The dashboard entry (`client-[hash].js`) is 2.85 MB raw. An esbuild metafile showed about 400 KB of it comes from code the browser never runs. | entry | raw | gzip-9 | br-11 | |---|---|---|---| | main | 2,847,273 | 814,316 | 661,472 | | this PR | 2,443,134 | 739,683 | 605,140 | | change | −404 KB (−14%) | −75 KB | −56 KB | The saving is bigger raw than compressed because locale tables compress well. The browser still has to parse and compile less JS, which matters most on phones. ## Changes - **zod locales: 426 KB → 103 KB.** `import { z } from "zod"` reads the `export * as z` namespace object. esbuild can't tree-shake that, so every zod locale (he, ru, ta, …, 248 KB) and the JSON-schema code stayed in the bundle. `import * as z from "zod"` gives the same API (zod 4 docs use it) and shakes correctly. On a one-line test bundle, the named import gives 433 KB and the namespace import 81 KB. This is a repo-wide codemod (325 files, import lines only), because one leftover named import anywhere in the browser graph brings everything back. - New oxlint rule `junior/zod-namespace-import` (in `tools/oxlint/junior`) keeps it that way. It is on in the junior config, which the plugin packages and `junior-plugin-api` share, and in the dashboard config. Root `pnpm lint` now also lints `junior-plugin-api`, because its code ships in the bundle. Type-only imports are allowed. The built-in `no-restricted-imports` can't do this: with `importNames: ["z"]` it also rejects `import * as z`. - **drizzle-orm: 34 KB → 0.** `@sentry/junior/api/schema` imported `AGENT_INVOCATION_STATUSES` and `juniorDestinationKindSchema` from the Drizzle table modules. Those modules call `pgTable(...)` at the top level, so the tables and drizzle-orm came along. The values now live in `chat/agent-invocations/statuses.ts` and `db/schema/destination-kind.ts`, and the table modules import them. `db:generate` reports no schema changes. - **One copy of the plugin API: −17 KB.** With `injectWorkspacePackages`, `@sentry/junior` has its own injected copy of `@sentry/junior-plugin-api`, so the browser loaded both. The client build now aliases the package to one resolved path. ## Not in this PR Splitting routes comes next, as a separate PR with screenshots. The entry still eagerly includes recharts plus d3 and redux (~348 KB, used only by charts), every page under `src/client/pages` (225 KB), and the emoji shortcode table (47 KB). ## Checks - `pnpm typecheck`, `pnpm lint` (including the new rule test in `oxlint-rules:check`), and `db:generate` (no changes). - Dashboard e2e: 89/89 passed, run locally with the CI env unset. - Dashboard unit tests: 368/369. The one failure is `dashboard-output.test.ts` (nitro build: `nitroIntegration` is not exported by a stale `@sentry/server-utils@11.6.0` in this sandbox). It's environmental and not caused by this diff; #2720 reported the same failure on main. - Integration tests: `child-agents`, `locations/*`, conversation `detail`, and `list-usage` (15/15). No UI change, so no screenshots. <!-- junior-session-footer:start --> <!-- junior-conversation-id:local%3Aweb%3A17b6d7f555623680001f2a90 --> -- [View Session in Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A17b6d7f555623680001f2a90/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
…-read-position # Conflicts: # packages/junior/src/api/conversations/detail-etag.ts
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4913b70. Configure here.
| ...(previous.unreadCursor | ||
| ? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage } | ||
| : undefined), | ||
| }; |
There was a problem hiding this comment.
Race drops unread cursor
Medium Severity
An in-flight detail poll that finishes after a read can bring a later latest page while keepLatestReadPosition still trusts the cached unreadCursor decision. That decision was made against the older events, so the cursor is gone and later 304s never restore it. A later visit in the same session then opens on the latest page instead of the unread rows.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4913b70. Configure here.


Opening a conversation built the whole detail report twice.
readConversationDetailEtag, added in #2655, hashed the viewer's read position, and the transcript sendsPUT /read-positionsoon after it opens and again as the reader scrolls. Each write changed the version token. So the next poll got a 200 with the full report, not a 304. That new token also madeconversationDetailQueryOptionsrefresh agents, memories, watches and Review items.A prod browser capture of one conversation open showed it:
pending-messages→ detail (200, 77.8 kB, 1.41s) →read-position→pending-messages→ detail (200 again, 77.9 kB, 1.44s) →memoriesandspendagain.Change:
useMarkConversationReadalready puts the stored position into the cached detail, so the reader's own client stays correct.unreadCursorfrom the last full build.useMarkConversationReadnow dropsunreadCursorandunreadPagewhen the new position reaches the latest page. It uses the same rule as the server (lastReadSeq + 1 < firstSeq). Without this, a second visit in the same session would open at rows the reader already read. The current visit is not affected, becauseuseReadPositionAtOpenkeeps the values from when the conversation opened.keepLatestReadPositionkeeps the cached cursor for the newer position, because no later poll would bring it back.Trade-off: another tab or device does not see the new read position until the detail changes or the page reloads. A new page load has no cache, so it always gets the current position. If the reader is still behind the latest page after a write, the cached cursor stays at the older position until the next full build. A reopen in that tab then loads from slightly earlier rows.
Not checked: the dashboard Playwright read-position spec, because Chrome is not installed in my sandbox. CI runs it. Mock reporting uses a content-hash ETag, so the e2e path still rebuilds after a read either way.
tests/dashboard-output.test.tsalso fails onmainin my sandbox.Overlaps: #2716 changes the first detail read (mailbox in the same request) in
queries.tsand the detail route, but not the ETag. #2657 (unread catch-up) touches read-position e2e and the unread flow. Whichever merges second may need a small rebase.--
View Session [Sentry]