Skip to content

GUI tabs show no loading state on switch; providers tab issues 38 requests per switch #753

Closed

Description

@lidge-jun

Summary

Switching sidebar tabs in the dashboard shows an empty surface before data appears, and several tabs give no loading indication at all while that happens. On the Codex Auth page the account list is blank for the first ~80ms of every revisit, with no skeleton or spinner, which reads as "the accounts did not load". Data does appear after an explicit action such as the quota-refresh button, which makes the explicit action look like the only working path.

Separately, one #providers tab switch issues 38 /api/* requests, including 8 duplicate /api/provider-quotas calls.

This issue asks for one shared loading contract across every tab, plus the request-count reduction.

Environment

  • opencodex v2.7.43 (npm global install), macOS, proxy on 127.0.0.1:10100
  • Served assets: index-cmds12BG.js / index-Czw-jpTU.css

What actually happens

Requests are issued correctly on every tab switch. Measured with CDP Network against the running dashboard:

Switch Requests Status / latency
to Codex Auth /api/codex-auth/active, /api/codex-auth/accounts, /api/codex-auth/active, /api/config 200, 2-6ms
to Models 7 requests 200, 3-127ms
to Providers 38 requests, incl. /api/provider-quotas x8 200

So this is not a missing-request bug. It is a missing-loading-state bug plus a request-count problem.

DOM sampling during tab switches (rows = account rows, skel = skeleton nodes):

to codex-auth (1st):  19ms rows=0 ... 82ms rows=7   <- first account row at 82ms
to codex-auth (2nd):  18ms rows=0 ... 82ms rows=7
cold reload:          113ms rows=0 skel=0 ... 480ms rows=7

skel=0 throughout: no skeleton is rendered in the shipped build during that window.

Forced refresh behaves differently, and that is why it looks like the only working path:

/api/codex-auth/accounts?refresh=1   200   908ms

During those 908ms the existing rows stay visible, because the forced path runs on an already-populated surface and skips the loading transition entirely.

Root cause

Two independent things.

1. No cache seed across route remounts. App.tsx renders one page at a time, so leaving a tab unmounts its controller. Returning starts from an empty account array, and the account-pool loading UI only renders when accountsCount === 0 — which is exactly that moment. There is no skeleton in the shipped build, so the user sees a bare surface.

2. No shared loading contract. There is no exported Spinner or Skeleton primitive. Each of the 15 surfaces implements its own first-load behavior: shimmer skeleton, EmptyState text, plain muted text, or nothing. Two surfaces cannot distinguish loading from failed at all, so a failed fetch can look like an indefinite load. Ten surfaces announce nothing to screen readers during first load.

The 38-request #providers burst comes from a derived cache key: provider account responses each update quotaRefreshKey in Providers.tsx, and the workspace shell's quota effect depends on that key, so it re-runs once per provider response.

Server side is not the cause

Local loopback timings stay under 1.1ms for /healthz, /api/config, /api/codex-auth/accounts, and the document route. /api/codex-auth/active and /api/config are in-memory projections. There is no rate limiter, ETag, or admission gate that could return stale or empty data on rapid switching, and no canceled requests were observed under rapid switching (6 switches at 180ms intervals, 0 loadingFailed events, 0 console errors).

One genuine server-side latency finding is worth tracking: /api/codex-auth/accounts fans out to one upstream usage request per credentialed account when its 5-minute cache is cold, with concurrency 4 and an 8s per-request timeout, so the cold bound is roughly 8s * ceil(accounts / 4). There is no request-level coalescing.

Requested changes

  1. A shared loading contract used by all 15 surfaces: immediate fetch on mount, a layout-shaped loading indicator, and a real distinction between loading, empty, and failed.
  2. Consistent skeleton/spinner primitives instead of four different per-page patterns, with role="status" / aria-busy coverage.
  3. Cache seeding plus background revalidation so a tab revisit does not blank out, without letting a stale-but-wrong list persist silently.
  4. Request de-duplication on the providers surface (stabilize the derived quota key, share in-flight reads) while preserving genuine refresh triggers: account switch, account mutation, manual refresh, cache invalidation, login/logout, config save.
  5. Coalescing and stale-while-revalidate for the /accounts upstream fan-out.

Affected surfaces

All sidebar tabs: Dashboard, Codex Auth, Providers, Models, Subagents, Logs (plus the Debug subview), Usage, Storage, API, Claude (Code and Desktop), Grok, Startup, Combos.

Notes

Some of this is already partially addressed on dev but not released. The dev work also introduced a related defect worth avoiding in the final design: a successful empty account list is recorded as hydrated, so a later failed load cannot surface an error state.

Activity

  1. github-actions commented on Jul 30, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: unknown.

  2. lidge-jun commented on Jul 30, 2026

    @lidge-jun
    OwnerAuthor

    Patch plan

    The work is split into six phases. WP1 (root cause + this issue) is done; the rest are planned to diff level.

    Phase Scope Depends on
    WP2 Shared loading contract: a thin adapter over the existing useKeyedClientResource, plus skeleton and status primitives with correct role="status" / aria-busy semantics WP1
    WP3 Migrate all 15 surfaces onto that contract, one commit per surface WP2
    WP4 Revalidation and de-duplication: stabilize the derived quota key, share in-flight reads, merge the six &quota=1 re-reads WP3
    WP5 Server-side /accounts coalescing and stale-while-revalidate WP1 (parallel with WP2-WP4)
    WP6 Settings-card and switch-alignment unification WP1 (parallel)
    WP7 Move per-model controls to CLI/API, keeping unified settings in the GUI Own CLI/API acceptance manifest

    Contract shape (WP2)

    Six states, so that a failure after a prior success can never look like a normal ready state:
    cold, loading-with-stale-data, ready-empty, ready-populated, failed-cold, failed-with-stale.
    "Has ever succeeded" and "last attempt succeeded" are tracked separately — this is what prevents a
    successful empty response from suppressing a later error.

    Mount fetch moves off the setTimeout(..., 0) pattern used by 13 of the 15 surfaces, because a
    mount-then-unmount currently drops the request with no retry.

    Request targets (WP4)

    Derived arithmetic rather than a round number: cold #providers goes from 38 measured to 25 or fewer
    (38 - 7 for provider-quotas in-flight sharing, - 6 for merging the &quota=1 account re-reads).
    Warm revisit target is 8 or fewer. Four scenarios are explicitly excluded from reduction and get
    preservation tests: explicit refresh, OAuth account switch, login/logout, config save. Two more
    preservation triggers cover concurrent mounts and in-flight followers.

    Any target below 25 requires naming a specific request from the measured list and proving its
    display or refresh behavior survives.

    Alignment fix (WP6)

    The threshold control and its switch are bottom-aligned today (align-items: flex-end), leaving a
    32px numeric control and a 20px toggle with centers 6px apart. The fix centers the switch inside a
    32px slot so both centers land on the same line, and converges the hand-rolled toggle onto the
    shared Switch component. Verified by measuring both elements' box centers, not by eyeballing.

    Verification

    Each phase re-runs the same CDP Network plus DOM-sampling procedure used to produce the measurements
    in this issue, so before and after are directly comparable. Screenshots are captured per surface for
    WP3 and for the card and switch alignment in WP6.

    Detailed diff-level documents live in the maintainer's private planning repo; this comment is the
    public summary.

  3. 10 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingguiDashboard, tray, settings UIinstallInstallation or packaging

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions