Skip to content

[fix] Bound the sidebar agent-list query and page the session list [AGE-4218] - #6525

Open
ashrafchowdury wants to merge 11 commits into
feat/sidebar-drag-reorder-6467from
fe-feat/sidebar-unbounded-sessions
Open

[fix] Bound the sidebar agent-list query and page the session list [AGE-4218]#6525
ashrafchowdury wants to merge 11 commits into
feat/sidebar-drag-reorder-6467from
fe-feat/sidebar-unbounded-sessions

Conversation

@ashrafchowdury

Copy link
Copy Markdown
Contributor

Context

Two problems in the same rail.

Opening the sidebar loaded the whole revision history of every workflow in the project. is_agent lives on a workflow revision, never on the artifact, so classifying the Agents group meant asking /workflows/revisions/query for every revision of every app and keeping only the newest of each. On a project with 64 workflows and 4,209 revisions that is 65 MB and 15 seconds, measured on localhost with no network latency, to produce 64 booleans. It also got worse every day a project was used, because the cost tracks commit history rather than agent count.

Separately, the Sessions group stopped at 50 rows with no way past them. maxItems was set to the same number as the fetch limit, so the refs.length > maxItems test that guards the "Show all" row could never be true and that row never rendered. You scrolled to row 50 and the list simply ended.

Changes

Bounding the classification (#6390). RevisionQuery gains an optional latest_per_artifact. When set, the DAO returns one revision per artifact with DISTINCT ON (artifact_id) ... ORDER BY artifact_id, id DESC, skipping the empty placeholder a new variant is seeded with.

Ordering is by id, not version, and that matters. version is a String column, so ORDER BY version DESC sorts "9" above "10". It is also assigned per variant (a count of earlier ids within the variant), so "max version per artifact" is not well defined. id is UUID7, and within a variant its order is exactly version order.

On the same seeded project:

before:  4,211 revisions   65.6 MB   15.10s
after:       64 revisions    945 KB    0.23s

The frontend side collapsed six independent fetches of that data into one project-wide agentId -> boolean map. The sidebar, the agents page, the prompts page and app-management now share a single cache entry and join against it. The map's key also dropped its per-workflow updated_at list, so committing to one agent no longer re-classifies every app in the project.

Paging the sessions. maxItems for Sessions is now unbounded, so the group renders every row it has loaded, and a second query fetches pages older than the polling head as you reach the end of the scroll box.

That tail query is deliberately not polled. A refetch interval over an accumulating list costs one request per loaded page per tick. Liveness stays on the head, and every changed row re-enters it, because activity ordering means a session that just did something is the newest one. A stale copy in the tail always loses the dedupe to the fresh copy above it. The tail boundary is frozen when the first page is requested rather than read live off the head, or every head poll would re-key the tail and refetch all of it.

Two supporting fixes the paging exposed. Rows used to be rebuilt on every 15s poll: toRow minted a fresh object per ref, the children memo depended on the whole entity-sources object so any one source rebuilt all four, and no row component was memoized. Rows are now cached per entity and compared by field, and LeafRow, RowLabel and GroupLabelRow are memoized. A poll that changes one session now re-renders one row.

The hand-arranged drag order also had two bugs that only bite once more rows than the old cap can be loaded. The zone cap trimmed with a blind slice(0, 200), so a drop in a large bucket wrote every id, discarded whatever fell past the cap, and left those rows unknown to the arrangement, where they led their bucket. One drop reordered the list. Eviction now drops the ids that are not on screen first. Separately, unseen sessions led on the rationale that you had just started them, which is only true at the head; rows arriving from a later page are older than everything arranged, so unseen rows now place by activity.

Tests

  • New backend acceptance tests cover latest_per_artifact against the real revision semantics, including the lexicographic guard (a workflow with 10+ revisions returns "10", not "9") and the multi-variant placeholder case.
  • New frontend tests pin that the classification map is fetched once across consumers, that an unbounded entity renders every ref and emits no "Show all", and both manual-order fixes.
  • @agenta/navigation 95 tests, @agenta/entities 1,501 tests. navigation, navigation-ui, entities and mobile typecheck and lint clean.
  • Added api/oss/tests/manual/workflows/seed_stress_catalog.py to reproduce the numbers above. It seeds depth rather than breadth, because the cost scales on revisions per workflow, not on agent count.

Notes for reviewers

  • This is stacked on [feat] Drag-and-drop reorder of agents and sessions in the sidebar #6515 and should merge after it. The sidebar code imports sidebarReorderActiveAtom and entity.dragZone from that branch.
  • fetchWorkflowAgentFlags uses raw axios rather than the Fern client. latest_per_artifact ships in this change, so the generated client does not carry it until the OpenAPI spec is regenerated. Flagged in the docblock to migrate with the rest of that file.
  • The rail still defaults to activity: 7d and type: chat. Paging removes the count ceiling, not those filters.
  • The Agents group keeps its 5-row cap and its working "Show all". Giving it the same treatment is follow-up work.

What to QA

Needs a project with more sessions than one page. seed_stress_catalog.py will make one.

  • Open the sidebar. The Agents group expands and lists agents without the multi-second stall.
  • Open the Sessions filter menu. The Agent facet shows "Loading agents…" briefly, then the agents. It should never read as an empty list.
  • Scroll to the bottom of the Sessions group. More rows load. Keep going and they keep loading.
  • Scroll back to the top. It should reach the top and stay there, not fight you or snap back.
  • Switch grouping to Date, then load several pages. Headings stay in descending date order and no day appears twice.
  • Drag a session inside a large group after loading a few pages. The drop lands where the line showed, the order survives a reload, and no other row moves.
  • Regression: check the same list on /m. Mobile shares this code and pages the same way.

The rail rebuilt every row object and re-rendered every row on each 15s/60s poll: toRow
minted a fresh SidebarConfig per ref, the children memo depended on the whole entity
sources object so any one source rebuilt all four, and no row component was memoized.

Cache each row on the ref it was built from, memoize per entity, and memo the row
components. A poll that changes one session's flags now re-renders one row.
maxItems for Sessions was the same number as the fetch limit, so the cap could never
bite and the Show all row it guards could never render — you scrolled to row 50 and the
list simply ended, with no way to reach the rest.

Lift the cap instead of raising it: Sessions renders every ref it holds, and the fetched
window becomes purely a page size. Show all goes with it; paging replaces it.

maxItems keeps its meaning for every other entity, and still applies before grouping.
Scrolling to the bottom of the Sessions group ended the list at 50 rows with no way to
reach the rest.

Add a second query for sessions older than the polling head, widened one page at a time
as the scroll box reaches its end. It is deliberately not polled: an interval over an
accumulating list costs a request per page per tick. Liveness stays on the head, which
every changed row re-enters — activity ordering means a session that just did something
is newest — so a stale copy below always loses the dedupe to the fresh one above.

Widening the window rather than appending a cursor page also re-reads the tail, so a row
archived elsewhere leaves it.

The page count resets when the filters change, and paging is suppressed mid-drag: the
reorder engine caches every row's rect at dragstart.
Two latent bugs that only bite once more rows than the old cap can be loaded.

The zone cap trimmed with a blind slice, so a drop in a large bucket wrote every id,
discarded whatever fell past the cap, and left those rows unknown to the arrangement —
where they led their bucket. One drop reordered the list. Evict the ids that are NOT on
screen instead, newest first, so a visible row never loses its place.

Unseen sessions led on the rationale that you had just started them, which is only true
at the head of the list. Rows arriving from a later page are older than everything
arranged, so place unseen rows by activity: newer leads, older trails, and a row with no
activity cannot claim to be new.
@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

AGE-4218

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 4, 2026 10:46am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • release/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Team

Run ID: 9e59aa68-e5fb-4092-aa38-758840e29877

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-6525.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6525-7036b95
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-09-04T10:51:10.672Z

Seeds a project with a deep workflow catalog and a pile of sessions, to reproduce and
measure the #6390 sidebar stall. The cost scales on revisions per workflow, not on agent
count, so the script seeds depth rather than breadth.

Workflows, variants and revisions go through the HTTP API so is_agent is inferred by the
real commit path; sessions are inserted directly because the streams route drives the
runner and needs provider keys.
… menu opens

The Sessions group is alwaysOpen, so gating the Agent facet's catalog on the
group gated nothing: SessionFilterMenu renders as the group's action on every
page, so every sidebar mount pulled the whole agent catalog — one revision
fetch per workflow — to fill a menu nobody had opened.

Gate it on the menu itself instead, and give the facet a loading row so the
deferred catalog never reads as "this project has no agents".
A caller that wants one fact per workflow — is this an agent? — had to download
every revision of every workflow to compute it, because the artifact list does
not carry is_agent and the revision query has no way to say "just the head".
Without windowing the DAO applies no LIMIT at all, so 20 apps x 30 revisions
came back in full.

latest_per_artifact goes on the SHARED RevisionQuery, not on the workflow
subclass: the workflow service downcasts to RevisionQuery before calling the
DAO, and Pydantic's extra=ignore would drop a subclass-only field there without
an error. Explicit revision_refs still win over it, archived revisions are
excluded as before, and the placeholder a new variant is seeded with is skipped
so a multi-variant workflow resolves to its real head.
…quest

Six call sites each downloaded every revision of every workflow to compute one
boolean per workflow, under keys that shared nothing. The sidebar's copy also
ran serially behind the artifact list and re-fetched every app's classification
whenever any one app's updated_at changed.

Replace them with one project-wide Map<workflowId, isAgent> behind a single
query key: fetchWorkflowAgentFlags asks the server for latest_per_artifact, so
one revision per workflow comes back instead of the whole history, and it parses
that payload once rather than twice. Each list keeps its own query — they show
different subsets — and joins against the map.

Also replaces api.ts's timestamp-first "latest revision" picker with the
version-first rule store.ts already uses, and fixes the last timestamp-first
sort in the latest-revision-id fallback.
Two consumers in a project must share one fetch, and a workflow absent from the
map must classify as a prompt — the rule the old revision-map path got for free
and the new map has to state.
Sharpens the acceptance tests after checking the behaviour against the running
stack: an unchanged commit is a no-op (so revisions need distinct data), a
variant with no commits has no revision rows at all, and a FIRST commit lands on
version "0" carrying data — which is why the placeholder guard keys off data and
flags rather than the version alone.
- The older-pages query keyed the project id at index 2, so the shared placeholder
  helper - which reads index 1 - never matched and the tail blanked on every page.
- Mobile was uncapped but had no way to page: it never wired onReachEnd, and with the
  cap lifted its Show all row could not render either, so it sat at one page with no
  affordance for the rest.
- The end-of-scroll guard keyed on scrollHeight, so a page whose rows all landed in
  collapsed groups added no height and latched paging off for good. Throttle instead.
- The tail boundary was read live off the polling head, so every poll that shifted the
  head refetched every loaded page. Freeze it when the first page is asked for.
- Drop the four classification helpers the agent-flags map replaced, the page-size
  export left behind by a rename, and the tests pinning them.
- Compare ref fields instead of serializing each row, and record why the new
  classification call is still on axios.
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