From 95888b9eaec67fce6835d40b5646f9aa273adc2c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 01:10:14 +0200 Subject: [PATCH 01/33] feat(frontend): IndexedDB per-query persistence for playground workflow entities and catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @agenta/shared/api/persist: idb-keyval-backed per-query persistence via TanStack's experimental_createQueryPersister, with two policy classes: - immutablePersister (revision bodies): restore from disk, never revalidate, schema-version buster; revision detail gets staleTime Infinity + 1h gcTime, and commit/list primes persist through primeWorkflowRevisionDetailCache (full bodies only) - catalogPersister (inspect, harness catalog, ag-type schemas, tool/trigger catalogs incl. infinite queries, evaluator/app/application templates, service schemas): paint-from-disk + one background revalidate when stale Replaces the localStorage disk-SWR layers (persistedInspect LRU/quota machinery, persistedCatalog) — persistedAgentType and last-selection stay in localStorage as sync-critical first-frame signals. Logout clears the IDB store; an idle GC sweep drops expired/stale-version entries. Safety and diagnostics: - nullish-data guard: null queryFn results are never persisted and pre-existing nullish entries are evicted on read (an immutable-restored null would suppress refetch forever) - agenta:persist:debug console logging (HIT/MISS/WRITE/SKIP/EVICT/GC) and agenta:persist:disable kill switch at the storage boundary - query-core unified at 5.100.9 via pnpm-workspace override (persist-client- core exact-pins its own query-core, which split the workspace into two nominally-incompatible type universes) Design doc with inventory, mutability-class policy, and build notes in docs/design/playground-query-persistence/plan.md. 290 unit tests green in @agenta/shared (15 new persistence behavioral tests over fake-indexeddb). --- .../playground-query-persistence/plan.md | 281 ++++++++++++++ web/oss/src/components/pages/_app/index.tsx | 8 + web/oss/src/hooks/useSession.ts | 2 + web/oss/src/state/app/atoms/templates.ts | 3 + .../gatewayTool/hooks/useToolActionDetail.ts | 3 + .../hooks/useToolCatalogActions.ts | 8 + .../hooks/useToolCatalogCategories.ts | 3 + .../hooks/useToolCatalogIntegrations.ts | 8 + .../hooks/useToolIntegrationDetail.ts | 3 + .../hooks/useTriggerCatalogEvents.ts | 8 + .../hooks/useTriggerCatalogIntegrations.ts | 8 + .../gatewayTrigger/hooks/useTriggerEvent.ts | 3 + .../src/shared/openapi/serviceSchemaAtoms.ts | 3 + .../src/workflow/state/appUtils.ts | 5 +- .../workflow/state/evaluatorTemplateAtoms.ts | 11 +- .../src/workflow/state/inspectMeta.ts | 30 +- .../src/workflow/state/persistedCatalog.ts | 60 --- .../src/workflow/state/persistedInspect.ts | 119 ------ .../src/workflow/state/store.ts | 67 ++-- web/packages/agenta-shared/package.json | 4 + .../agenta-shared/src/api/persist/debug.ts | 84 ++++ .../agenta-shared/src/api/persist/gc.ts | 32 ++ .../src/api/persist/idbStorage.ts | 91 +++++ .../agenta-shared/src/api/persist/index.ts | 4 + .../src/api/persist/persisters.ts | 40 ++ .../agenta-shared/src/api/persist/version.ts | 6 + .../agenta-shared/tests/unit/persist.test.ts | 363 ++++++++++++++++++ .../tests/unit/persistNullGuard.test.ts | 92 +++++ web/pnpm-lock.yaml | 203 ++++------ web/pnpm-workspace.yaml | 2 + 30 files changed, 1191 insertions(+), 363 deletions(-) create mode 100644 docs/design/playground-query-persistence/plan.md delete mode 100644 web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts delete mode 100644 web/packages/agenta-entities/src/workflow/state/persistedInspect.ts create mode 100644 web/packages/agenta-shared/src/api/persist/debug.ts create mode 100644 web/packages/agenta-shared/src/api/persist/gc.ts create mode 100644 web/packages/agenta-shared/src/api/persist/idbStorage.ts create mode 100644 web/packages/agenta-shared/src/api/persist/index.ts create mode 100644 web/packages/agenta-shared/src/api/persist/persisters.ts create mode 100644 web/packages/agenta-shared/src/api/persist/version.ts create mode 100644 web/packages/agenta-shared/tests/unit/persist.test.ts create mode 100644 web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md new file mode 100644 index 0000000000..b5935da510 --- /dev/null +++ b/docs/design/playground-query-persistence/plan.md @@ -0,0 +1,281 @@ +# Playground query persistence — IndexedDB-backed SWR for workflow entities & playground catalogs + +Status: BUILT (increments ①–③, 2026-07-18) — pending live browser verification; increment ④ (measure + Class C exceptions) not started. See "Build notes" at the bottom. +Scope: agent-playground experience — workflow entities (artifact/variant/revision) + +playground-adjacent fetches (templates, catalogs, schemas, models, tools/trigger +catalogs). Explicitly NOT an app-wide cache subsystem. +Branch: `fe-refactor/data-optimizations` + +## Problem + +Two compounding issues on the agent-playground load path: + +1. **Cold/warm reload re-fetches everything.** The critical TTI chain is + `session/project → revision body → inspect → config sections paint`. Inspect and the + static catalogs are already disk-seeded (localStorage), but the **revision body — the + primary TTI blocker and the immutable-by-key one — is not persisted at all**. It also + has the TanStack default 5-minute `gcTime`, so it evaporates mid-session: switching + away from a revision for 5 idle minutes refetches its full body. +2. **The backend is capacity-limited.** Cold reload fires ~15–22 concurrent requests; + a saturated backend (esp. local dev) queues 6–9s per request. Any persistence design + that paints-from-disk but still revalidates everything makes the UI *look* fast while + sending the same request burst. Revalidation policy is therefore as important as + storage. + +Additionally, the three existing hand-rolled localStorage layers +(`persistedInspect.ts`, `persistedCatalog.ts`, `persistedAgentType.ts` in +`web/packages/agenta-entities/src/workflow/state/`) are rationing a ~5MB origin quota +shared with SuperTokens auth state — `persistedInspect` carries LRU(15) eviction, a +1.2MB char budget, and quota-purge machinery that exists *only* because localStorage is +small and synchronous. + +## Design center + +**One decision, made per query: mutability class × storage tier × revalidation policy.** + +Mechanism: TanStack Query's `experimental_createQueryPersister` (per-query persistence), +NOT `persistQueryClient` (whole-cache). Reasons: + +- Whole-cache dehydration sweeps in every mutable pointer query by default; the + `shouldDehydrateQuery` denylist would drift as queries are added. +- `PersistQueryClientProvider` gates render on an async restore; we're on jotai + (`useHydrateAtoms` in `web/oss/src/state/Providers.tsx:21`) and gating the app on an + IndexedDB read is the opposite of the TTI goal. +- Per-query lets us opt in exactly the revision-detail query first and measure. + +Storage: IndexedDB via `idb-keyval`, one shared module in +`web/packages/agenta-shared/src/api/persist/` exporting persister instances per policy +class. The QueryClient itself (`web/packages/agenta-shared/src/api/queryClient.ts:7`) +stays bare; queries opt in via `persister` in their query options. + +## Mutability classes and policy + +### Class A — immutable-by-key: persist, NEVER revalidate + +The only class where persistence *reduces* backend load rather than deferring it. + +| Query | Where | Today | Win | +|---|---|---|---| +| `["workflows","revision", revId, projectId]` — full revision body | `agenta-entities/src/workflow/state/store.ts:1118` (`workflowQueryAtomFamily`) | 30s staleTime, default 5m gcTime, no persistence | **The jackpot.** Primary TTI blocker on warm reload; also GC-refetch mid-session | +| `["revision", projectId, revId]` — testset revision | `agenta-entities/src/testset/state/store.ts:245` | staleTime/gcTime Infinity (memory only) | Cross-reload; low priority | +| `["revision-with-testcases", …]` | `testset/state/revisionMolecule.ts:166` | opt-in enabled | Same, when playground test data loaded | +| `["trace-entity"]` / `["trace-summary"]` | `trace/state/store.ts:693/761` | staleTime Infinity, 5m gcTime | OUT of first scope — noted for later | + +Policy: `staleTime: Infinity`, `maxAge: Infinity`, restore from IDB, zero revalidation. + +Caveat: immutable server-side ≠ immutable client-shape. The Zod `workflowSchema` +(`workflow/core/schema.ts:271-336`) evolves; every persisted entry is keyed with a +**schema-version buster** (the `VERSION = "1"` idiom from `persistedInspect.ts`). Bump +on any schema change → old entries are dead weight, GC'd by maxAge sweep, refetched. + +Also raise the in-memory `gcTime` for the revision-detail query (e.g. 1h): the persister +covers cross-GC restore, but restore is async — a longer gcTime avoids even the brief +pending flash when switching back to a recently viewed revision. + +### Class B — catalogs & schemas (change on backend deploy): persist, paint-from-disk, ONE low-priority revalidate + +Arda's established rule from the disk-SWR round applies to this whole class: +**`initialDataUpdatedAt: 0` (paint from disk + one background revalidate), NOT +`staleTime: Infinity`** — schemas still evolve during active development. + +| Query | Where | Today | Treatment | +|---|---|---|---| +| Inspect body | `workflow/state/store.ts:1200` + `persistedInspect.ts` | localStorage LRU(15), 1.2MB budget | **Migrate to IDB**; raise entry count; delete LRU/quota code | +| Harness catalog (embeds full model catalog + pricing — the "models" source) | `workflow/state/inspectMeta.ts:98` + `persistedCatalog` | localStorage seed | Migrate to IDB (large blob) | +| ag-type schemas | `workflow/state/store.ts:1403` + `persistedCatalog` | localStorage seed | Same | +| Tool catalog: categories / integrations / actions / details | `gatewayTool/hooks/useToolCatalog*.ts` (incl. two `atomWithInfiniteQuery`) | 5–30m staleTime, memory only | Persist → drawer opens instantly across reloads; revalidate on drawer-open only | +| Trigger catalog: integrations / events | `gatewayTrigger/hooks/useTriggerCatalog*.ts` | 5m, memory only | Same | +| Evaluator templates | `workflow/state/evaluatorTemplateAtoms.ts:23` | 5m, memory only; fires near cold load via config panel | Persist + idle-defer revalidation | +| App / application templates | `workflow/state/appUtils.ts:38`, `oss/src/state/app/atoms/templates.ts:14` | 5m | Persist (home/onboarding benefit) | +| Service schema (legacy custom apps) | `shared/openapi/serviceSchemaAtoms.ts:29` | 30m/1h, already well-tuned | Persist, lowest priority | + +Revalidation discipline (the anti-choke core): + +1. **Seeded ⇒ `priority:"low"`.** Plumbing exists: `lowPriorityWhenCached` in + `agenta-shared/src/api/axios.ts:44` + low-priority client variants in + `agenta-sdk/src/resources.ts`. Already applied to inspect/catalog revalidations. +2. **Revalidate once per session, not per mount.** After the single background refetch, + normal `staleTime` suppresses repeats. +3. **Idle-defer revalidations** of anything not visible — fold into the existing + `idleReadyAtom` pattern (`oss/src/state/boot/idleReady.ts`, commit add38e89) so + warm-reload revalidations don't join the cold-load network burst. + +### Class C — user-mutable pointers & lists: mostly DON'T persist + +`["workflows","latestRevision"]` (store.ts:877 — the stale-"latest" hazard: renders a +stale head after a teammate commits), `revisionsByWorkflow`/`revisions` lists (already +thin `{id, version, created_at}` refs), workflow `detail`/`artifact`, vault secrets +(`secret/state/atoms.ts:96`), tool/trigger connections, environments +(`environment/state/store.ts:202`), testset lists, session mounts. + +Payloads are thin — persistence buys milliseconds and risks staleness bugs. Keep +these on the existing low-priority/deferred treatment. + +Two paint-fast **exceptions to evaluate later** (persist + ALWAYS revalidate): + +- Vault secrets — kills the model-key-badge flicker in + `AgentChatPanel → useAgentModelKeyStatus.ts:59` / ConnectModelBanner. +- Environments list — deploy-drawer paint. + +Deferred to increment ④; measure first. + +### Class D — frequently changing: NEVER persist + +Session records (`session/state/records.ts:21`), trigger deliveries +(`useTriggerDeliveries.ts:22`), mount file listings/contents (`session/state/mounts.ts`). +The session *tab list* is already `atomWithStorage` +(`AgentChatSlice/state/sessions.ts:76`) — untouched. + +## Storage-tier rule + +Not everything migrates to IDB. The distinction that must survive: + +- **Sync-critical tiny signals stay in localStorage.** `persistedAgentType` (header + flavor, `PlaygroundLoadingShell.tsx:25`; splitter geometry latch, + `MainLayout.tsx:212-242`) and the persisted last-selection + (`state/url/playground.ts:148-179`) gate **first-frame render decisions**. IndexedDB + is async and cannot serve a synchronous first-frame read. DO NOT migrate these. +- **Heavy bodies move to IndexedDB.** Inspect, harness catalog, revision bodies, tool + catalogs. Bonus: `persistedInspect` today does synchronous `JSON.parse` of ~100KB+ + payloads during render; IDB restore is async, off the critical frame. +- Net: localStorage shrinks to a few KB of signals (quota pressure vs SuperTokens gone); + the LRU/eviction/quota-purge code in `persistedInspect.ts` is **deleted**, not + replicated. + +## Module sketch + +``` +web/packages/agenta-shared/src/api/persist/ + idbStorage.ts // idb-keyval store "agenta-query-cache", AsyncStorage adapter + persisters.ts // immutablePersister (Class A: maxAge Infinity + version buster) + // catalogPersister (Class B: bounded maxAge, e.g. 14d) + version.ts // PERSIST_SCHEMA_VERSION — bump invalidates Class A entries + gc.ts // idle-time sweep: drop entries past maxAge / wrong version +``` + +Queries opt in via `persister: xPersister.persisterFn` in their query options +(supported by `atomWithQuery`). Keys in IDB are the serialized queryKey + version +prefix; cross-tab sharing comes free (IDB is origin-scoped; persister writes are +throttled). + +## Expected benefits + +1. **Warm-reload TTI**: the entire render chain `revision → inspect → sections` serves + from disk; only `profile/projects` + pointer revalidations touch the network before + paint. Today the config panel and chat host block on the revision fetch + (`AgentChatSkeleton` gate, `MainLayout.tsx:519-521`). +2. **Subsumes the parked TTI plan** (`project_agent_playground_tti_plan`): the persisted + revision body contains `uri`/`url`/`flags`, so phases 2–3 (persistedRevisionMeta + + early inspect prefetch) collapse into "restore revision from IDB, derive the inspect + key from it". Phase 1 (snapshot hint) remains independently useful. +3. **Warm-reload request count drops** (not just reorders — distinct from the + priority-demotion pass): Class A revalidations eliminated; Class B collapsed to one + low-priority idle refetch each. On the capacity-limited backend this is the lever + that matters. +4. **Mid-session refetch elimination**: revision bodies survive the 5-minute GC; + revision-switching stops refetching bodies. +5. **Drawer UX**: tool/trigger catalogs + evaluator templates open instantly on every + visit after the first. + +## Risks / open questions (settle empirically in increment ①) + +1. **The `initialData` race.** `store.ts:877` and `store.ts:1118` use + `initialData: detailCached` + `enabled: !detailCached`, reading the query cache + synchronously at atom evaluation. The per-query persister restores *asynchronously* + on observer mount. Does restore land before the enabled-gate/initialData evaluate, + or race them? If it races, keep a small synchronous latch (or reorder: persister + restore first, `initialData` as fallback). This is THE question increment ① answers. +2. **Commit-time cache priming.** `primeWorkflowRevisionDetailCache` + (`store.ts:124`, called from `workflow/state/commit.ts:310/485`) writes via + `setQueryData`. Confirm the persister observes cache writes that don't originate + from its own queryFn — a just-committed revision must be persisted too, or the first + reload after a commit misses the newest revision. +3. **Infinite queries.** Persister support for `atomWithInfiniteQuery` pages + (tool/trigger catalogs) is the least-proven corner of the experimental API. Verify; + fall back to persisting first-page-only if pages misbehave. +4. **Multi-workspace / auth.** Keys include `projectId` already (good). Decide whether + logout should clear the IDB store (probably yes — hook the same place SuperTokens + clears its state). +5. **Eviction.** No LRU needed at IDB scale, but do an idle-time sweep (maxAge + + version mismatch) so the store doesn't grow unboundedly across months. + +## Build order + +Each increment is a standalone win; stop-and-measure between them. + +1. **① Infra + revision body.** `persist/` module; wire + `["workflows","revision", revId, projectId]` through `immutablePersister` with + schema-version buster; raise its `gcTime`; settle risks 1–2 empirically on the + highest-value target. Verify: warm reload paints config panel without a revision + network request; commit → reload shows the new revision. +2. **② Migrate inspect + catalogs off localStorage.** `persistedInspect` + + `persistedCatalog` → `catalogPersister` (IDB), preserving the + `initialDataUpdatedAt: 0` + low-priority-revalidate semantics. Delete the LRU/quota + machinery. `persistedAgentType` and last-selection are NOT migrated (sync-critical). +3. **③ Drawer catalogs.** Tool/trigger catalog queries + evaluator/app templates via + `catalogPersister`; revalidate on drawer-open; idle-defer the rest. +4. **④ Measure, then decide Class C exceptions** (vault secrets, environments list) — + only if the badge/drawer flicker still registers after ①–③. + +## Build notes (2026-07-18, increments ①–③) + +Answers to the open risks, settled empirically during the build: + +1. **initialData race — none exists.** The persister runs only inside a fetch the + `enabled`/`initialData` gates already allowed, and restores only when + `query.state.data === undefined`. `detailCached` present ⇒ persister never runs; + absent ⇒ restore happens before any network. (query-persist-client-core source.) +2. **Commit priming — handled at one choke point.** All commit/list primes route + through `primeWorkflowRevisionDetailCache`, which now fire-and-forgets + `immutablePersister.persistQueryByKey` for full bodies only (`workflow.data` + truthy). Note `persistQueryByKey` resolves before the IDB write completes — never + assume durability before navigation/unload. +3. **Infinite queries — wired, sound.** `infiniteQueryBehavior` hands the persister the + whole `{pages, pageParams}`; `fetchNextPage` re-persists post-update state. Restored + stale infinite queries refetch all restored pages (standard TanStack semantics). +4. **Class B revalidation needs an observer.** `fetchQuery` alone restores but never + background-refetches (`isStale()` is false without observers). `atomWithQuery` + mounts observers, so all wired queries get paint-then-revalidate; imperative + prefetch paths get restore-only. (Verified by unit test.) +5. **Version discipline.** `@tanstack/query-persist-client-core` pinned `5.100.9` + + `pnpm-workspace.yaml` override `@tanstack/query-core: 5.100.9` — the workspace must + keep exactly one query-core or `Query`/`QueryClient` types split nominally. +6. **Typing.** query-core's `persister` option uses `NoInfer`: plain sites + need `persisterFn`, infinite sites need an + `as QueryPersister` cast (types-only mismatch). + +Behavior deltas accepted: inspect disk entries are per-revision (queryHash), no longer +per-service — a new revision's first inspect won't paint from a sibling's entry. +Revalidate-on-restore fires only when restored data is stale (vs the old +`initialDataUpdatedAt: 0` always-once). + +Landed outside the original sketch: logout `clearPersistedQueryCache()` in +`oss/src/hooks/useSession.ts`; GC sweep in `_app` `PreloadQueries`. Unit coverage: +`agenta-shared/tests/unit/persist.test.ts` (11 behavioral tests over fake-indexeddb). + +**Diagnostics:** `localStorage.setItem("agenta:persist:debug", "1")` + reload enables +console logging at the storage boundary (`persist/debug.ts`): `HIT` (restore, with +size and age), `MISS`, `WRITE`, `SKIP` (nullish data), `EVICT` (expired/buster/nullish), +`CLEAR`, and a GC summary with swept counts. No-op (single flag check) when disabled. +**Kill switch:** `localStorage.setItem("agenta:persist:disable", "1")` + reload makes +reads miss and writes no-op (entries survive) — for A/B-ing whether a symptom is +persistence-related. + +**Nullish-data guard (found in first live run):** a revision queryFn that resolves +`null` (transient API miss / failed validation) must never persist — an +immutable-restored `null` suppresses refetch forever. The storage adapter skips +nullish writes and treats pre-existing nullish entries as miss + evict on read. + +Still open: live browser verification (warm-reload paint without revision request; +commit → reload shows new revision; drawer catalogs instant on reopen), then +increment ④. + +## Out of scope + +- Whole-cache `persistQueryClient` / render-gating restore. +- Persisting Class C pointers (`latestRevision`, lists) or Class D live data + (session records, deliveries, mount files). +- Trace store persistence (immutable trace-entity/summary noted, separate effort). +- Non-playground surfaces (observability tables, evaluations) — same infra would apply + later, but not this pass. +- The backend 8s/request latency itself (server-side capacity, not FE-fixable). diff --git a/web/oss/src/components/pages/_app/index.tsx b/web/oss/src/components/pages/_app/index.tsx index d24912d071..1b5a515870 100644 --- a/web/oss/src/components/pages/_app/index.tsx +++ b/web/oss/src/components/pages/_app/index.tsx @@ -1,4 +1,7 @@ +import {useEffect} from "react" + import {configureAgentaSdk} from "@agenta/sdk/config" +import {schedulePersistedQueryGc} from "@agenta/shared/api/persist" import {default as AppContextComponent} from "@agenta/ui/app-message" import {QueryClientProvider} from "@tanstack/react-query" import {App as AppComponent} from "antd" @@ -53,6 +56,11 @@ const PreloadQueries = () => { useUser() useProjectData() + // One idle-time sweep of expired/stale-version persisted query entries. + useEffect(() => { + schedulePersistedQueryGc() + }, []) + return null } diff --git a/web/oss/src/hooks/useSession.ts b/web/oss/src/hooks/useSession.ts index e06725fd63..7f838fb6f4 100644 --- a/web/oss/src/hooks/useSession.ts +++ b/web/oss/src/hooks/useSession.ts @@ -1,5 +1,6 @@ import {useEffect} from "react" +import {clearPersistedQueryCache} from "@agenta/shared/api/persist" import {useQueryClient} from "@tanstack/react-query" import {useAtomValue, useSetAtom} from "jotai" import {useRouter} from "next/router" @@ -77,6 +78,7 @@ export const useSession: () => { // Clear React Query cache to prevent unauthorized requests queryClient.clear() + void clearPersistedQueryCache() // Reset Jotai atoms resetProfileData() diff --git a/web/oss/src/state/app/atoms/templates.ts b/web/oss/src/state/app/atoms/templates.ts index a2580413eb..df719dca6f 100644 --- a/web/oss/src/state/app/atoms/templates.ts +++ b/web/oss/src/state/app/atoms/templates.ts @@ -2,6 +2,8 @@ import { fetchWorkflowCatalogTemplates, type WorkflowCatalogTemplate, } from "@agenta/entities/workflow" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey} from "@tanstack/react-query" import {atom} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -31,6 +33,7 @@ export const templatesQueryAtom = atomWithQuery((get) } return failureCount < 3 }, + persister: catalogPersister.persisterFn, } }) diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts index f1c930d7f4..5f3ec131c2 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts @@ -1,3 +1,5 @@ +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey} from "@tanstack/react-query" import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -32,6 +34,7 @@ export const toolActionDetailQueryFamily = atomFamily( refetchOnWindowFocus: false, enabled: !!integrationKey && !!actionKey, retry: (failureCount, error) => !isActionNotFoundError(error) && failureCount < 3, + persister: catalogPersister.persisterFn, })), (a, b) => a.integrationKey === b.integrationKey && a.actionKey === b.actionKey, ) diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts index 4f720d3212..472b9781bb 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogActions.ts @@ -1,5 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {atom, useAtomValue, useSetAtom} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithInfiniteQuery} from "jotai-tanstack-query" @@ -37,6 +39,12 @@ export const toolCatalogActionsInfiniteFamily = atomFamily((integrationKey: stri staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey, + // Persists whole {pages, pageParams}; cast bridges persisterFn's pageParam-less typing. + persister: catalogPersister.persisterFn as QueryPersister< + ToolCatalogActionsResponse, + QueryKey, + unknown + >, } }), ) diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts index 2899711b72..ed041aa62c 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogCategories.ts @@ -1,5 +1,7 @@ import {useMemo} from "react" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey} from "@tanstack/react-query" import {useAtomValue} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -18,6 +20,7 @@ export const toolCatalogCategoriesQueryAtom = atomWithQuery, })) export const useToolCatalogCategories = () => { diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts index 927893138c..14f3b7e868 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolCatalogIntegrations.ts @@ -1,5 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {atom, useAtomValue, useSetAtom} from "jotai" import {atomWithInfiniteQuery} from "jotai-tanstack-query" @@ -55,6 +57,12 @@ export const toolCatalogIntegrationsInfiniteAtom = getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, staleTime: 5 * 60_000, refetchOnWindowFocus: false, + // Persists whole {pages, pageParams}; cast bridges persisterFn's pageParam-less typing. + persister: catalogPersister.persisterFn as QueryPersister< + ToolCatalogIntegrationsResponse, + QueryKey, + unknown + >, } }) diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts index ce51eab118..9a611388cc 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolIntegrationDetail.ts @@ -1,3 +1,5 @@ +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey} from "@tanstack/react-query" import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -14,6 +16,7 @@ export const toolIntegrationDetailQueryFamily = atomFamily((integrationKey: stri staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey, + persister: catalogPersister.persisterFn, })), ) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts index 4c01a992ff..471b505e32 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogEvents.ts @@ -1,5 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {atom, useAtomValue, useSetAtom} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithInfiniteQuery} from "jotai-tanstack-query" @@ -31,6 +33,12 @@ export const triggerCatalogEventsInfiniteFamily = atomFamily((integrationKey: st staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey, + // Persists whole {pages, pageParams}; cast bridges persisterFn's pageParam-less typing. + persister: catalogPersister.persisterFn as QueryPersister< + TriggerCatalogEventsResponse, + QueryKey, + unknown + >, } }), ) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts index a4fdf6a53f..e1d6047031 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerCatalogIntegrations.ts @@ -1,5 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {atom, useAtomValue, useSetAtom} from "jotai" import {atomWithInfiniteQuery} from "jotai-tanstack-query" @@ -29,6 +31,12 @@ export const triggerCatalogIntegrationsInfiniteAtom = getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, staleTime: 5 * 60_000, refetchOnWindowFocus: false, + // Persists whole {pages, pageParams}; cast bridges persisterFn's pageParam-less typing. + persister: catalogPersister.persisterFn as QueryPersister< + TriggerCatalogIntegrationsResponse, + QueryKey, + unknown + >, } }) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts index 94ac383212..b7f3b40128 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts @@ -1,3 +1,5 @@ +import {catalogPersister} from "@agenta/shared/api/persist" +import type {QueryKey} from "@tanstack/react-query" import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -22,6 +24,7 @@ export const triggerEventDetailQueryFamily = atomFamily( staleTime: 5 * 60_000, refetchOnWindowFocus: false, enabled: !!integrationKey && !!eventKey, + persister: catalogPersister.persisterFn, })), (a, b) => a.integrationKey === b.integrationKey && a.eventKey === b.eventKey, ) diff --git a/web/packages/agenta-entities/src/shared/openapi/serviceSchemaAtoms.ts b/web/packages/agenta-entities/src/shared/openapi/serviceSchemaAtoms.ts index b1671eb756..4a12f26a97 100644 --- a/web/packages/agenta-entities/src/shared/openapi/serviceSchemaAtoms.ts +++ b/web/packages/agenta-entities/src/shared/openapi/serviceSchemaAtoms.ts @@ -6,7 +6,9 @@ * and cached, making schema data available immediately when a revision is selected. */ +import {catalogPersister} from "@agenta/shared/api/persist" import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import type {QueryKey} from "@tanstack/react-query" import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" @@ -38,6 +40,7 @@ const serviceSchemaQueryAtomFamily = atomFamily((serviceType: AppServiceType) => refetchOnWindowFocus: false, refetchOnMount: false, enabled: get(sessionAtom) && !!projectId, + persister: catalogPersister.persisterFn, } }), ) diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index e3e5d826df..af78e35c38 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -12,7 +12,9 @@ * @packageDocumentation */ +import {catalogPersister} from "@agenta/shared/api/persist" import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import type {QueryKey} from "@tanstack/react-query" import {atom, getDefaultStore} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -35,7 +37,7 @@ import {workflowLocalServerDataAtomFamily} from "./store" * Query atom for application template definitions (chat, completion, custom). * Templates are static data (built-in app types), cached for 5 minutes. */ -export const appTemplatesQueryAtom = atomWithQuery((get) => { +export const appTemplatesQueryAtom = atomWithQuery((get) => { const projectId = get(projectIdAtom) return { queryKey: ["appTemplates", projectId], @@ -46,6 +48,7 @@ export const appTemplatesQueryAtom = atomWithQuery((get) => { enabled: get(sessionAtom) && !!projectId, staleTime: 5 * 60_000, refetchOnWindowFocus: false, + persister: catalogPersister.persisterFn, } }) diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorTemplateAtoms.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorTemplateAtoms.ts index fbccb032e5..b32d1fd82b 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorTemplateAtoms.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorTemplateAtoms.ts @@ -8,7 +8,9 @@ * Re-exported from `./evaluatorUtils` for backward compatibility. */ +import {catalogPersister} from "@agenta/shared/api/persist" import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import type {QueryKey} from "@tanstack/react-query" import {atom} from "jotai" import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" @@ -20,7 +22,10 @@ import {fetchEvaluatorTemplates} from "../api/templates" * Query atom for evaluator template definitions. * Templates are static data (built-in evaluator types), cached for 5 minutes. */ -export const evaluatorTemplatesQueryAtom = atomWithQuery((get) => { +export const evaluatorTemplatesQueryAtom = atomWithQuery<{ + count: number + templates: EvaluatorCatalogTemplate[] +}>((get) => { const projectId = get(projectIdAtom) return { queryKey: ["evaluatorTemplates", projectId], @@ -31,6 +36,10 @@ export const evaluatorTemplatesQueryAtom = atomWithQuery((get) => { enabled: get(sessionAtom) && !!projectId, staleTime: 5 * 60_000, refetchOnWindowFocus: false, + persister: catalogPersister.persisterFn< + {count: number; templates: EvaluatorCatalogTemplate[]}, + QueryKey + >, } }) diff --git a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts index 19e0a13a7c..fdb90fa982 100644 --- a/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts +++ b/web/packages/agenta-entities/src/workflow/state/inspectMeta.ts @@ -12,14 +12,13 @@ * `api/oss/src/apis/fastapi/workflows/router.py` (`/catalog/harnesses/`). */ +import {catalogPersister} from "@agenta/shared/api/persist" import {atom} from "jotai" import {atomFamily} from "jotai/utils" -import {atomWithQuery} from "jotai-tanstack-query" +import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" import {fetchHarnessCapabilities} from "../api" -import {persistedCatalogSeed, writePersistedCatalog} from "./persistedCatalog" - /** A real, sourced price (USD per million tokens). Never a rating. */ export interface ModelPricing { input_per_mtok: number @@ -89,27 +88,24 @@ export type HarnessCapabilitiesMap = Record * The harness catalog. Global and project-independent (the catalog is static), so it is not keyed * by anything. * - * Persisted to localStorage (`persistedCatalogSeed`) so an agent-playground reload has the harness + * Persisted to IndexedDB (`catalogPersister`) so an agent-playground reload has the harness * capabilities available for first paint (model picker + collapsed "Unavailable"/"Connect key" - * badges) without a blocking fetch, then revalidates once in the background. NOT `staleTime: - * Infinity` — harness capabilities are still evolving, so the disk seed is treated as stale-on-reload. + * badges) without a blocking fetch, then revalidates once in the background when stale. NOT + * `staleTime: Infinity` — harness capabilities are still evolving. */ -const HARNESS_CATALOG_CACHE_KEY = "harness-catalog" -export const harnessCatalogQueryAtom = atomWithQuery(() => { - const seed = persistedCatalogSeed(HARNESS_CATALOG_CACHE_KEY) - // Disk seed present → the model picker / badges already painted, so this fetch is a background - // revalidation; demote it to low priority so it yields to the critical-path queries. - const lowPriority = seed.initialData !== undefined +export const harnessCatalogQueryAtom = atomWithQuery((get) => { + const queryClient = get(queryClientAtom) + const queryKey = ["workflows", "catalog", "harnesses"] return { - queryKey: ["workflows", "catalog", "harnesses"], + queryKey, queryFn: async () => { - const catalog = (await fetchHarnessCapabilities({ + // In-memory data present ⇒ this is a background revalidate ⇒ low network priority. + const lowPriority = queryClient.getQueryData(queryKey) !== undefined + return (await fetchHarnessCapabilities({ lowPriority, })) as unknown as HarnessCapabilitiesMap - writePersistedCatalog(HARNESS_CATALOG_CACHE_KEY, catalog) - return catalog }, - ...seed, + persister: catalogPersister.persisterFn, staleTime: 5 * 60_000, refetchOnWindowFocus: false, } diff --git a/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts b/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts deleted file mode 100644 index f80c0caa84..0000000000 --- a/web/packages/agenta-entities/src/workflow/state/persistedCatalog.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Disk-backed SWR seed for the static workflow catalogs (ag-type schema, harness capabilities). - * - * These responses are global and immutable-per-key, so a cold page reload re-fetching them is pure - * waterfall latency before the agent playground can paint its config sections / model picker. We - * persist them to `localStorage` and feed the last value back as the query's `initialData`, so a - * reload paints from cache INSTANTLY instead of waiting on the network. - * - * The seed is deliberately marked immediately stale (`initialDataUpdatedAt: 0`) rather than pinned - * (`staleTime: Infinity`): the agent-template schema is still evolving, so on the first mount after - * a reload the query paints from disk AND fires one background (non-blocking) revalidation that - * rewrites the cache. A finite `staleTime` on the consuming query then dedupes in-session remounts. - * - * Best-effort: any SSR / storage / parse / quota failure silently falls back to a normal fetch. - */ - -const CACHE_PREFIX = "agenta:catalog-swr" -// Bump to hard-invalidate every persisted catalog after a breaking response-shape change. -const CACHE_VERSION = "1" - -interface PersistedCatalogEntry { - v: string - ts: number - data: T -} - -const storageKey = (key: string) => `${CACHE_PREFIX}:${CACHE_VERSION}:${key}` - -/** - * TanStack query options to spread for a disk-seeded, background-revalidating catalog query. - * Empty when there is nothing persisted (or no `window`), so the query fetches normally. - */ -export function persistedCatalogSeed(key: string): { - initialData?: T - initialDataUpdatedAt?: number -} { - if (typeof window === "undefined") return {} - try { - const raw = window.localStorage.getItem(storageKey(key)) - if (!raw) return {} - const parsed = JSON.parse(raw) as PersistedCatalogEntry - if (parsed?.v !== CACHE_VERSION || parsed.data == null) return {} - // `initialDataUpdatedAt: 0` = treat the disk value as ancient: paint from it immediately, - // but always revalidate once on the first post-reload mount to catch schema changes. - return {initialData: parsed.data, initialDataUpdatedAt: 0} - } catch { - return {} - } -} - -/** Persist a freshly-fetched catalog value. Call from the query's `queryFn` after a successful fetch. */ -export function writePersistedCatalog(key: string, data: T): void { - if (typeof window === "undefined" || data == null) return - try { - const entry: PersistedCatalogEntry = {v: CACHE_VERSION, ts: Date.now(), data} - window.localStorage.setItem(storageKey(key), JSON.stringify(entry)) - } catch { - // quota exceeded / serialization failure — the cache is best-effort, so ignore. - } -} diff --git a/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts b/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts deleted file mode 100644 index ea9ab847b4..0000000000 --- a/web/packages/agenta-entities/src/workflow/state/persistedInspect.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Disk-backed SWR for `inspectWorkflow` — the long pole of the agent-playground reload waterfall - * (it leaves the Agenta API host and hits the agent-service container directly, and it gates which - * config sections render). The inspect response is keyed by `uri + serviceUrl` (per SERVICE, shared - * across a workflow's revisions), so one persisted entry serves every revision of an agent. - * - * Persisted so a reload paints the config sections from disk instantly, then revalidates once in the - * background (`initialDataUpdatedAt: 0`) — a committed revision's config is immutable, but its - * resolved schema can shift when the service redeploys, so we never pin it. Payloads are large, so - * this is bounded by a BYTE BUDGET (not an entry count): `localStorage` is a single ~5 MB budget - * shared with auth-critical state (SuperTokens), and count-capping variable-size blobs lets the - * cache silently monopolize the origin and starve other writers into a `QuotaExceededError`. We keep - * the cache well under the origin limit, evict oldest-first to make room BEFORE writing, and purge - * our own namespace if a write ever still hits quota — so this cache never leaves the origin full. - * - * Best-effort: any SSR / storage / parse / quota failure silently falls back to a normal fetch. - */ - -const PREFIX = "agenta:inspect-swr" -// Bump to hard-invalidate every persisted inspect after a breaking response-shape change. -const VERSION = "1" -// Total budget for all inspect payloads, in JSON chars (~UTF-16 code units, the unit browsers -// meter localStorage in). ~1.2 MB of chars ≈ ~2.4 MB stored — a fraction of the ~5 MB origin, so -// auth + app state always keep headroom. A single payload larger than this is simply not cached. -const MAX_BYTES = 1_200_000 -// Secondary hard cap so a pathological run of tiny payloads can't create unbounded index churn. -const MAX_ENTRIES = 15 -const INDEX_KEY = `${PREFIX}:${VERSION}:__index` - -const entryKey = (key: string) => `${PREFIX}:${VERSION}:${key}` - -interface PersistedInspectEntry { - v: string - data: T -} - -function readIndex(): string[] { - try { - const raw = window.localStorage.getItem(INDEX_KEY) - const parsed = raw ? JSON.parse(raw) : [] - return Array.isArray(parsed) ? (parsed as string[]) : [] - } catch { - return [] - } -} - -/** Drop the entire inspect namespace (every entry + the index). Used to self-heal on quota. */ -function purgeInspectCache(): void { - try { - for (const id of readIndex()) { - try { - window.localStorage.removeItem(entryKey(id)) - } catch { - // ignore - } - } - window.localStorage.removeItem(INDEX_KEY) - } catch { - // ignore - } -} - -/** - * TanStack query options to spread for a disk-seeded, background-revalidating inspect query. - * Empty when nothing is persisted for `key` (or no `window`), so the query fetches normally. - */ -export function persistedInspectSeed(key: string): { - initialData?: T - initialDataUpdatedAt?: number -} { - if (typeof window === "undefined" || !key) return {} - try { - const raw = window.localStorage.getItem(entryKey(key)) - if (!raw) return {} - const parsed = JSON.parse(raw) as PersistedInspectEntry - if (parsed?.v !== VERSION || parsed.data == null) return {} - // Paint from disk immediately, but always revalidate once (schema can shift on redeploy). - return {initialData: parsed.data, initialDataUpdatedAt: 0} - } catch { - return {} - } -} - -/** Persist a freshly-fetched inspect value. Call from the query's `queryFn` after a successful fetch. */ -export function writePersistedInspect(key: string, data: T): void { - if (typeof window === "undefined" || !key || data == null) return - - const raw = JSON.stringify({v: VERSION, data} satisfies PersistedInspectEntry) - // A single payload over the whole budget is never worth caching (and can't be evicted into fit). - if (raw.length > MAX_BYTES) return - - try { - // Evict oldest-first to fit the byte budget BEFORE writing the new entry, so the cache never - // transiently overflows and never depends on the write succeeding to run eviction. - const kept: string[] = [] - let total = raw.length - for (const id of readIndex()) { - if (id === key) continue - const existing = window.localStorage.getItem(entryKey(id)) - const size = existing ? existing.length : 0 - if (kept.length < MAX_ENTRIES - 1 && total + size <= MAX_BYTES) { - kept.push(id) - total += size - } else { - try { - window.localStorage.removeItem(entryKey(id)) - } catch { - // ignore - } - } - } - window.localStorage.setItem(entryKey(key), raw) - window.localStorage.setItem(INDEX_KEY, JSON.stringify([key, ...kept])) - } catch { - // Quota despite the budget (origin already full from elsewhere): drop our whole namespace so - // we free space and never leave the origin full for auth-critical writers. Best-effort cache. - purgeInspectCache() - } -} diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 5f31918a52..a6e159de60 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -11,6 +11,7 @@ * @packageDocumentation */ +import {catalogPersister, immutablePersister} from "@agenta/shared/api/persist" import {projectIdAtom, sessionAtom} from "@agenta/shared/state" import {createBatchFetcher, stripEmptyCollectionsDeep} from "@agenta/shared/utils" import isEqual from "fast-deep-equal" @@ -62,8 +63,6 @@ import { withLatestAgentFlags, } from "./helpers" import {writePersistedAgentType} from "./persistedAgentType" -import {persistedCatalogSeed, writePersistedCatalog} from "./persistedCatalog" -import {persistedInspectSeed, writePersistedInspect} from "./persistedInspect" // ============================================================================ // HELPERS @@ -127,7 +126,12 @@ function primeWorkflowRevisionDetailCache( workflow: Workflow | null | undefined, ): void { if (!workflow?.id) return - queryClient.setQueryData(["workflows", "revision", workflow.id, projectId], workflow) + const queryKey = ["workflows", "revision", workflow.id, projectId] + queryClient.setQueryData(queryKey, workflow) + // setQueryData bypasses the persister — mirror full bodies (only revisions carry `data`) to IDB. + if (workflow.data) { + void immutablePersister.persistQueryByKey(queryKey, queryClient).catch(() => undefined) + } } /** @@ -1115,7 +1119,7 @@ export function primeWorkflowArtifactCacheImperative( * via the batch fetcher because the playground stores revision IDs, not workflow IDs. */ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => - atomWithQuery((get) => { + atomWithQuery((get) => { const projectId = get(workflowProjectIdAtom) const queryClient = get(queryClientAtom) const detailCached = @@ -1169,6 +1173,8 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => return persistType(await workflowRevisionBatchFetcher({projectId, revisionId})) }, initialData: detailCached ?? undefined, + // detailCached/enabled evaluate synchronously; the persister restores only inside a fetch they allowed, so no race. + persister: immutablePersister.persisterFn, enabled: get(sessionAtom) && !!projectId && @@ -1176,7 +1182,8 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => !detailCached && !isLocalDraftId(revisionId) && !isPlaceholderId(revisionId), - staleTime: 30_000, + staleTime: Infinity, + gcTime: 60 * 60_000, } }), ) @@ -1197,7 +1204,7 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => * `http://host/services/completion` → `agenta:builtin:completion:v0`). */ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => - atomWithQuery((get) => { + atomWithQuery((get) => { const projectId = get(workflowProjectIdAtom) const revisionQuery = get(workflowQueryAtomFamily(revisionId)) const serverData = revisionQuery.data ?? null @@ -1248,24 +1255,18 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => hasUrl && (isAgent || !hasAllSchemas) - // Per-SERVICE cache key (inspect is keyed by uri+serviceUrl, shared across revisions), so one - // persisted entry serves every revision of an agent. Only seed when inspect actually runs. - const inspectCacheKey = - isEnabled && uri && serviceUrl ? `${projectId}::${uri}::${serviceUrl}` : "" - const inspectSeed = persistedInspectSeed(inspectCacheKey) - // Disk seed present → the config sections already painted, so this is a background - // revalidation; demote it to low priority so it yields to the critical-path queries. - const lowPriority = inspectSeed.initialData !== undefined + const queryClient = get(queryClientAtom) + const queryKey = ["workflows", "inspect", revisionId, uri, serviceUrl, projectId] return { - queryKey: ["workflows", "inspect", revisionId, uri, serviceUrl, projectId], + queryKey, queryFn: async (): Promise => { if (!projectId || !uri || !serviceUrl) return null - const result = await inspectWorkflow(uri, projectId, serviceUrl, {lowPriority}) - if (result) writePersistedInspect(inspectCacheKey, result) - return result + // In-memory data present ⇒ this is a background revalidate ⇒ low network priority. + const lowPriority = queryClient.getQueryData(queryKey) !== undefined + return inspectWorkflow(uri, projectId, serviceUrl, {lowPriority}) }, - // Disk seed → paint config sections instantly on reload, then revalidate once (SWR). - ...inspectSeed, + // IDB-persisted (per revision+uri+serviceUrl): paint from disk, then one revalidate when stale. + persister: catalogPersister.persisterFn, enabled: isEnabled, staleTime: 60_000, } @@ -1394,27 +1395,23 @@ export const workflowBuildKitOverlayReadyAtomFamily = atomFamily((revisionId: st * When the frontend encounters a schema property with `x-ag-type-ref` but no * sub-properties, it calls this to get the full schema from the backend. * - * Persisted to localStorage (`persistedCatalogSeed`) so a cold reload paints the + * Persisted to IndexedDB (`catalogPersister`) so a cold reload paints the * agent-template config sections from disk instantly, then revalidates once in the - * background. NOT `staleTime: Infinity` — the agent-template schema is still evolving, - * so the disk seed is treated as stale-on-reload; the finite `staleTime` only dedupes - * in-session remounts (e.g. revision switches). + * background when stale. NOT `staleTime: Infinity` — the agent-template schema is still + * evolving; the finite `staleTime` only dedupes in-session remounts (e.g. revision switches). */ export const agTypeSchemaAtomFamily = atomFamily((agType: string) => - atomWithQuery((_get) => { - const cacheKey = `ag-type-schema:${agType}` - const seed = persistedCatalogSeed>(cacheKey) - // With a disk seed the UI already painted, so this fetch is a background revalidation — - // demote it to low network priority so it doesn't compete with the critical-path queries. - const lowPriority = seed.initialData !== undefined + atomWithQuery>((get) => { + const queryClient = get(queryClientAtom) + const queryKey = ["workflows", "schemas", "ag-types", agType] return { - queryKey: ["workflows", "schemas", "ag-types", agType], + queryKey, queryFn: async (): Promise> => { - const schema = await fetchAgTypeSchema(agType, {lowPriority}) - writePersistedCatalog(cacheKey, schema) - return schema + // In-memory data present ⇒ this is a background revalidate ⇒ low network priority. + const lowPriority = queryClient.getQueryData(queryKey) !== undefined + return fetchAgTypeSchema(agType, {lowPriority}) }, - ...seed, + persister: catalogPersister.persisterFn, staleTime: 5 * 60_000, refetchOnWindowFocus: false, } diff --git a/web/packages/agenta-shared/package.json b/web/packages/agenta-shared/package.json index 6455056059..faa95805e4 100644 --- a/web/packages/agenta-shared/package.json +++ b/web/packages/agenta-shared/package.json @@ -20,6 +20,7 @@ ".": "./src/index.ts", "./api": "./src/api/index.ts", "./api/env": "./src/api/env.ts", + "./api/persist": "./src/api/persist/index.ts", "./state": "./src/state/index.ts", "./utils": "./src/utils/index.ts", "./hooks": "./src/hooks/index.ts", @@ -27,8 +28,10 @@ "./types": "./src/types/index.ts" }, "dependencies": { + "@tanstack/query-persist-client-core": "5.100.9", "axios": "1.16.0", "dayjs": "^1.11.20", + "idb-keyval": "^6.3.0", "json5": "^2.2.3", "jsonrepair": "^3.13.3", "uuid": "^11.1.1" @@ -37,6 +40,7 @@ "@types/node": "^20.19.20", "@types/react": "^19.0.10", "@vitest/coverage-v8": "^4.1.4", + "fake-indexeddb": "^6.2.5", "typescript": "^5.9.3", "vitest": "^4.1.4" }, diff --git a/web/packages/agenta-shared/src/api/persist/debug.ts b/web/packages/agenta-shared/src/api/persist/debug.ts new file mode 100644 index 0000000000..8548916108 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/debug.ts @@ -0,0 +1,84 @@ +import type {PersistedQuery} from "@tanstack/query-persist-client-core" + +const DEBUG_FLAG = "agenta:persist:debug" +const DISABLE_FLAG = "agenta:persist:disable" + +/** Enable via `localStorage.setItem("agenta:persist:debug", "1")` + reload; disable with removeItem. */ +export const isPersistDebugEnabled = (): boolean => { + try { + return typeof localStorage !== "undefined" && localStorage.getItem(DEBUG_FLAG) === "1" + } catch { + return false + } +} + +/** Kill switch for A/B debugging: `localStorage.setItem("agenta:persist:disable", "1")` + reload. */ +export const isPersistDisabled = (): boolean => { + try { + return typeof localStorage !== "undefined" && localStorage.getItem(DISABLE_FLAG) === "1" + } catch { + return false + } +} + +const shortKey = (key: string): string => { + // Key format: `${prefix}-${queryHash}`; the hash is the stringified queryKey. + const dash = key.indexOf("-[") + if (dash === -1) return key + const prefix = key.slice(0, dash) + const hash = key.slice(dash + 1) + return `${prefix} ${hash.length > 120 ? `${hash.slice(0, 120)}…` : hash}` +} + +const approxSize = (value: PersistedQuery): string => { + try { + const bytes = JSON.stringify(value.state.data)?.length ?? 0 + return bytes > 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B` + } catch { + return "?" + } +} + +const ageOf = (value: PersistedQuery): string => { + const updatedAt = value.state.dataUpdatedAt + if (!updatedAt) return "no-timestamp" + const seconds = Math.round((Date.now() - updatedAt) / 1000) + return seconds > 120 ? `${Math.round(seconds / 60)}m old` : `${seconds}s old` +} + +type PersistEvent = "read-hit" | "read-miss" | "write" | "skip" | "evict" | "clear" | "gc" + +/** Console diagnostics for the persistence layer; no-op unless the debug flag is set. */ +export const persistLog = ( + event: PersistEvent, + key?: string, + value?: PersistedQuery | null, +): void => { + if (!isPersistDebugEnabled()) return + const label = key ? shortKey(key) : "" + switch (event) { + case "read-hit": + console.info( + `[persist] HIT ${label} (${value ? `${approxSize(value)}, ${ageOf(value)}` : "?"})`, + ) + break + case "read-miss": + console.info(`[persist] MISS ${label}`) + break + case "write": + console.info(`[persist] WRITE ${label}${value ? ` (${approxSize(value)})` : ""}`) + break + case "skip": + console.info(`[persist] SKIP ${label} (nullish data — not persisted)`) + break + case "evict": + console.info(`[persist] EVICT ${label} (expired or buster mismatch)`) + break + case "clear": + console.info("[persist] CLEAR all entries") + break + case "gc": + console.info(`[persist] GC ${label}`) + break + } +} diff --git a/web/packages/agenta-shared/src/api/persist/gc.ts b/web/packages/agenta-shared/src/api/persist/gc.ts new file mode 100644 index 0000000000..2888e24374 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/gc.ts @@ -0,0 +1,32 @@ +import {isPersistDebugEnabled, persistLog} from "./debug" +import {idbQueryStorage} from "./idbStorage" +import {catalogPersister, immutablePersister} from "./persisters" + +let scheduled = false + +/** + * One idle-time sweep per session: drops entries past maxAge or with a stale + * PERSIST_SCHEMA_VERSION buster. Safe to call from multiple mount points. + */ +export function schedulePersistedQueryGc(): void { + if (scheduled || typeof window === "undefined") return + scheduled = true + + const run = async () => { + const before = isPersistDebugEnabled() + ? (await idbQueryStorage.entries?.())?.length + : undefined + await immutablePersister.persisterGc().catch(() => undefined) + await catalogPersister.persisterGc().catch(() => undefined) + if (before !== undefined) { + const after = (await idbQueryStorage.entries?.())?.length ?? 0 + persistLog("gc", `${before} entries → ${after} (${before - after} swept)`) + } + } + + if (typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(run, {timeout: 10_000}) + } else { + setTimeout(run, 5_000) + } +} diff --git a/web/packages/agenta-shared/src/api/persist/idbStorage.ts b/web/packages/agenta-shared/src/api/persist/idbStorage.ts new file mode 100644 index 0000000000..c04d5def09 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/idbStorage.ts @@ -0,0 +1,91 @@ +import type {AsyncStorage, PersistedQuery} from "@tanstack/query-persist-client-core" +import {clear, createStore, del, entries, get, set} from "idb-keyval" +import type {UseStore} from "idb-keyval" + +import {isPersistDisabled, persistLog} from "./debug" + +const DB_NAME = "agenta-query-cache" +const STORE_NAME = "queries" + +let store: UseStore | null = null + +/** Lazy store handle; null during SSR or when IndexedDB is unavailable. */ +const getStore = (): UseStore | null => { + if (typeof indexedDB === "undefined") return null + if (!store) store = createStore(DB_NAME, STORE_NAME) + return store +} + +/** Nullish data must never persist: an immutable-restored `null` would suppress refetch forever. */ +const hasPersistableData = (value: PersistedQuery): boolean => + value.state.data !== null && value.state.data !== undefined + +/** + * AsyncStorage adapter over IndexedDB for TanStack Query's per-query persister. + * Stores PersistedQuery objects directly (structured clone) — no JSON round-trip. + * All operations are best-effort: storage failures degrade to a cache miss. + */ +export const idbQueryStorage: AsyncStorage = { + getItem: async (key) => { + const s = getStore() + if (!s || isPersistDisabled()) return undefined + try { + const value = await get(key, s) + if (value !== undefined && !hasPersistableData(value)) { + void del(key, s).catch(() => undefined) + persistLog("evict", key) + return undefined + } + persistLog(value === undefined ? "read-miss" : "read-hit", key, value) + return value + } catch { + return undefined + } + }, + setItem: async (key, value) => { + const s = getStore() + if (!s || isPersistDisabled()) return + if (!hasPersistableData(value)) { + persistLog("skip", key) + return + } + try { + await set(key, value, s) + persistLog("write", key, value) + } catch { + // best-effort: quota/serialization failures must never break the query + } + }, + removeItem: async (key) => { + const s = getStore() + if (!s) return + try { + await del(key, s) + // The persister removes entries only when expired or buster-mismatched. + persistLog("evict", key) + } catch { + // best-effort + } + }, + entries: async () => { + const s = getStore() + if (!s) return [] + try { + return await entries(s) + } catch { + return [] + } + }, +} + +/** Drop every persisted query entry (call on logout / workspace teardown). */ +export async function clearPersistedQueryCache(): Promise { + const s = getStore() + if (!s) return + try { + await clear(s) + persistLog("clear") + } catch { + // best-effort + } +} diff --git a/web/packages/agenta-shared/src/api/persist/index.ts b/web/packages/agenta-shared/src/api/persist/index.ts new file mode 100644 index 0000000000..063441ec3f --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/index.ts @@ -0,0 +1,4 @@ +export {PERSIST_SCHEMA_VERSION} from "./version" +export {idbQueryStorage, clearPersistedQueryCache} from "./idbStorage" +export {immutablePersister, catalogPersister, type QueryPersister} from "./persisters" +export {schedulePersistedQueryGc} from "./gc" diff --git a/web/packages/agenta-shared/src/api/persist/persisters.ts b/web/packages/agenta-shared/src/api/persist/persisters.ts new file mode 100644 index 0000000000..576e0462e3 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/persisters.ts @@ -0,0 +1,40 @@ +import {experimental_createQueryPersister} from "@tanstack/query-persist-client-core" +import type {PersistedQuery} from "@tanstack/query-persist-client-core" + +import {idbQueryStorage} from "./idbStorage" +import {PERSIST_SCHEMA_VERSION} from "./version" + +const identity = (value: PersistedQuery) => value + +const CATALOG_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 + +/** + * Class A — immutable-by-key bodies (e.g. workflow revisions): restore from disk and + * never refetch. Pair with `staleTime: Infinity` on the query so later observer mounts + * don't revalidate either. Invalidation happens only via PERSIST_SCHEMA_VERSION bumps. + */ +export const immutablePersister = experimental_createQueryPersister({ + storage: idbQueryStorage, + buster: PERSIST_SCHEMA_VERSION, + maxAge: Number.POSITIVE_INFINITY, + serialize: identity, + deserialize: identity, + refetchOnRestore: false, + prefix: "agenta-imm", +}) + +/** + * Class B — catalogs/schemas that change on backend deploys: paint-from-disk, then one + * background revalidate when stale (refetchOnRestore default). Keep a finite staleTime + * on the query; restored entries are older than it, so exactly one refetch fires. + */ +export const catalogPersister = experimental_createQueryPersister({ + storage: idbQueryStorage, + buster: PERSIST_SCHEMA_VERSION, + maxAge: CATALOG_MAX_AGE_MS, + serialize: identity, + deserialize: identity, + prefix: "agenta-cat", +}) + +export type QueryPersister = typeof immutablePersister diff --git a/web/packages/agenta-shared/src/api/persist/version.ts b/web/packages/agenta-shared/src/api/persist/version.ts new file mode 100644 index 0000000000..c2ee33054e --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/version.ts @@ -0,0 +1,6 @@ +/** + * Buster for persisted query entries. Bump whenever the client-side shape of any + * persisted payload changes (e.g. a Zod schema evolves) — entries with a stale + * version are discarded on read and swept by GC. + */ +export const PERSIST_SCHEMA_VERSION = "v1" diff --git a/web/packages/agenta-shared/tests/unit/persist.test.ts b/web/packages/agenta-shared/tests/unit/persist.test.ts new file mode 100644 index 0000000000..5814a195ac --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/persist.test.ts @@ -0,0 +1,363 @@ +// fake-indexeddb must load before the module under test so `typeof indexedDB !== "undefined"` +import "fake-indexeddb/auto" + +import type {PersistedQuery} from "@tanstack/query-persist-client-core" +import {QueryClient, QueryObserver, hashKey} from "@tanstack/react-query" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" +import {beforeEach, describe, expect, it, vi} from "vitest" + +import {clearPersistedQueryCache, idbQueryStorage} from "../../src/api/persist/idbStorage" +import {catalogPersister, immutablePersister} from "../../src/api/persist/persisters" +import {PERSIST_SCHEMA_VERSION} from "../../src/api/persist/version" + +const DAY_MS = 24 * 60 * 60 * 1000 + +const newClient = () => + new QueryClient({defaultOptions: {queries: {retry: false, gcTime: Number.POSITIVE_INFINITY}}}) + +// persist-client-core bundles its own query-core copy, so its persisterFn signature is +// structurally identical but nominally distinct from react-query's QueryPersister +const asPersister = (fn: typeof immutablePersister.persisterFn): QueryPersister => + fn as unknown as QueryPersister + +// persistQueryByKey's QueryClient type also comes from the bundled query-core copy +type PersisterClient = Parameters[1] + +// a queryFn that must never run; retry:false makes any call fail the test loudly +const neverFetch = () => + vi.fn(async (): Promise => { + throw new Error("unexpected fetch") + }) + +const immStorageKey = (key: QueryKey) => `agenta-imm-${hashKey(key)}` +const catStorageKey = (key: QueryKey) => `agenta-cat-${hashKey(key)}` + +// persistQuery / afterRestore run on notifyManager.schedule (setTimeout 0) +const flushMacrotasks = async (rounds = 3) => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +const listEntries = async () => (await idbQueryStorage.entries?.()) ?? [] + +const waitForStored = async (storageKey: string): Promise => { + await vi.waitFor(async () => { + expect(await idbQueryStorage.getItem(storageKey)).toBeTruthy() + }) + const entry = await idbQueryStorage.getItem(storageKey) + if (!entry) throw new Error(`expected persisted entry at ${storageKey}`) + return entry +} + +const mutateStored = async ( + storageKey: string, + mutate: (entry: PersistedQuery) => PersistedQuery, +): Promise => { + const entry = await idbQueryStorage.getItem(storageKey) + if (!entry) throw new Error(`expected persisted entry at ${storageKey}`) + await idbQueryStorage.setItem(storageKey, mutate(entry)) +} + +const agedCopy = (entry: PersistedQuery, dataUpdatedAt: number): PersistedQuery => ({ + ...entry, + state: {...entry.state, dataUpdatedAt}, +}) + +const makePersisted = (key: QueryKey, data: unknown, dataUpdatedAt = Date.now()): PersistedQuery => ({ + buster: PERSIST_SCHEMA_VERSION, + queryHash: hashKey(key), + queryKey: key, + state: { + data, + dataUpdateCount: 1, + dataUpdatedAt, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: "success", + fetchStatus: "idle", + }, +}) + +beforeEach(async () => { + await clearPersistedQueryCache() +}) + +describe("idbQueryStorage round-trip", () => { + it("setItem/getItem/removeItem/entries round-trip PersistedQuery objects", async () => { + const key: QueryKey = ["rt", 1] + const persisted = makePersisted(key, {hello: "world", nested: [1, 2, 3]}) + + await idbQueryStorage.setItem("rt-key", persisted) + expect(await idbQueryStorage.getItem("rt-key")).toEqual(persisted) + + const entries = await listEntries() + expect(entries).toEqual([["rt-key", persisted]]) + + await idbQueryStorage.removeItem("rt-key") + expect(await idbQueryStorage.getItem("rt-key")).toBeUndefined() + expect(await listEntries()).toEqual([]) + }) + + it("getItem of a missing key returns undefined", async () => { + expect(await idbQueryStorage.getItem("never-written")).toBeUndefined() + }) +}) + +describe("immutablePersister (Class A)", () => { + it("restores on a fresh client without refetching and keeps the original dataUpdatedAt", async () => { + const key: QueryKey = ["imm", "class-a"] + const body = {id: "rev-1", config: {prompts: ["a", "b"]}} + + const clientA = newClient() + const spyA = vi.fn(async () => body) + const first = await clientA.fetchQuery({ + queryKey: key, + queryFn: spyA, + persister: asPersister(immutablePersister.persisterFn), + staleTime: Number.POSITIVE_INFINITY, + }) + expect(first).toEqual(body) + expect(spyA).toHaveBeenCalledTimes(1) + + // persistQuery runs on a macrotask after the fetch resolves + const stored = await waitForStored(immStorageKey(key)) + expect(stored.buster).toBe(PERSIST_SCHEMA_VERSION) + expect(stored.state.data).toEqual(body) + + // age the stored timestamp so we can prove restore keeps it + const agedAt = Date.now() - 3_600_000 + await idbQueryStorage.setItem(immStorageKey(key), agedCopy(stored, agedAt)) + + // fresh client = simulated reload + const clientB = newClient() + const spyB = neverFetch() + const restored = await clientB.fetchQuery({ + queryKey: key, + queryFn: spyB, + persister: asPersister(immutablePersister.persisterFn), + staleTime: Number.POSITIVE_INFINITY, + }) + expect(restored).toEqual(body) + expect(spyB).not.toHaveBeenCalled() + + // afterRestore macrotask: restores timestamps; refetchOnRestore=false → no fetch + await flushMacrotasks() + expect(spyB).not.toHaveBeenCalled() + expect(clientB.getQueryState(key)?.dataUpdatedAt).toBe(agedAt) + }) + + it("restores entries far older than 14d (maxAge Infinity)", async () => { + const key: QueryKey = ["imm", "ancient"] + const body = {id: "rev-old"} + await idbQueryStorage.setItem( + immStorageKey(key), + makePersisted(key, body, Date.now() - 15 * DAY_MS), + ) + + const client = newClient() + const spy = neverFetch() + const restored = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(immutablePersister.persisterFn), + staleTime: Number.POSITIVE_INFINITY, + }) + expect(restored).toEqual(body) + await flushMacrotasks() + expect(spy).not.toHaveBeenCalled() + }) +}) + +describe("catalogPersister (Class B)", () => { + it("observer paints restored data first, then exactly one background refetch", async () => { + const key: QueryKey = ["cat", "models"] + const staleBody = {models: ["gpt-4"]} + const freshBody = {models: ["gpt-4", "gpt-5"]} + + const clientA = newClient() + await clientA.fetchQuery({ + queryKey: key, + queryFn: async () => staleBody, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 60_000, + }) + const stored = await waitForStored(catStorageKey(key)) + + // age past staleTime but within maxAge → restore succeeds, then revalidates + await idbQueryStorage.setItem(catStorageKey(key), agedCopy(stored, Date.now() - 120_000)) + + const clientB = newClient() + const spy = vi.fn(async () => freshBody) + const observer = new QueryObserver(clientB, { + queryKey: key, + queryFn: spy, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 60_000, + retry: false, + }) + const seen: unknown[] = [] + const unsubscribe = observer.subscribe((result) => { + if (result.data !== undefined) seen.push(result.data) + }) + + try { + // restored data arrives before any network result + await vi.waitFor(() => expect(seen.length).toBeGreaterThan(0)) + expect(seen[0]).toEqual(staleBody) + + // one background revalidate fires because the restored entry is stale + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => + expect(observer.getCurrentResult().data).toEqual(freshBody), + ) + await flushMacrotasks() + expect(spy).toHaveBeenCalledTimes(1) + } finally { + unsubscribe() + } + }) + + it("fetchQuery alone (no observer) restores but does NOT background-refetch", async () => { + const key: QueryKey = ["cat", "no-observer"] + const staleBody = {v: "old"} + await idbQueryStorage.setItem( + catStorageKey(key), + makePersisted(key, staleBody, Date.now() - 120_000), + ) + + const client = newClient() + const spy = vi.fn(async () => ({v: "new"})) + const restored = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 60_000, + }) + expect(restored).toEqual(staleBody) + + // without observers Query.isStale() is false when data exists → no revalidate + await flushMacrotasks() + expect(spy).not.toHaveBeenCalled() + }) + + it("discards entries older than 14d and takes the network path", async () => { + const key: QueryKey = ["cat", "expired"] + const freshBody = {v: "fresh"} + await idbQueryStorage.setItem( + catStorageKey(key), + makePersisted(key, {v: "expired"}, Date.now() - 15 * DAY_MS), + ) + + const client = newClient() + const spy = vi.fn(async () => freshBody) + const result = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 60_000, + }) + expect(result).toEqual(freshBody) + expect(spy).toHaveBeenCalledTimes(1) + + // stale entry was dropped, fresh fetch re-persisted + const stored = await waitForStored(catStorageKey(key)) + expect(stored.state.data).toEqual(freshBody) + }) +}) + +describe("buster mismatch", () => { + it("discards the stale entry, fetches, and re-persists with the current buster", async () => { + const key: QueryKey = ["imm", "busted"] + const freshBody = {id: "new-shape"} + + await idbQueryStorage.setItem(immStorageKey(key), { + ...makePersisted(key, {id: "old-shape"}), + buster: "v0", + }) + + const client = newClient() + const spy = vi.fn(async () => freshBody) + const result = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(immutablePersister.persisterFn), + staleTime: Number.POSITIVE_INFINITY, + }) + expect(result).toEqual(freshBody) + expect(spy).toHaveBeenCalledTimes(1) + + await vi.waitFor(async () => { + const stored = await idbQueryStorage.getItem(immStorageKey(key)) + expect(stored?.buster).toBe(PERSIST_SCHEMA_VERSION) + expect(stored?.state.data).toEqual(freshBody) + }) + }) +}) + +describe("persistQueryByKey", () => { + it("persists data primed via setQueryData; fresh client restores without fetching", async () => { + const key: QueryKey = ["imm", "primed"] + const body = {id: "primed-rev", payload: {x: 1}} + + const clientA = newClient() + clientA.setQueryData(key, body) + // dual query-core instances make the QueryClient types nominally incompatible + await immutablePersister.persistQueryByKey(key, clientA as unknown as PersisterClient) + + // setItem inside persistQuery is fire-and-forget; poll until written + const stored = await waitForStored(immStorageKey(key)) + expect(stored.state.data).toEqual(body) + expect(stored.buster).toBe(PERSIST_SCHEMA_VERSION) + + const clientB = newClient() + const spy = neverFetch() + const restored = await clientB.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(immutablePersister.persisterFn), + staleTime: Number.POSITIVE_INFINITY, + }) + expect(restored).toEqual(body) + await flushMacrotasks() + expect(spy).not.toHaveBeenCalled() + }) +}) + +describe("clearPersistedQueryCache", () => { + it("drops every entry", async () => { + await idbQueryStorage.setItem("agenta-imm-a", makePersisted(["a"], 1)) + await idbQueryStorage.setItem("agenta-cat-b", makePersisted(["b"], 2)) + expect((await listEntries()).length).toBe(2) + + await clearPersistedQueryCache() + expect(await listEntries()).toEqual([]) + }) +}) + +describe("SSR guard (no indexedDB)", () => { + it("all storage methods resolve harmlessly", async () => { + vi.resetModules() + vi.stubGlobal("indexedDB", undefined) + try { + const mod = await import("../../src/api/persist/idbStorage") + await expect( + Promise.resolve(mod.idbQueryStorage.getItem("k")), + ).resolves.toBeUndefined() + await expect( + Promise.resolve(mod.idbQueryStorage.setItem("k", makePersisted(["k"], 1))), + ).resolves.toBeUndefined() + await expect(Promise.resolve(mod.idbQueryStorage.removeItem("k"))).resolves.toBeUndefined() + await expect(Promise.resolve(mod.idbQueryStorage.entries?.())).resolves.toEqual([]) + await expect(mod.clearPersistedQueryCache()).resolves.toBeUndefined() + } finally { + vi.unstubAllGlobals() + vi.resetModules() + } + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts b/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts new file mode 100644 index 0000000000..9e0210eec5 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts @@ -0,0 +1,92 @@ +// fake-indexeddb must load before the module under test so `typeof indexedDB !== "undefined"` +import "fake-indexeddb/auto" + +import type {PersistedQuery} from "@tanstack/query-persist-client-core" +import {hashKey} from "@tanstack/react-query" +import type {QueryKey} from "@tanstack/react-query" +import {createStore, get, set} from "idb-keyval" +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" + +import {clearPersistedQueryCache, idbQueryStorage} from "../../src/api/persist/idbStorage" +import {PERSIST_SCHEMA_VERSION} from "../../src/api/persist/version" + +// Same DB/store the adapter uses — lets tests plant entries bypassing the setItem guard +const rawStore = createStore("agenta-query-cache", "queries") + +const makePersisted = (key: QueryKey, data: unknown): PersistedQuery => ({ + buster: PERSIST_SCHEMA_VERSION, + queryHash: hashKey(key), + queryKey: key, + state: { + data, + dataUpdatedAt: Date.now(), + dataUpdateCount: 1, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: "success", + fetchStatus: "idle", + }, +}) + +beforeEach(async () => { + await clearPersistedQueryCache() +}) + +describe("nullish-data guard", () => { + it("setItem skips entries whose data is null or undefined", async () => { + await idbQueryStorage.setItem("agenta-imm-null", makePersisted(["k1"], null)) + await idbQueryStorage.setItem("agenta-imm-undef", makePersisted(["k2"], undefined)) + expect(await get("agenta-imm-null", rawStore)).toBeUndefined() + expect(await get("agenta-imm-undef", rawStore)).toBeUndefined() + }) + + it("setItem still writes falsy-but-real data (0, empty string, empty object)", async () => { + await idbQueryStorage.setItem("agenta-imm-zero", makePersisted(["k3"], 0)) + await idbQueryStorage.setItem("agenta-imm-empty", makePersisted(["k4"], {})) + expect(await idbQueryStorage.getItem("agenta-imm-zero")).toBeTruthy() + expect(await idbQueryStorage.getItem("agenta-imm-empty")).toBeTruthy() + }) + + it("getItem treats a pre-existing null-data entry as a miss and evicts it", async () => { + // Plant directly, bypassing the setItem guard (simulates entries persisted pre-guard) + await set("agenta-imm-legacy", makePersisted(["k5"], null), rawStore) + expect(await idbQueryStorage.getItem("agenta-imm-legacy")).toBeUndefined() + await vi.waitFor(async () => { + expect(await get("agenta-imm-legacy", rawStore)).toBeUndefined() + }) + }) +}) + +describe("kill switch (agenta:persist:disable)", () => { + const disabledStorage = { + getItem: (k: string) => (k === "agenta:persist:disable" ? "1" : null), + setItem: () => undefined, + removeItem: () => undefined, + } + + beforeEach(() => { + vi.stubGlobal("localStorage", disabledStorage) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("reads miss and writes no-op while the flag is set", async () => { + await idbQueryStorage.setItem("agenta-imm-off", makePersisted(["k6"], {a: 1})) + expect(await get("agenta-imm-off", rawStore)).toBeUndefined() + + // Plant a real entry: reads must still miss while disabled + await set("agenta-imm-present", makePersisted(["k7"], {b: 2}), rawStore) + expect(await idbQueryStorage.getItem("agenta-imm-present")).toBeUndefined() + + // Entry survives (kill switch hides, does not destroy) + vi.unstubAllGlobals() + expect(await idbQueryStorage.getItem("agenta-imm-present")).toBeTruthy() + }) +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0b7e7b6bec..4bb2b22aa7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -19,6 +19,7 @@ overrides: postcss@<8.5.10: '>=8.5.10 <9' next@<15.5.18: '>=15.5.18 <16.0.0' vite: ^8.0.16 + '@tanstack/query-core': 5.100.9 patchedDependencies: '@agentaai/nextstepjs@2.1.3-agenta.2': 9d43b8144d7ca4a0b1dfe79073b70fe8a8fa3b52db9a5d88053d4927fab1b251 @@ -173,7 +174,7 @@ importers: specifier: ^2.8.2 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) '@tanstack/query-core': - specifier: ^5.90.20 + specifier: 5.100.9 version: 5.100.9 '@tanstack/react-query': specifier: ^5.90.21 @@ -204,7 +205,7 @@ importers: version: 6.3.7(date-fns@3.6.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.5.14) + version: 10.4.20(postcss@8.5.19) axios: specifier: 1.16.0 version: 1.16.0 @@ -249,7 +250,7 @@ importers: version: 0.11.0(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.6))(jotai@2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) js-yaml: specifier: ^4.1.1 - version: 4.1.1 + version: 4.2.0 jsonrepair: specifier: ^3.13.3 version: 3.14.0 @@ -261,7 +262,7 @@ importers: version: 15.5.18(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) postcss: specifier: ^8.5.10 - version: 8.5.14 + version: 8.5.19 posthog-js: specifier: ^1.223.3 version: 1.372.9 @@ -529,7 +530,7 @@ importers: version: 6.3.7(date-fns@3.6.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.5.14) + version: 10.4.20(postcss@8.5.19) axios: specifier: 1.16.0 version: 1.16.0 @@ -574,7 +575,7 @@ importers: version: 1.15.4 js-yaml: specifier: ^4.1.1 - version: 4.1.1 + version: 4.2.0 jsonrepair: specifier: ^3.13.3 version: 3.14.0 @@ -601,7 +602,7 @@ importers: version: 4.10.38 postcss: specifier: ^8.5.10 - version: 8.5.14 + version: 8.5.19 posthog-js: specifier: ^1.223.3 version: 1.372.9 @@ -643,7 +644,7 @@ importers: version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1) semver: specifier: ^7.7.4 - version: 7.7.4 + version: 7.8.4 shiki: specifier: ^3.23.0 version: 3.23.0 @@ -941,7 +942,7 @@ importers: version: 1.0.1(jotai@2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6)) js-yaml: specifier: ^4.1.1 - version: 4.1.1 + version: 4.2.0 jsonrepair: specifier: ^3.13.3 version: 3.14.0 @@ -1007,7 +1008,7 @@ importers: version: 0.11.0(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.6))(jotai@2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) js-yaml: specifier: ^4.1.1 - version: 4.1.1 + version: 4.2.0 lodash: specifier: ^4.17.23 version: 4.18.1 @@ -1234,6 +1235,9 @@ importers: packages/agenta-shared: dependencies: + '@tanstack/query-persist-client-core': + specifier: 5.100.9 + version: 5.100.9 '@tanstack/react-query': specifier: '>=5.0.0' version: 5.100.9(react@19.2.6) @@ -1243,6 +1247,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + idb-keyval: + specifier: ^6.3.0 + version: 6.3.0 jotai: specifier: '>=2.0.0' version: 2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6) @@ -1271,6 +1278,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.6(vitest@4.1.6) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1335,7 +1345,7 @@ importers: specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/query-core': - specifier: ^5.90.20 + specifier: 5.100.9 version: 5.100.9 '@tanstack/react-query': specifier: ^5.90.21 @@ -1372,7 +1382,7 @@ importers: version: 0.11.0(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.6))(jotai@2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) js-yaml: specifier: ^4.1.1 - version: 4.1.1 + version: 4.2.0 json5: specifier: ^2.2.3 version: 2.2.3 @@ -1607,18 +1617,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -1648,10 +1650,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1729,9 +1727,6 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.0': - resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} @@ -3372,6 +3367,9 @@ packages: '@tanstack/query-core@5.100.9': resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} + '@tanstack/query-persist-client-core@5.100.9': + resolution: {integrity: sha512-sCPZZp3D9sOeqcA4SDxjUIm4wVq8PwHebH4ouFZetwjT4xvGjT/cLBQ4Sst+BFcFuk745pCPkf3T4MFliLHECQ==} + '@tanstack/react-query@5.100.9': resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} peerDependencies: @@ -3945,11 +3943,6 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -4190,9 +4183,6 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} - caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -4905,6 +4895,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5221,6 +5215,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5494,7 +5491,7 @@ packages: jotai-tanstack-query@0.11.0: resolution: {integrity: sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==} peerDependencies: - '@tanstack/query-core': '*' + '@tanstack/query-core': 5.100.9 '@tanstack/react-query': '*' jotai: '>=2.0.0' react: ^18.0.0 || ^19.0.0 @@ -5543,10 +5540,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -6261,14 +6254,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.19: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} @@ -6632,11 +6617,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -6962,10 +6942,6 @@ packages: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -7577,12 +7553,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.29.7': {} @@ -7616,11 +7588,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7704,11 +7671,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 @@ -8054,7 +8016,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.0 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -9253,6 +9215,10 @@ snapshots: '@tanstack/query-core@5.100.9': {} + '@tanstack/query-persist-client-core@5.100.9': + dependencies: + '@tanstack/query-core': 5.100.9 + '@tanstack/react-query@5.100.9(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.9 @@ -9859,9 +9825,9 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - acorn-import-phases@1.0.4(acorn@8.16.0): + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -9871,8 +9837,6 @@ snapshots: dependencies: acorn: 8.17.0 - acorn@8.16.0: {} - acorn@8.17.0: {} ai@6.0.0-beta.150(zod@4.4.3): @@ -10093,14 +10057,14 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.20(postcss@8.5.14): + autoprefixer@10.4.20(postcss@8.5.19): dependencies: browserslist: 4.28.2 - caniuse-lite: 1.0.30001792 + caniuse-lite: 1.0.30001799 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.14 + postcss: 8.5.19 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -10151,7 +10115,7 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 + caniuse-lite: 1.0.30001799 electron-to-chromium: 1.5.352 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -10179,8 +10143,6 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001792: {} - caniuse-lite@1.0.30001799: {} ccount@2.0.1: {} @@ -10637,7 +10599,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.9 - semver: 7.7.4 + semver: 7.8.4 electron-to-chromium@1.5.352: {} @@ -11092,6 +11054,8 @@ snapshots: expect-type@1.3.0: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -11128,9 +11092,9 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fetch-blob@3.2.0: dependencies: @@ -11428,6 +11392,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.3.0: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -11719,10 +11685,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -12004,7 +11966,7 @@ snapshots: magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: @@ -12159,7 +12121,7 @@ snapshots: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001799 - postcss: 8.5.15 + postcss: 8.5.19 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.6) @@ -12378,30 +12340,30 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.14): + postcss-import@15.1.0(postcss@8.5.19): dependencies: - postcss: 8.5.14 + postcss: 8.5.19 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.14): + postcss-js@4.1.0(postcss@8.5.19): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.14 + postcss: 8.5.19 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.4)(yaml@2.8.4): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.22.4)(yaml@2.8.4): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.14 + postcss: 8.5.19 tsx: 4.22.4 yaml: 2.8.4 - postcss-nested@6.2.0(postcss@8.5.14): + postcss-nested@6.2.0(postcss@8.5.19): dependencies: - postcss: 8.5.14 + postcss: 8.5.19 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -12411,18 +12373,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.14: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.19: dependencies: nanoid: 3.3.12 @@ -12884,8 +12834,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.4: {} set-function-length@1.2.2: @@ -13146,7 +13094,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 superjson@1.13.3: @@ -13237,11 +13185,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.14 - postcss-import: 15.1.0(postcss@8.5.14) - postcss-js: 4.1.0(postcss@8.5.14) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.4)(yaml@2.8.4) - postcss-nested: 6.2.0(postcss@8.5.14) + postcss: 8.5.19 + postcss-import: 15.1.0(postcss@8.5.19) + postcss-js: 4.1.0(postcss@8.5.19) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.22.4)(yaml@2.8.4) + postcss-nested: 6.2.0(postcss@8.5.19) postcss-selector-parser: 6.1.2 resolve: 1.22.12 sucrase: 3.35.1 @@ -13264,7 +13212,7 @@ snapshots: terser@5.47.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13298,15 +13246,10 @@ snapshots: tinyexec@1.1.2: {} - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {} @@ -13605,7 +13548,7 @@ snapshots: std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.1.5(@types/node@20.19.39)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.0)(tsx@4.22.4)(yaml@2.8.4) why-is-node-running: 2.3.0 @@ -13684,8 +13627,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.16.0 - acorn-import-phases: 1.0.4(acorn@8.16.0) + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.21.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index ba26704226..25ca9295fc 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -18,6 +18,8 @@ overrides: 'postcss@<8.5.10': '>=8.5.10 <9' 'next@<15.5.18': '>=15.5.18 <16.0.0' vite: '^8.0.16' + # Single query-core instance: keep in lockstep with @tanstack/react-query 5.100.9. + '@tanstack/query-core': '5.100.9' allowBuilds: '@swc/core': true browser-tabs-lock: false From 9bfd2dfd69978c4908bece6af50db09a3cb24079 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 01:20:09 +0200 Subject: [PATCH 02/33] perf(frontend): cut cold-load head-of-line and eager revisions-list fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from measuring the persistence work (dev, 3 runs/condition: persisted requests absent as designed, but felt reload time dominated by the front of the waterfall, not the data chain): - profile + projects paint-fast: profileQueryAtom and projectsQueryAtom now restore from IndexedDB and revalidate in the background (profile has no staleTime, so restore always triggers exactly one refetch). profile gates the entire level-2 fanout on !!user, so warm reloads start that tree ~0.5-1s earlier. Logout clears the store; nullish results never persist. - eager revisions-list mount removed: workflowLatestRevisionIdAtomFamily's fallback branch get()-mounted the full revisions-by-workflow query whenever the dedicated latest-revision query was still in flight — the entire cold-load window — via the two always-mounted header consumers (PlaygroundVariantConfigHeader, SelectVariant). The fallback is now a passive queryClient.getQueryData peek; reactivity flows through the dedicated latest query, whose cache both writers prime. Pickers still mount the list on open; the no-selection default path keeps its list fallback. Replaces a full-body list fetch (0.5-1.7s observed) with the lightweight latest-revision batch on cold loads with a known selection. --- .../playground-query-persistence/plan.md | 26 +++++++++++++++++-- web/oss/src/state/profile/selectors/user.ts | 6 +++++ .../src/state/project/selectors/project.ts | 4 +++ .../src/workflow/state/store.ts | 17 ++++++++---- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md index b5935da510..88799db680 100644 --- a/docs/design/playground-query-persistence/plan.md +++ b/docs/design/playground-query-persistence/plan.md @@ -266,9 +266,31 @@ persistence-related. immutable-restored `null` suppresses refetch forever. The storage adapter skips nullish writes and treats pre-existing nullish entries as miss + evict on read. +**Measurement round (2026-07-19, dev, 3 runs per condition):** persisted requests are +absent as designed, but dev-reload felt-time is dominated by compile/boot (DCL 0.9–43.8s +variance) and backend swing (same endpoint 549→1706ms), so the effect sits under the +noise floor. Key structural finding: the revision body normally rides the +revisions-list response (dedup design), so Class-A persistence only removes a request +when the list is skipped. Two follow-up increments landed: + +- **Head-of-line paint-fast (Class C exceptions):** `profileQueryAtom` + (oss/state/profile/selectors/user.ts) and `projectsQueryAtom` + (oss/state/project/selectors/project.ts) now use `catalogPersister` — + profile has no staleTime so restore always background-revalidates; the level-2 + fanout (gated on `!!user`) starts ~0.5–1s earlier on warm reloads. +- **Eager revisions-list mount removed:** `workflowLatestRevisionIdAtomFamily`'s + fallback (store.ts ~956) `get()`-mounted the full revisions-list query whenever the + dedicated latest query was in flight — i.e. the whole cold-load window, via the two + always-mounted header consumers (PlaygroundVariantConfigHeader, SelectVariant). Now a + passive `queryClient.getQueryData` peek; reactivity flows through the dedicated + latest query (both writers prime its cache). Pickers still mount the list on open; + the no-selection default path (playground.ts ensurePlaygroundDefaults) keeps its + list fallback. + Still open: live browser verification (warm-reload paint without revision request; -commit → reload shows new revision; drawer catalogs instant on reopen), then -increment ④. +commit → reload shows new revision; drawer catalogs instant on reopen; the jotai +"store mutation during atom read" dev warning A/B via the kill switch), then +increment ④ measurement on a prod build. ## Out of scope diff --git a/web/oss/src/state/profile/selectors/user.ts b/web/oss/src/state/profile/selectors/user.ts index 97304da986..ecddcc6666 100644 --- a/web/oss/src/state/profile/selectors/user.ts +++ b/web/oss/src/state/profile/selectors/user.ts @@ -1,5 +1,7 @@ +import {catalogPersister} from "@agenta/shared/api/persist" import {logAtom} from "@agenta/shared/state" import type {User} from "@agenta/shared/types" +import type {QueryKey} from "@tanstack/react-query" import type {AxiosError} from "axios" import {atom} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -35,6 +37,10 @@ export const profileQueryAtom = atomWithQuery((get) => ({ retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 2000), experimental_prefetchInRender: true, enabled: get(sessionExistsAtom), + // Head-of-line gate for the level-2 fanout: paint from disk, always revalidate + // (no staleTime ⇒ restored data is stale ⇒ one background refetch). Logout + // clears the IDB store, and nullish results are never persisted. + persister: catalogPersister.persisterFn, })) const logProfile = process.env.NEXT_PUBLIC_LOG_PROFILE_ATOMS === "true" diff --git a/web/oss/src/state/project/selectors/project.ts b/web/oss/src/state/project/selectors/project.ts index bf63053c29..7c30cbb3b0 100644 --- a/web/oss/src/state/project/selectors/project.ts +++ b/web/oss/src/state/project/selectors/project.ts @@ -1,4 +1,6 @@ +import {catalogPersister} from "@agenta/shared/api/persist" import {logAtom, projectIdAtom} from "@agenta/shared/state" +import type {QueryKey} from "@tanstack/react-query" import {atom} from "jotai" import {atomWithStorage} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -111,6 +113,8 @@ export const projectsQueryAtom = atomWithQuery((get) => { // parallel with /profile/, so this does not reintroduce the sequential // profile-wait that 2ede5faa10 removed to fix the demo-banner cold-load race. enabled: get(sessionExistsAtom) && jwtReady && !isAcceptRoute && !!orgId, + // Paint from disk + background revalidate; key includes orgId so no cross-org bleed. + persister: catalogPersister.persisterFn, } }) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index a6e159de60..5bf13a5949 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -950,11 +950,18 @@ export const workflowLatestRevisionIdAtomFamily = atomFamily((workflowId: string const query = get(workflowLatestRevisionQueryAtomFamily(workflowId)) if (query.data?.id) return query.data.id - // Fall back to the revisions-by-workflow cache (e.g. primed by the - // revisions table). The raw refs are NOT guaranteed to be ordered, so - // sort descending by recency and skip v0 before picking the first. - const revisionsQuery = get(workflowRevisionsByWorkflowQueryAtomFamily(workflowId)) - const refs = revisionsQuery.data?.refs + // Fall back to the revisions-by-workflow cache via a PASSIVE peek — + // get() on the query atom mounts it and fires the full-list fetch on + // every cold load while the dedicated latest query is still in flight. + // Reactivity is provided by branch 1 (both writers prime its cache). + const projectId = get(workflowProjectIdAtom) + const listData = get(queryClientAtom).getQueryData([ + "workflows", + "revisionsByWorkflow", + workflowId, + projectId, + ]) + const refs = listData?.refs if (refs && refs.length > 0) { const sorted = [...refs] .filter((r) => (r.version ?? 0) !== 0) From 0d7d6ba4b054d8fae908fae75e4b43feb014f765 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 01:49:30 +0200 Subject: [PATCH 03/33] perf(frontend): remove redundant playground cold-load requests (HAR-driven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A captured reload waterfall showed the agent playground firing requests for collapsed empty sections, hidden dropdown data, and one guaranteed-null query. This trims the burst without changing what any other page fetches: - idleReadyAtom moves to @agenta/shared/state so package queries can gate on it (oss entitlements consumer re-pointed; no re-export shim) - tools/connections, triggers/subscriptions, triggers/schedules gain enabled: idleReady — they render collapsed-section counts and no longer spend 1.2-2s backend slots inside the first-paint burst - the environments list stays untouched (render-critical on the deployments dashboard); instead the playground's always-mounted deployment badge (revisionDeploymentAtomFamily) returns empty until idle, so it no longer drags environments/query into the burst - variants list defers to dropdown-open: new workflowVariantsCachedListAtomFamily passive cache peek + sticky activation latches in SelectVariant and DeployVariantButton (which double-mounted the list via useAppEnvironments); the collapsed label falls back to revision name, then the variant slug carried by the revision body - fix a guaranteed-null request: DeployVariantButton read the workflow-level is_evaluator flag through the revision-keyed molecule with a WORKFLOW id, firing a null /workflows/revisions/query on every playground load; it now reads workflowDetailQueryAtomFamily, cache-shared with the router - the __ag__build_kit overlay persists via catalogPersister (staleTime Infinity -> 5m so platform updates are picked up) Known same-pattern sites on other views (EvaluatorPlaygroundHeader, WorkflowRevisionDrawer MetadataSidebar, evaluatorColumns) are documented in docs/design/playground-query-persistence/plan.md as a follow-up sweep. --- .../playground-query-persistence/plan.md | 27 ++++++ .../Components/Menus/SelectVariant/index.tsx | 53 ++++++++++- .../assets/DeployVariantButton/index.tsx | 93 +++++++++++++------ web/oss/src/state/access/atoms.ts | 2 +- .../src/environment/state/store.ts | 6 +- .../hooks/useToolConnectionsQuery.ts | 5 +- .../hooks/useTriggerSchedules.ts | 5 +- .../hooks/useTriggerSubscriptions.ts | 5 +- .../agenta-entities/src/workflow/index.ts | 1 + .../src/workflow/state/index.ts | 1 + .../src/workflow/state/store.ts | 24 ++++- .../agenta-shared/src/state}/idleReady.ts | 0 web/packages/agenta-shared/src/state/index.ts | 3 + 13 files changed, 188 insertions(+), 37 deletions(-) rename web/{oss/src/state/boot => packages/agenta-shared/src/state}/idleReady.ts (100%) diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md index 88799db680..9653c75626 100644 --- a/docs/design/playground-query-persistence/plan.md +++ b/docs/design/playground-query-persistence/plan.md @@ -287,6 +287,33 @@ when the list is skipped. Two follow-up increments landed: the no-selection default path (playground.ts ensurePlaygroundDefaults) keeps its list fallback. +**HAR-driven burst-reduction batch (2026-07-19):** a real reload waterfall +(32 requests) drove these; all landed: + +- `idleReadyAtom` moved to `@agenta/shared/state` (oss consumer re-pointed). +- Idle-gated out of the cold burst: `tools/connections`, `triggers/subscriptions`, + `triggers/schedules` (query-level `enabled`), and the environments list via the + playground's `revisionDeploymentAtomFamily` badge atom (the shared list query is + untouched — it is render-critical on the deployments dashboard). +- Variants list deferred to dropdown-open: `workflowVariantsCachedListAtomFamily` + passive peek + sticky activation latches in `SelectVariant` and + `DeployVariantButton` (which double-mounted the list via `useAppEnvironments`). + Collapsed label falls back to revision name → stripped variant slug from the + revision body. +- Zombie null-fetch fixed: `DeployVariantButton` fed the WORKFLOW id into the + revision-keyed `workflowMolecule.selectors.isEvaluator` → guaranteed-null + `/revisions/query` every load. Now reads `workflowDetailQueryAtomFamily` + (cache-shared with the router). +- Build-kit overlay (`__ag__build_kit`) persisted via `catalogPersister` + (staleTime Infinity → 5m). + +Known remaining from the HAR (not in this batch): mounts/files 24s listing (owned +on a separate branch), `sessions/records` triple-fetch (duplicate query stores), +`spans/query` 1.9MB payload trim, billing-502 gating on OSS, and three secondary +sites reusing the workflow-id-into-revision-molecule anti-pattern +(`EvaluatorPlaygroundHeader.tsx:47`, `WorkflowRevisionDrawer/MetadataSidebar.tsx:44`, +`evaluatorColumns.tsx:49` — fire on other views). + Still open: live browser verification (warm-reload paint without revision request; commit → reload shows new revision; drawer catalogs instant on reopen; the jotai "store mutation during atom read" dev warning A/B via the kill switch), then diff --git a/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx b/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx index 8c5f634978..93d2fe2604 100644 --- a/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx +++ b/web/oss/src/components/Playground/Components/Menus/SelectVariant/index.tsx @@ -17,6 +17,7 @@ import { workflowLatestRevisionIdAtomFamily, createLocalDraftFromWorkflowRevision, workflowVariantsListDataAtomFamily, + workflowVariantsCachedListAtomFamily, } from "@agenta/entities/workflow" import { createWorkflowRevisionAdapter, @@ -307,6 +308,18 @@ const SelectVariant = ({ // so hooks are always called in the same order. const [singlePopoverOpen, setSinglePopoverOpen] = useState(false) + // Sticky latch: the variants-list query mounts only after a picker popover + // has been opened once; until then the trigger label peeks the cache passively. + const [variantsListActivated, setVariantsListActivated] = useState(false) + const handleSinglePopoverOpenChange = useCallback((open: boolean) => { + if (open) setVariantsListActivated(true) + setSinglePopoverOpen(open) + }, []) + const handleComparePopoverOpenChange = useCallback((open: boolean) => { + if (open) setVariantsListActivated(true) + setComparePopoverOpen(open) + }, []) + const handleSingleSelect = useCallback( (result: WorkflowRevisionSelectionResult) => { handleSelect(result) @@ -337,7 +350,16 @@ const SelectVariant = ({ } | null )?.variant_id ?? "" - const workflowVariants = useAtomValue(workflowVariantsListDataAtomFamily(selectedWorkflowId)) + // Before first open: passive cache peek only, so this label never fires the fetch. + const workflowVariants = useAtomValue( + useMemo( + () => + variantsListActivated + ? workflowVariantsListDataAtomFamily(selectedWorkflowId) + : workflowVariantsCachedListAtomFamily(selectedWorkflowId), + [variantsListActivated, selectedWorkflowId], + ), + ) const selectedVariantName = useMemo(() => { if (!selectedVariantId) return null const variant = workflowVariants.find((item) => item.id === selectedVariantId) @@ -345,6 +367,24 @@ const SelectVariant = ({ return name && name.length > 0 ? name : null }, [selectedVariantId, workflowVariants]) + // Variant slug carried on the revision body — label fallback when the + // variants list isn't cached yet (variant label = name, then slug). + const selectedVariantSlug = useMemo(() => { + const entity = rawWorkflowEntity as { + workflow_variant_slug?: string | null + variant_slug?: string | null + workflow_slug?: string | null + artifact_slug?: string | null + } | null + const slug = entity?.workflow_variant_slug ?? entity?.variant_slug ?? null + if (!slug) return null + const parentSlug = entity?.workflow_slug ?? entity?.artifact_slug ?? null + // Variant slugs are "." — strip the prefix for display. + return parentSlug && slug.startsWith(`${parentSlug}.`) + ? slug.slice(parentSlug.length + 1) + : slug + }, [rawWorkflowEntity]) + // Look up the parent workflow name for the browse-mode trigger label. Only // BROWSE mode uses this (scoped mode returns null below), so gate the // full-catalog read on `mode` — otherwise an app playground fetches every app @@ -368,7 +408,11 @@ const SelectVariant = ({ return selectedVariantName ?? selectedRevisionData?.name ?? "Draft" } if (selectedRevisionQuery.isPending) return "Loading..." - const variantName = selectedVariantName ?? selectedRevisionData?.name ?? selectPlaceholder + const variantName = + selectedVariantName ?? + selectedRevisionData?.name ?? + selectedVariantSlug ?? + selectPlaceholder // In browse mode, keep workflow context available only when the variant // label cannot disambiguate on its own. if ( @@ -383,6 +427,7 @@ const SelectVariant = ({ }, [ singleSelectedValue, selectedVariantName, + selectedVariantSlug, selectedRevisionData, selectedRevisionQuery.isPending, selectPlaceholder, @@ -433,7 +478,7 @@ const SelectVariant = ({ /> } open={comparePopoverOpen} - onOpenChange={setComparePopoverOpen} + onOpenChange={handleComparePopoverOpenChange} trigger="click" placement="bottomRight" arrow={false} @@ -513,7 +558,7 @@ const SelectVariant = ({ } open={singlePopoverOpen} - onOpenChange={setSinglePopoverOpen} + onOpenChange={handleSinglePopoverOpenChange} trigger="click" placement="bottomLeft" arrow={false} diff --git a/web/oss/src/components/Playground/Components/Modals/DeployVariantModal/assets/DeployVariantButton/index.tsx b/web/oss/src/components/Playground/Components/Modals/DeployVariantModal/assets/DeployVariantButton/index.tsx index 315f0f7093..ba25fbb0c8 100644 --- a/web/oss/src/components/Playground/Components/Modals/DeployVariantModal/assets/DeployVariantButton/index.tsx +++ b/web/oss/src/components/Playground/Components/Modals/DeployVariantModal/assets/DeployVariantButton/index.tsx @@ -1,6 +1,10 @@ import {cloneElement, isValidElement, useCallback, useMemo, useState} from "react" -import {workflowMolecule, workflowVariantsListDataAtomFamily} from "@agenta/entities/workflow" +import { + workflowDetailQueryAtomFamily, + workflowMolecule, + workflowVariantsListDataAtomFamily, +} from "@agenta/entities/workflow" import {CloudArrowUp} from "@phosphor-icons/react" import {useAtomValue} from "jotai" import dynamic from "next/dynamic" @@ -12,15 +16,19 @@ import {DeployVariantButtonProps} from "./types" const DeployVariantModal = dynamic(() => import("../.."), {ssr: false}) -const DeployVariantButton = ({ +// Mounted only after the first click — holds the environments + variants-list +// subscriptions so rendering the button alone never fires those queries. +const DeployVariantModalHost = ({ + open, + onCancel, variantId, revisionId, - label, - icon = true, - children, - ...props -}: DeployVariantButtonProps) => { - const [isDeployModalOpen, setIsDeployModalOpen] = useState(false) +}: { + open: boolean + onCancel: () => void + variantId?: string + revisionId?: string +}) => { const { environments: _environments, mutate: mutateEnv, @@ -29,9 +37,6 @@ const DeployVariantButton = ({ const runnableData = useAtomValue(workflowMolecule.selectors.data(revisionId || "")) const workflowId = runnableData?.workflow_id || "" - // Workflow-level evaluator flag — canonical, unlike the revision-level - // `flags.is_evaluator` which is `false` on v0 revisions of evaluators. - const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(workflowId)) const variants = useAtomValue(workflowVariantsListDataAtomFamily(workflowId)) const {environments, variantName, revision} = useMemo(() => { @@ -47,6 +52,47 @@ const DeployVariantButton = ({ await mutateEnv() }, [mutateEnv]) + return ( + + ) +} + +const DeployVariantButton = ({ + variantId, + revisionId, + label, + icon = true, + children, + ...props +}: DeployVariantButtonProps) => { + const [isDeployModalOpen, setIsDeployModalOpen] = useState(false) + // Sticky latch — data queries (environments, variants) mount on first open only. + const [modalActivated, setModalActivated] = useState(false) + + const runnableData = useAtomValue(workflowMolecule.selectors.data(revisionId || "")) + const workflowId = runnableData?.workflow_id || "" + // Workflow-level evaluator flag — canonical, unlike the revision-level + // `flags.is_evaluator` which is `false` on v0 revisions of evaluators. + // Read via the workflow-keyed detail query (cache-shared with the router); + // the revision molecule is revision-keyed, and feeding it a workflow id + // fired a guaranteed-null /revisions/query on every playground load. + const workflowDetail = useAtomValue(workflowDetailQueryAtomFamily(workflowId || null)) + const isEvaluator = Boolean(workflowDetail.data?.flags?.is_evaluator) + + const handleOpenDeployModal = useCallback(() => { + setModalActivated(true) + setIsDeployModalOpen(true) + }, []) const handleCloseDeployModal = useCallback(() => setIsDeployModalOpen(false), []) // Evaluator workflows aren't deployed to environments — never render a @@ -63,33 +109,28 @@ const DeployVariantButton = ({ onClick: () => void }>, { - onClick: () => { - setIsDeployModalOpen(true) - }, + onClick: handleOpenDeployModal, }, ) ) : ( } - onClick={() => setIsDeployModalOpen(true)} + onClick={handleOpenDeployModal} tooltipProps={icon && !label ? {title: "Deploy"} : {}} label={label} {...props} /> )} - + {modalActivated && ( + + )} ) } diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index 936ec8be85..bc1995e753 100644 --- a/web/oss/src/state/access/atoms.ts +++ b/web/oss/src/state/access/atoms.ts @@ -1,12 +1,12 @@ import {inferQueueMaxFromPlan} from "@agenta/entities/trace/etl" import {lowPriorityWhenCached} from "@agenta/shared/api" +import {idleReadyAtom} from "@agenta/shared/state" import {atom} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import {isBillingEnabled, isEE} from "@/oss/lib/helpers/isEE" -import {idleReadyAtom} from "@/oss/state/boot/idleReady" import {selectedOrgIdAtom} from "@/oss/state/org" import {profileQueryAtom} from "@/oss/state/profile/selectors/user" import {projectIdAtom} from "@/oss/state/project" diff --git a/web/packages/agenta-entities/src/environment/state/store.ts b/web/packages/agenta-entities/src/environment/state/store.ts index b82015f15a..1dd2315756 100644 --- a/web/packages/agenta-entities/src/environment/state/store.ts +++ b/web/packages/agenta-entities/src/environment/state/store.ts @@ -5,7 +5,7 @@ * These provide the single source of truth for server data. */ -import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import {idleReadyAtom, projectIdAtom, sessionAtom} from "@agenta/shared/state" import {createBatchFetcher, isValidUUID} from "@agenta/shared/utils" import {atom, getDefaultStore} from "jotai" import type {PrimitiveAtom} from "jotai" @@ -336,6 +336,10 @@ export const revisionDeploymentAtomFamily = atomFamily((revisionId: string) => atom((get) => { if (!revisionId) return [] + // Passive badge chrome (always-mounted config header): don't let it pull the + // environments list into the cold-load burst; the badge fills in after idle. + if (!get(idleReadyAtom)) return [] + const listQuery = get(environmentsListQueryAtomFamily(false)) const environments = listQuery.data?.environments ?? [] diff --git a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts index 28de151e33..4b5fb4cf12 100644 --- a/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts +++ b/web/packages/agenta-entities/src/gatewayTool/hooks/useToolConnectionsQuery.ts @@ -1,15 +1,18 @@ +import {idleReadyAtom} from "@agenta/shared/state" import {useAtomValue} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" import {queryToolConnections} from "../api" import type {ToolConnectionsResponse} from "../core/types" -export const toolConnectionsQueryAtom = atomWithQuery(() => ({ +export const toolConnectionsQueryAtom = atomWithQuery((get) => ({ queryKey: ["tools", "connections"], // Secondary (tool selector); yield to the render-critical playground queries on load. queryFn: () => queryToolConnections({lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, + // Renders a collapsed-section count; stay out of the cold-load burst entirely. + enabled: get(idleReadyAtom), })) export const useToolConnectionsQuery = () => { diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts index 7ca0505502..01936d8eb7 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSchedules.ts @@ -1,5 +1,6 @@ import {useMemo} from "react" +import {idleReadyAtom} from "@agenta/shared/state" import {useAtomValue} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -7,12 +8,14 @@ import {queryTriggerSchedules} from "../api" import type {TriggerSchedule, TriggerSchedulesResponse} from "../core/types" // Distinct from subscription/catalog/connection keys. -export const triggerSchedulesQueryAtom = atomWithQuery(() => ({ +export const triggerSchedulesQueryAtom = atomWithQuery((get) => ({ queryKey: ["triggers", "schedules"], // Secondary (trigger count badge / section); yield to the render-critical playground queries. queryFn: () => queryTriggerSchedules(undefined, {lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, + // Renders a collapsed-section count; stay out of the cold-load burst entirely. + enabled: get(idleReadyAtom), })) export const useTriggerSchedules = () => { diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts index 9613232e13..ec048eabfe 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts @@ -1,5 +1,6 @@ import {useMemo} from "react" +import {idleReadyAtom} from "@agenta/shared/state" import {useAtomValue} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery} from "jotai-tanstack-query" @@ -8,12 +9,14 @@ import {queryTriggerSubscriptions} from "../api" import type {TriggerSubscription, TriggerSubscriptionsResponse} from "../core/types" // Distinct from the catalog/connection keys (["triggers", "catalog"|"connections"]). -export const triggerSubscriptionsQueryAtom = atomWithQuery(() => ({ +export const triggerSubscriptionsQueryAtom = atomWithQuery((get) => ({ queryKey: ["triggers", "subscriptions"], // Secondary (trigger count badge / section); yield to the render-critical playground queries. queryFn: () => queryTriggerSubscriptions(undefined, {lowPriority: true}), staleTime: 30_000, refetchOnWindowFocus: false, + // Renders a collapsed-section count; stay out of the cold-load burst entirely. + enabled: get(idleReadyAtom), })) export const useTriggerSubscriptions = () => { diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 409b879943..11d0a71687 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -229,6 +229,7 @@ export { // Variant/Revision list queries workflowVariantsQueryAtomFamily, workflowVariantsListDataAtomFamily, + workflowVariantsCachedListAtomFamily, workflowRevisionsQueryAtomFamily, workflowRevisionRefsByVariantAtomFamily, workflowRevisionsListDataAtomFamily, diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 9e80c04bc7..3790233548 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -53,6 +53,7 @@ export { // Variant/Revision list queries (for 3-level hierarchy) workflowVariantsQueryAtomFamily, workflowVariantsListDataAtomFamily, + workflowVariantsCachedListAtomFamily, workflowRevisionsQueryAtomFamily, workflowRevisionRefsByVariantAtomFamily, workflowRevisionsListDataAtomFamily, diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 5bf13a5949..7d13c95d1c 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -571,6 +571,24 @@ export const workflowVariantsListDataAtomFamily = atomFamily((workflowId: string }), ) +const EMPTY_WORKFLOW_VARIANTS: WorkflowVariant[] = [] + +// Passive peek at the cached variants list — never mounts/fetches the query. +// Non-reactive to cache writes; swap to the mounting atom once data is needed. +export const workflowVariantsCachedListAtomFamily = atomFamily((workflowId: string) => + atom((get) => { + const projectId = get(workflowProjectIdAtom) + if (!projectId || !workflowId) return EMPTY_WORKFLOW_VARIANTS + const cached = get(queryClientAtom).getQueryData([ + "workflows", + "variants", + workflowId, + projectId, + ]) + return cached?.workflow_variants ?? EMPTY_WORKFLOW_VARIANTS + }), +) + // ============================================================================ // REVISION LIST QUERY BY WORKFLOW (for 2-level hierarchy: Workflow → Revision) // ============================================================================ @@ -1318,7 +1336,7 @@ export const simpleApplicationQueryAtomFamily = atomFamily((applicationId: strin }), ) -export const agentBuildKitOverlayAtom = atomWithQuery((get) => { +export const agentBuildKitOverlayAtom = atomWithQuery((get) => { const projectId = get(workflowProjectIdAtom) return { queryKey: ["agentBuildKitOverlay", AGENT_BUILD_KIT_WORKFLOW_SLUG, projectId], @@ -1327,8 +1345,10 @@ export const agentBuildKitOverlayAtom = atomWithQuery((get) => { return fetchAgentBuildKitOverlay(projectId) }, enabled: get(sessionAtom) && !!projectId, - staleTime: Infinity, + // Platform-managed slug (Class B): paint from disk, revalidate when >5m old. + staleTime: 5 * 60_000, refetchOnWindowFocus: false, + persister: catalogPersister.persisterFn, } }) diff --git a/web/oss/src/state/boot/idleReady.ts b/web/packages/agenta-shared/src/state/idleReady.ts similarity index 100% rename from web/oss/src/state/boot/idleReady.ts rename to web/packages/agenta-shared/src/state/idleReady.ts diff --git a/web/packages/agenta-shared/src/state/index.ts b/web/packages/agenta-shared/src/state/index.ts index 793d32c326..d65e0ef298 100644 --- a/web/packages/agenta-shared/src/state/index.ts +++ b/web/packages/agenta-shared/src/state/index.ts @@ -29,3 +29,6 @@ export {devLog} from "./devLog" // Storage adapters for atomWithStorage export {stringStorage} from "./stringStorage" + +// Boot-phase idle gate for non-critical bootstrap queries +export {idleReadyAtom} from "./idleReady" From b3a3bccafc38db3610f688235533731c0a802798 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 02:08:03 +0200 Subject: [PATCH 04/33] fix(frontend): inspect persist key rotated per reload; session records fetched 3x Two cold-load defects found via HAR captures after the persistence work: - inspect: local draft ids are minted fresh on every reload (generateLocalId -> local--) and snapshot hydration always re-creates drafts, so the inspect queryKey's revisionId component changed every reload -> the IndexedDB persister missed every time and the full /inspect round-trip (5.3s under contention) fired on every draft reload. The request body never contains a revision id (inspectWorkflow keys on uri + serviceUrl), so the queryKey is now service-scoped: ["workflows","inspect", uri, serviceUrl, projectId]. Restores the old persistedInspect per-service sharing and dedupes in-memory inspect entries across drafts/revisions of one service. Sole invalidation site is prefix-based and unaffected; no per-revision invalidation exists. - session records: loadSessionMessages called querySessionRecords directly, outside TanStack, from two AgentConversation effects (cache-miss hydration + revalidate-on-open). The effects' cancelled flag discards results but not requests, so StrictMode double-mounts / ancestor-key remounts fired two simultaneous 226KB fetches on top of the Inspector's atom-based one (which also fetches while closed - it subscribes before its active guard). The imperative path now goes through queryClient.fetchQuery with the shared sessionRecordsQueryOptions (new fetchSessionRecordsAtom), so all paths join one flight per 15s stale window. --- .../AgentChatSlice/assets/loadSession.ts | 15 +++++------ .../agenta-entities/src/session/index.ts | 1 + .../src/session/state/records.ts | 26 ++++++++++++++++--- .../src/workflow/state/store.ts | 6 +++-- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/assets/loadSession.ts b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts index 1b0629dbd2..5ad1ba0d33 100644 --- a/web/oss/src/components/AgentChatSlice/assets/loadSession.ts +++ b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts @@ -1,5 +1,4 @@ -import {querySessionRecords} from "@agenta/entities/session" -import {projectIdAtom} from "@agenta/shared/state" +import {fetchSessionRecordsAtom} from "@agenta/entities/session" import type {UIMessage} from "ai" import {getDefaultStore} from "jotai" @@ -18,15 +17,13 @@ import {transcriptToMessages} from "./transcriptToMessages" * falls back to whatever is already in localStorage. */ export const loadSessionMessages = async (sessionId: string): Promise => { - const projectId = getDefaultStore().get(projectIdAtom) - if (!projectId) return null - - // Replay hydration is secondary to the live conversation stream — send it low-priority so - // Chromium schedules it behind render-critical traffic. A transient fetch failure resolves to - // `null` (the documented "request failed" contract) so the caller shows the history-unavailable + // Fetch through the shared records query cache (same key as `sessionRecordsQueryFamily`) so + // hydration, revalidation, and the Inspector's atom subscribers share ONE network flight per + // stale window instead of each issuing a raw duplicate request. A failure resolves to `null` + // (the documented "request failed" contract) so the caller shows the history-unavailable // notice instead of leaking an unhandled rejection. try { - const records = await querySessionRecords({sessionId, projectId, lowPriority: true}) + const records = await getDefaultStore().set(fetchSessionRecordsAtom, sessionId) if (!records || records.length === 0) return null return transcriptToMessages(records) } catch (err) { diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index c5529a4e54..38c4369f76 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -69,6 +69,7 @@ export { sessionRecordsQueryFamily, sessionRecordFileRecencyAtomFamily, revalidateSessionRecordsAtom, + fetchSessionRecordsAtom, sessionRecordsQueryKey, } from "./state/records" export { diff --git a/web/packages/agenta-entities/src/session/state/records.ts b/web/packages/agenta-entities/src/session/state/records.ts index ce6a7cb166..a9c0a2e6a9 100644 --- a/web/packages/agenta-entities/src/session/state/records.ts +++ b/web/packages/agenta-entities/src/session/state/records.ts @@ -16,21 +16,39 @@ import type {SessionRecord} from "../core/schema" export const sessionRecordsQueryKey = (projectId: string, sessionId: string) => ["session", "records", projectId, sessionId] as const +const SESSION_RECORDS_STALE_MS = 15_000 + +// Single source of key + fn so atom subscribers and imperative fetches share one flight. +const sessionRecordsQueryOptions = (projectId: string, sessionId: string) => ({ + queryKey: sessionRecordsQueryKey(projectId, sessionId), + queryFn: ({signal}: {signal?: AbortSignal}) => + querySessionRecords({sessionId, projectId, abortSignal: signal, lowPriority: true}), + staleTime: SESSION_RECORDS_STALE_MS, +}) + /** The full, ordered record event log for one session. */ export const sessionRecordsQueryFamily = atomFamily((sessionId: string) => atomWithQuery((get) => { const projectId = get(projectIdAtom) ?? "" return { - queryKey: sessionRecordsQueryKey(projectId, sessionId), - queryFn: ({signal}) => - querySessionRecords({sessionId, projectId, abortSignal: signal, lowPriority: true}), + ...sessionRecordsQueryOptions(projectId, sessionId), enabled: Boolean(sessionId && projectId), - staleTime: 15_000, refetchOnWindowFocus: false, } }), ) +/** Imperative records fetch through the shared cache — dedupes with atom subscribers instead of + * issuing a raw parallel request. Resolves from cache within the stale window. */ +export const fetchSessionRecordsAtom = atom( + null, + (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) return Promise.resolve(null) + return get(queryClientAtom).fetchQuery(sessionRecordsQueryOptions(projectId, sessionId)) + }, +) + /** Durable per-file recency (newest write/edit timestamp per tool path) derived from the record * log — the cross-device, reload-safe source of "which file is newest". Drive surfaces merge this * with the live browser file-activity log (which only sees this tab's turns) so ordering is diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 7d13c95d1c..a2d8405742 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1281,7 +1281,9 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => (isAgent || !hasAllSchemas) const queryClient = get(queryClientAtom) - const queryKey = ["workflows", "inspect", revisionId, uri, serviceUrl, projectId] + // Service-scoped key (no revisionId): inspect depends only on uri+serviceUrl+projectId, + // and local draft ids rotate per reload — keying by them broke persister restores. + const queryKey = ["workflows", "inspect", uri, serviceUrl, projectId] return { queryKey, queryFn: async (): Promise => { @@ -1290,7 +1292,7 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => const lowPriority = queryClient.getQueryData(queryKey) !== undefined return inspectWorkflow(uri, projectId, serviceUrl, {lowPriority}) }, - // IDB-persisted (per revision+uri+serviceUrl): paint from disk, then one revalidate when stale. + // IDB-persisted (per uri+serviceUrl+project): paint from disk, then one revalidate when stale. persister: catalogPersister.persisterFn, enabled: isEnabled, staleTime: 60_000, From 617b3514d261eb2ebfa2436d1f53101f8ae81615 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 02:51:51 +0200 Subject: [PATCH 05/33] perf(frontend): persist trace summaries, gate billing on config, fix null-fetch stragglers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the HAR-driven playground load work: - trace summaries (the 1.9MB /spans/query transcript enrichment) are immutable once ingested (the query already had staleTime Infinity) and now persist via immutablePersister: the payload downloads once per session ever; reloads restore from IndexedDB with zero network. The response was already roots+errors-filtered — it is fat because root spans embed attributes.ag.data, which the playground result chips genuinely need, and /spans/query supports no field projection (a server-side projection option is the follow-up if first-load cost matters) - billing queries (subscription/catalog/pricing) now gate on isBillingEnabled() (NEXT_PUBLIC_AGENTA_BILLING_ENABLED) in addition to isEE(): deployments without the billing service no longer burn a guaranteed 502 on every load; entitlement logic already treats billing-disabled as unrestricted - a closed agent-chat Inspector no longer fetches session records (226KB): it subscribed the records query before its active guard; the query now gets an empty key until the inspector opens - three more sites fed a WORKFLOW id into the revision-keyed molecule (guaranteed-null /revisions/query on their views), same defect as the DeployVariantButton fix: EvaluatorPlaygroundHeader (evaluator playground load), WorkflowRevisionDrawer MetadataSidebar (drawer open), and annotationSessionController testsetSyncEvaluatorsAtom (annotation queues). All now read workflowDetailQueryAtomFamily. evaluatorColumns was audited and left alone: the evaluators table pre-seeds molecule entities, so no fetch fires there and the substitution would have added N real requests --- .../AgentChatSlice/components/Inspector/Inspector.tsx | 4 +++- .../ConfigureEvaluator/EvaluatorPlaygroundHeader.tsx | 11 ++++++----- web/oss/src/state/access/atoms.ts | 7 +++++-- .../state/controllers/annotationSessionController.ts | 8 ++++++-- web/packages/agenta-entities/src/trace/state/store.ts | 6 +++++- .../WorkflowRevisionDrawer/MetadataSidebar.tsx | 11 ++++++----- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/components/Inspector/Inspector.tsx b/web/oss/src/components/AgentChatSlice/components/Inspector/Inspector.tsx index 1f7573ab1f..9544c59875 100644 --- a/web/oss/src/components/AgentChatSlice/components/Inspector/Inspector.tsx +++ b/web/oss/src/components/AgentChatSlice/components/Inspector/Inspector.tsx @@ -88,9 +88,11 @@ export function Inspector({sessionId}: {sessionId: string}) { const [rawOpen, setRawOpen] = useAtom(inspectorRawOpenAtom) const revalidateRecords = useSetAtom(revalidateSessionRecordsAtom) const queryClient = useQueryClient() - const records = useAtomValue(sessionRecordsQueryFamily(sessionId)) const active = target?.sessionId === sessionId ? target : null + // Empty key disables the query while closed — subscribing with the real id + // here made a closed inspector fetch 226KB of records on every cold load. + const records = useAtomValue(sessionRecordsQueryFamily(active ? sessionId : "")) const focusedTurn = active?.focusedTurn ?? null // Gate all record-heavy work behind the open-slide settle (see BODY_READY_MS). Mount-scoped, so diff --git a/web/oss/src/components/Evaluators/components/ConfigureEvaluator/EvaluatorPlaygroundHeader.tsx b/web/oss/src/components/Evaluators/components/ConfigureEvaluator/EvaluatorPlaygroundHeader.tsx index 9ffdc4f20c..c3fbc3fdf5 100644 --- a/web/oss/src/components/Evaluators/components/ConfigureEvaluator/EvaluatorPlaygroundHeader.tsx +++ b/web/oss/src/components/Evaluators/components/ConfigureEvaluator/EvaluatorPlaygroundHeader.tsx @@ -9,7 +9,7 @@ import {useMemo} from "react" -import {workflowMolecule} from "@agenta/entities/workflow" +import {workflowDetailQueryAtomFamily, workflowMolecule} from "@agenta/entities/workflow" import {playgroundController} from "@agenta/playground" import {Typography} from "antd" import {useAtomValue} from "jotai" @@ -36,16 +36,17 @@ const EvaluatorPlaygroundHeader: React.FC = () => { // The entity display name lives on the workflow artifact; the revision's // own `name` carries the variant name ("default"). - const workflowId = evaluatorData?.workflow_id ?? evaluatorEntityId + const workflowId = evaluatorData?.workflow_id ?? null const artifactName = useAtomValue( useMemo( () => workflowMolecule.selectors.artifactName(evaluatorEntityId), [evaluatorEntityId], ), ) - const workflowSlug = useAtomValue( - useMemo(() => workflowMolecule.selectors.slug(workflowId), [workflowId]), - ) + // Workflow-keyed detail query (cache-shared) — the molecule's slug selector + // is revision-keyed and fires a guaranteed-null /revisions/query for a workflow id. + const workflowDetail = useAtomValue(workflowDetailQueryAtomFamily(workflowId)) + const workflowSlug = workflowDetail.data?.slug ?? null const evaluatorName = artifactName?.trim() || workflowSlug?.trim() || evaluatorData?.slug?.trim() || "Evaluator" diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index bc1995e753..79d1789313 100644 --- a/web/oss/src/state/access/atoms.ts +++ b/web/oss/src/state/access/atoms.ts @@ -110,8 +110,11 @@ export const currentSubscriptionQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, // Deferred to browser idle (billing chrome, not first-paint critical). + // isBillingEnabled: config-gated — deployments without the billing service + // (e.g. local EE dev) otherwise burn a guaranteed 502 on every load. enabled: isEE() && + isBillingEnabled() && sessionExists && !!organizationId && !!user && @@ -155,7 +158,7 @@ export const catalogQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, // Deferred to browser idle (billing chrome, not first-paint critical). - enabled: isEE() && sessionExists && get(idleReadyAtom), + enabled: isEE() && isBillingEnabled() && sessionExists && get(idleReadyAtom), retry: entitlementRetry, } }) @@ -176,7 +179,7 @@ export const pricingQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, // Deferred to browser idle (billing chrome, not first-paint critical). - enabled: isEE() && sessionExists && get(idleReadyAtom), + enabled: isEE() && isBillingEnabled() && sessionExists && get(idleReadyAtom), retry: entitlementRetry, } }) diff --git a/web/packages/agenta-annotation/src/state/controllers/annotationSessionController.ts b/web/packages/agenta-annotation/src/state/controllers/annotationSessionController.ts index 3c8e0e79e7..9a35120d82 100644 --- a/web/packages/agenta-annotation/src/state/controllers/annotationSessionController.ts +++ b/web/packages/agenta-annotation/src/state/controllers/annotationSessionController.ts @@ -63,7 +63,7 @@ import { traceRootSpanAtomFamily, type TraceSpan, } from "@agenta/entities/trace" -import {workflowMolecule} from "@agenta/entities/workflow" +import {workflowDetailQueryAtomFamily, workflowMolecule} from "@agenta/entities/workflow" import {axios, getAgentaApiUrl, queryClient} from "@agenta/shared/api" import {projectIdAtom} from "@agenta/shared/state" import {extractApiErrorMessage} from "@agenta/shared/utils" @@ -552,7 +552,11 @@ const testsetSyncEvaluatorsAtom = atom((get) => { for (const step of annotationSteps) { const workflowId = step.references?.evaluator?.id ?? null - const evaluatorEntity = workflowId ? get(workflowMolecule.selectors.data(workflowId)) : null + // Workflow-keyed detail query (cache-shared) — the molecule's data selector + // is revision-keyed and fires a guaranteed-null /revisions/query for a workflow id. + const evaluatorEntity = workflowId + ? get(workflowDetailQueryAtomFamily(workflowId)).data + : null const name = evaluatorEntity?.name?.trim() || null const slug = step.references?.evaluator?.slug ?? diff --git a/web/packages/agenta-entities/src/trace/state/store.ts b/web/packages/agenta-entities/src/trace/state/store.ts index 1bafb87fdc..72f273755f 100644 --- a/web/packages/agenta-entities/src/trace/state/store.ts +++ b/web/packages/agenta-entities/src/trace/state/store.ts @@ -18,8 +18,10 @@ * ``` */ +import {immutablePersister} from "@agenta/shared/api/persist" import {projectIdAtom, sessionAtom} from "@agenta/shared/state" import {createBatchFetcher} from "@agenta/shared/utils" +import type {QueryKey} from "@tanstack/react-query" import {atom, getDefaultStore} from "jotai" import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" @@ -758,12 +760,14 @@ export const traceEntityAtomFamily = instrumentedAtomFamily( */ export const traceSummaryQueryAtomFamily = instrumentedAtomFamily( (traceId: string | null) => - atomWithQuery((get) => { + atomWithQuery((get) => { const projectId = get(projectIdAtom) return { queryKey: ["trace-summary", projectId, traceId ?? "none"], enabled: Boolean(get(sessionAtom) && traceId && projectId), + // Class A persistence: reloads restore summaries from IDB, no refetch. + persister: immutablePersister.persisterFn, // Immutable once ingested; invalidateTraceEntityCache covers reruns. staleTime: Infinity, gcTime: 5 * 60_000, diff --git a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/MetadataSidebar.tsx b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/MetadataSidebar.tsx index 8f2302360b..ad90367b56 100644 --- a/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/MetadataSidebar.tsx +++ b/web/packages/agenta-playground-ui/src/components/WorkflowRevisionDrawer/MetadataSidebar.tsx @@ -8,7 +8,7 @@ import {memo, useMemo} from "react" import {environmentMolecule} from "@agenta/entities/environment" import {UserAuthorLabel} from "@agenta/entities/shared" -import {workflowMolecule} from "@agenta/entities/workflow" +import {workflowDetailQueryAtomFamily, workflowMolecule} from "@agenta/entities/workflow" import {FormattedDate, cn, textColors} from "@agenta/ui" import {Typography} from "antd" import clsx from "clsx" @@ -39,10 +39,11 @@ const MetadataSidebar = memo(({revisionId, context, isCompact}: MetadataSidebarP const workflowData = useAtomValue( useMemo(() => workflowMolecule.selectors.data(revisionId), [revisionId]), ) - const workflowId = workflowData?.workflow_id ?? "" - const workflowSlug = useAtomValue( - useMemo(() => workflowMolecule.selectors.slug(workflowId), [workflowId]), - ) + const workflowId = workflowData?.workflow_id ?? null + // Workflow-keyed detail query (cache-shared) — the molecule's slug selector + // is revision-keyed and fires a guaranteed-null /revisions/query for a workflow id. + const workflowDetail = useAtomValue(workflowDetailQueryAtomFamily(workflowId)) + const workflowSlug = workflowDetail.data?.slug ?? null const deployedIn = useAtomValue(environmentMolecule.atoms.revisionDeployment(revisionId)) const { renderEnvironmentLabel, From f9acb0e1cbec4857552d42a2e282105e36c9f1ae Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 02:52:20 +0200 Subject: [PATCH 06/33] docs(design): record HAR rounds 2-3 outcomes in playground-query-persistence plan --- .../design/playground-query-persistence/plan.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md index 9653c75626..4d926f1b68 100644 --- a/docs/design/playground-query-persistence/plan.md +++ b/docs/design/playground-query-persistence/plan.md @@ -307,12 +307,17 @@ when the list is skipped. Two follow-up increments landed: - Build-kit overlay (`__ag__build_kit`) persisted via `catalogPersister` (staleTime Infinity → 5m). -Known remaining from the HAR (not in this batch): mounts/files 24s listing (owned -on a separate branch), `sessions/records` triple-fetch (duplicate query stores), -`spans/query` 1.9MB payload trim, billing-502 gating on OSS, and three secondary -sites reusing the workflow-id-into-revision-molecule anti-pattern -(`EvaluatorPlaygroundHeader.tsx:47`, `WorkflowRevisionDrawer/MetadataSidebar.tsx:44`, -`evaluatorColumns.tsx:49` — fire on other views). +Follow-up rounds (commits e63a5e928e + 9b3a6103c7), all verified across four HAR +captures: sessions/records ×3 → ×1 (imperative loadSession path bypassed TanStack; +"cancelled" flags discard results, not requests); inspect persist key service-scoped +(draft ids rotate per reload — revisionId in the key caused a persister miss every +draft reload); trace summaries Class-A persisted (1.9MB /spans/query once per session +ever; /spans/query has no field-projection — server-side projection excluding +`attributes.ag.data` for summary callers is the backend follow-up); billing queries +config-gated on `isBillingEnabled()`; closed Inspector no longer fetches records; +EvaluatorPlaygroundHeader / MetadataSidebar / annotationSessionController null-fetch +sites fixed (evaluatorColumns audited: NOT a defect — the table pre-seeds molecule +entities). Mounts/files remains owned on a separate branch. Still open: live browser verification (warm-reload paint without revision request; commit → reload shows new revision; drawer catalogs instant on reopen; the jotai From f5ebc5469f58a153d2ba72dc1bd0d7baae19c733 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 03:22:06 +0200 Subject: [PATCH 07/33] perf(frontend): secrets/records paint-from-disk, sidebar list deferrals Increment-4 round of the playground persistence work: - vault secrets paint-fast with REDACTED persistence: /secrets/ returns plaintext key material (provider keys, AWS secrets, session tokens, service-account JSON), so a dedicated vaultSecretsPersister serializes a redacted projection - every secret-value field becomes a truthy "[redacted]" sentinel (truthy so hasKey badges paint correctly from disk), and refetchOnRestore: "always" guarantees one immediate refresh so a sentinel can never linger into the provider edit modal and get saved back over a real key. 8 tests incl. a no-raw-secret-in-payload sweep - session records paint-from-disk: chat history renders instantly on warm reload via a dedicated recordsPersister (7d maxAge, prefix agenta-rec, refetchOnRestore: "always" - fires even observer-less, closing the bare fetchQuery restore gap). The one-shot loadSessionMessages path gains an onRefreshed callback so the fresh transcript is re-delivered after the post-restore revalidate; adoption stays behind the existing busy/ strictly-ahead guards, so live streams and optimistic tails are never clobbered - sidebar all-apps list + agent-flags batch gated at the sidebar source (returns loading until idle): the query atoms stay untouched because archived-apps/overview/evaluation pages render primary content from them. Orgs list gated enabled: neededForBoot || idle where neededForBoot means the URL carries no workspace id - auth/post-login/workspace-selection flows fetch immediately, workspace-scoped routes defer to idle; org DETAIL and the fa56d9f dedup seeding untouched --- .../AgentChatSlice/AgentConversation.tsx | 40 +++-- .../AgentChatSlice/assets/loadSession.ts | 19 ++- .../src/components/Sidebar/dynamic/source.ts | 6 + web/oss/src/state/org/selectors/org.ts | 14 +- web/packages/agenta-entities/package.json | 1 + .../agenta-entities/src/secret/state/atoms.ts | 11 +- .../src/secret/state/persistence.ts | 91 ++++++++++ .../agenta-entities/src/session/index.ts | 1 + .../src/session/state/records.ts | 56 +++++- .../unit/secret-persist-redaction.test.ts | 160 ++++++++++++++++++ .../agenta-shared/src/api/persist/gc.ts | 3 +- .../agenta-shared/src/api/persist/index.ts | 7 +- .../src/api/persist/persisters.ts | 20 +++ .../agenta-shared/tests/unit/persist.test.ts | 76 ++++++++- web/pnpm-lock.yaml | 3 + 15 files changed, 484 insertions(+), 24 deletions(-) create mode 100644 web/packages/agenta-entities/src/secret/state/persistence.ts create mode 100644 web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index bcce2e5f24..69d7358c0d 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -607,6 +607,13 @@ const AgentConversation = ({ const busy = status === "submitted" || status === "streaming" + // `messages`/`busy` change every token; consumers that must stay referentially stable + // (`handleRewind`, the hydration/SWR adoption guards) read them through refs instead. + const messagesRef = useRef(messages) + messagesRef.current = messages + const busyRef = useRef(busy) + busyRef.current = busy + // Mid-stream drive signals: settled write-ish tool calls append file-activity entries (and // throttle-revalidate the drives) as the turn streams, not just at onFinish. useFileActivityDetector({sessionId, messages}) @@ -644,7 +651,23 @@ const AgentConversation = ({ // StrictMode's mount→unmount→mount cycle re-runs the fetch (the first run is cancelled) // instead of latching a ref that leaves the transcript blank. let cancelled = false - loadSessionMessages(sessionId) + // Post-restore revalidation: the first result may be the disk-restored log (paints + // instantly); when the guaranteed background refetch lands, adopt it under the same + // guards as the SWR effect below — never mid-stream, only when strictly ahead. + const adoptRefreshed = (freshMsgs: UIMessage[]) => { + if (cancelled || busyRef.current) return + if (freshMsgs.length <= messagesRef.current.length) return + freshMsgs.forEach((m) => { + seenIdsRef.current.add(m.id) + restoredIdsRef.current.add(m.id) + }) + armBottomRef.current = true + // The restore said "no records" but the server has some — clear the notice. + setHydratedEmpty(false) + setMessages(freshMsgs) + persistMessages({id: sessionId, messages: freshMsgs}) + } + loadSessionMessages(sessionId, adoptRefreshed) .then((msgs) => { if (cancelled) return if (!msgs || msgs.length === 0) { @@ -942,14 +965,6 @@ const AgentConversation = ({ }, [firstRunSeed, entityId, activeSessionId, sessionId, messages.length, setFirstRunSeed]) const consumedRunNonceRef = useRef(null) - // `handleRewind` is passed to every memo'd `AgentMessage`, so it must stay referentially - // stable — a streamed token must not recreate it and re-render the whole list. `messages`/ - // `busy` change every token, so read them through refs instead of capturing them. - const messagesRef = useRef(messages) - messagesRef.current = messages - const busyRef = useRef(busy) - busyRef.current = busy - // SWR revalidate-on-open: a cached session paints instantly from localStorage; in the background // we refetch the durable records ONCE (low-priority) and adopt the server transcript ONLY IF it's // strictly ahead of what we're showing (a turn finished on another device). We never clobber a @@ -963,7 +978,7 @@ const AgentConversation = ({ // As with the hydration effect above: no persistent ref, so StrictMode's double-mount // re-runs the revalidation rather than latching it out. let cancelled = false - loadSessionMessages(sessionId).then((serverMsgs) => { + const adopt = (serverMsgs: UIMessage[] | null) => { if (cancelled || !serverMsgs || serverMsgs.length === 0) return const prev = messagesRef.current if (busyRef.current || serverMsgs.length <= prev.length) return @@ -974,7 +989,10 @@ const AgentConversation = ({ armBottomRef.current = true setMessages(serverMsgs) persistMessages({id: sessionId, messages: serverMsgs}) - }) + } + // The first result may itself be the disk-restored records log; the callback re-applies + // the same guarded adoption when the guaranteed background revalidation lands. + loadSessionMessages(sessionId, adopt).then(adopt) return () => { cancelled = true } diff --git a/web/oss/src/components/AgentChatSlice/assets/loadSession.ts b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts index 5ad1ba0d33..e7590fba71 100644 --- a/web/oss/src/components/AgentChatSlice/assets/loadSession.ts +++ b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts @@ -15,15 +15,30 @@ import {transcriptToMessages} from "./transcriptToMessages" * Returns `null` when there is no server history (project scope missing, request failed, or * the record log is empty — e.g. the ingest worker isn't running locally). The caller then * falls back to whatever is already in localStorage. + * + * The records query is disk-persisted (IndexedDB): a warm reload resolves instantly from the + * restored log, and the entities layer guarantees one background revalidation (disk is never + * authoritative). Because this return is a one-shot copy, `onRefreshed` re-delivers the + * transcript when that revalidation lands — callers apply it behind their own adoption guards. */ -export const loadSessionMessages = async (sessionId: string): Promise => { +export const loadSessionMessages = async ( + sessionId: string, + onRefreshed?: (messages: UIMessage[]) => void, +): Promise => { // Fetch through the shared records query cache (same key as `sessionRecordsQueryFamily`) so // hydration, revalidation, and the Inspector's atom subscribers share ONE network flight per // stale window instead of each issuing a raw duplicate request. A failure resolves to `null` // (the documented "request failed" contract) so the caller shows the history-unavailable // notice instead of leaking an unhandled rejection. try { - const records = await getDefaultStore().set(fetchSessionRecordsAtom, sessionId) + const {records, refreshed} = await getDefaultStore().set(fetchSessionRecordsAtom, sessionId) + if (refreshed && onRefreshed) { + void refreshed.then((fresh) => { + if (!fresh || fresh.length === 0) return + const freshMsgs = transcriptToMessages(fresh) + if (freshMsgs && freshMsgs.length > 0) onRefreshed(freshMsgs) + }) + } if (!records || records.length === 0) return null return transcriptToMessages(records) } catch (err) { diff --git a/web/oss/src/components/Sidebar/dynamic/source.ts b/web/oss/src/components/Sidebar/dynamic/source.ts index 52ce129962..d3af62b07a 100644 --- a/web/oss/src/components/Sidebar/dynamic/source.ts +++ b/web/oss/src/components/Sidebar/dynamic/source.ts @@ -1,4 +1,5 @@ import type {ListQueryState} from "@agenta/entities/shared" +import {idleReadyAtom} from "@agenta/shared/state" import {atom, type Atom} from "jotai" import {sidebarOpenGroupsAtomFamily, sidebarPopupGroupsAtomFamily} from "@/oss/lib/atoms/sidebar" @@ -24,6 +25,11 @@ export const gatedSidebarSource = ( return {status: "idle", refs: []} } + // Sidebar chrome — hold the catalog queries until the first idle moment after load. + if (!get(idleReadyAtom)) { + return {status: "loading", refs: []} + } + const query = get(listAtom) if (query.isPending) return {status: "loading", refs: []} if (query.isError) return {status: "error", refs: [], error: query.error} diff --git a/web/oss/src/state/org/selectors/org.ts b/web/oss/src/state/org/selectors/org.ts index 0d631124f0..184c8e803a 100644 --- a/web/oss/src/state/org/selectors/org.ts +++ b/web/oss/src/state/org/selectors/org.ts @@ -1,4 +1,4 @@ -import {logAtom} from "@agenta/shared/state" +import {idleReadyAtom, logAtom} from "@agenta/shared/state" import type {User} from "@agenta/shared/types" import {atom} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" @@ -120,6 +120,10 @@ export const resolveWorkspaceIdForOrg = async ( export const orgsQueryAtom = atomWithQuery((get) => { const userId = (get(userAtom) as User | null)?.id + // Boot resolution needs the list only when the URL carries no workspace id (auth + // redirects, /w selection). On workspace-scoped routes it is switcher/settings + // chrome, so defer it to the first idle moment instead of the load burst. + const neededForBoot = !get(appIdentifiersAtom).workspaceId return { queryKey: ["orgs", userId || ""], queryFn: async () => fetchAllOrgsList(), @@ -127,7 +131,7 @@ export const orgsQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, - enabled: !!userId, + enabled: !!userId && (neededForBoot || get(idleReadyAtom)), } }) @@ -207,6 +211,12 @@ const normalizeOrgIdentifier = async ( return {orgId: id, workspaceId: null} } + // Pre-idle the deferred list is empty; a cached workspace→org map VALUE still proves + // `id` is an org id, keeping the fetchProject fallback off the cold-load path. + if (Object.values(readWorkspaceOrgMap()).includes(id)) { + return {orgId: id, workspaceId: null} + } + const mapped = resolveOrgId(id) if (mapped) { return {orgId: mapped, workspaceId: id} diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index 69a48995e9..2ed8980687 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -72,6 +72,7 @@ "@agenta/sdk": "workspace:../agenta-sdk", "@agenta/shared": "workspace:../agenta-shared", "@agenta/ui": "workspace:../agenta-ui", + "@tanstack/query-persist-client-core": "5.100.9", "fast-deep-equal": "^3.1.3", "jotai-scheduler": "^0.0.5", "lodash": "^4.17.23", diff --git a/web/packages/agenta-entities/src/secret/state/atoms.ts b/web/packages/agenta-entities/src/secret/state/atoms.ts index ca578f14a1..164f66cd09 100644 --- a/web/packages/agenta-entities/src/secret/state/atoms.ts +++ b/web/packages/agenta-entities/src/secret/state/atoms.ts @@ -37,6 +37,7 @@ import { llmAvailableProvidersToken, removeEmptyFromObjects, } from "@agenta/shared/utils" +import type {QueryKey} from "@tanstack/react-query" import {atom} from "jotai" import {atomWithStorage} from "jotai/utils" import {atomWithMutation, atomWithQuery} from "jotai-tanstack-query" @@ -54,6 +55,8 @@ import { type VaultMigrationStatus, } from "../core/types" +import {vaultSecretsPersister} from "./persistence" + interface CreateMutationArgs { projectId: string payload: CreateSecretDto @@ -92,8 +95,13 @@ export const providerKeySetupDoneAtom = atomWithStorage( * The query key includes `user?.id` so that switching users invalidates * the cache (a different user's secrets must not leak through React Query's * cache). + * + * Persistence (Class C): paints from a REDACTED IndexedDB projection (secret + * values replaced with a truthy sentinel — see `./persistence`) and ALWAYS + * fires one background refetch on restore, so real values only ever live in + * memory. */ -export const vaultSecretsQueryAtom = atomWithQuery((get) => { +export const vaultSecretsQueryAtom = atomWithQuery((get) => { const user = get(userAtom) // Read migration status to keep this atom subscribed to migration changes // (matches OSS behavior — migration completion can trigger a refetch). @@ -114,6 +122,7 @@ export const vaultSecretsQueryAtom = atomWithQuery((get) => { refetchOnReconnect: false, refetchOnMount: true, enabled: !!user && !!projectId, + persister: vaultSecretsPersister.persisterFn, } }) diff --git a/web/packages/agenta-entities/src/secret/state/persistence.ts b/web/packages/agenta-entities/src/secret/state/persistence.ts new file mode 100644 index 0000000000..2042aa6413 --- /dev/null +++ b/web/packages/agenta-entities/src/secret/state/persistence.ts @@ -0,0 +1,91 @@ +/** + * Vault Secrets — persistence (Class C: persist REDACTED + ALWAYS revalidate) + * + * `listSecrets` returns PLAINTEXT secret values (provider API keys, AWS + * credentials, vertex service-account JSON, named-secret content). Those must + * never reach IndexedDB, so this persister serializes a redacted projection: + * every secret-value field is replaced with a truthy sentinel, metadata + * (names, ids, kinds, models, timestamps) is kept verbatim. + * + * The sentinel keeps presence semantics (`!!secret.key`) intact so consumers + * like the agent playground's model-key badges paint correctly from disk. + * Because restored rows carry sentinels instead of real values, + * `refetchOnRestore: "always"` is mandatory — exactly one background refetch + * fires on restore regardless of age, replacing sentinels with live data. + */ + +import {idbQueryStorage, PERSIST_SCHEMA_VERSION} from "@agenta/shared/api/persist" +import type {LlmProvider} from "@agenta/shared/types" +import {experimental_createQueryPersister} from "@tanstack/query-persist-client-core" +import type {PersistedQuery} from "@tanstack/query-persist-client-core" + +import type {NamedSecretRow} from "../core/types" + +/** Truthy, obviously-not-a-key sentinel written to disk in place of secret values. */ +export const VAULT_PERSIST_REDACTED = "[redacted]" + +const VAULT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 + +/** Every `LlmProvider` field that can carry actual secret material. */ +const SECRET_VALUE_FIELDS = [ + "key", + "apiKey", + "accessKeyId", + "accessKey", + "sessionToken", + "bearerToken", + "vertexCredentials", +] as const + +/** Redact one vault row: sentinel for non-empty secret values, metadata kept. */ +export const redactVaultSecretRow = (row: LlmProvider): LlmProvider => { + const next: LlmProvider = {...row} + for (const field of SECRET_VALUE_FIELDS) { + if (next[field]) next[field] = VAULT_PERSIST_REDACTED + } + + const named = next as NamedSecretRow + if (named.content !== undefined) { + if (typeof named.content === "string") { + named.content = named.content ? VAULT_PERSIST_REDACTED : "" + } else { + // Keep keys (env-var-style names, comparable to routinely cached config); redact values. + named.content = Object.fromEntries( + Object.keys(named.content).map((key) => [key, VAULT_PERSIST_REDACTED]), + ) + } + } + + return next +} + +/** Serialize hook: redact `state.data` without mutating the live query state. */ +export const redactPersistedVaultQuery = (persisted: PersistedQuery): PersistedQuery => { + const data = persisted.state.data + if (!Array.isArray(data)) return persisted + + return { + ...persisted, + state: { + ...persisted.state, + data: (data as LlmProvider[]).map(redactVaultSecretRow), + }, + } +} + +const identity = (value: PersistedQuery) => value + +/** + * Dedicated vault persister. Differs from `catalogPersister` in two ways: + * `serialize` strips secret values, and `refetchOnRestore: "always"` forces + * one revalidation per restore even when the entry is younger than staleTime. + */ +export const vaultSecretsPersister = experimental_createQueryPersister({ + storage: idbQueryStorage, + buster: PERSIST_SCHEMA_VERSION, + maxAge: VAULT_MAX_AGE_MS, + serialize: redactPersistedVaultQuery, + deserialize: identity, + refetchOnRestore: "always", + prefix: "agenta-vault", +}) diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 38c4369f76..81683d5110 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -71,6 +71,7 @@ export { revalidateSessionRecordsAtom, fetchSessionRecordsAtom, sessionRecordsQueryKey, + type SessionRecordsFetchResult, } from "./state/records" export { sessionMountsQueryFamily, diff --git a/web/packages/agenta-entities/src/session/state/records.ts b/web/packages/agenta-entities/src/session/state/records.ts index a9c0a2e6a9..ee4e921052 100644 --- a/web/packages/agenta-entities/src/session/state/records.ts +++ b/web/packages/agenta-entities/src/session/state/records.ts @@ -4,7 +4,9 @@ * shared cache entry per session so every surface dedupes, low-priority fetch, and a revalidate * write-atom the chat fires after each finished turn (no live backend channel for records). */ +import {recordsPersister} from "@agenta/shared/api/persist" import {projectIdAtom} from "@agenta/shared/state" +import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {atom} from "jotai" import {atomFamily} from "jotai/utils" import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" @@ -19,11 +21,20 @@ export const sessionRecordsQueryKey = (projectId: string, sessionId: string) => const SESSION_RECORDS_STALE_MS = 15_000 // Single source of key + fn so atom subscribers and imperative fetches share one flight. +// Persisted to IndexedDB so a warm reload paints the transcript from disk instead of blocking +// on the (~200KB, backend-slow) records query; recordsPersister ALWAYS revalidates on restore +// — disk is never authoritative for a live, append-only log. const sessionRecordsQueryOptions = (projectId: string, sessionId: string) => ({ - queryKey: sessionRecordsQueryKey(projectId, sessionId), + // Widened to QueryKey so fetchQuery/atomWithQuery and the persister agree on one key type. + queryKey: sessionRecordsQueryKey(projectId, sessionId) as QueryKey, queryFn: ({signal}: {signal?: AbortSignal}) => querySessionRecords({sessionId, projectId, abortSignal: signal, lowPriority: true}), staleTime: SESSION_RECORDS_STALE_MS, + // persist-client-core bundles its own query-core types; the cast bridges the nominal split. + persister: recordsPersister.persisterFn as unknown as QueryPersister< + SessionRecord[] | null, + QueryKey + >, }) /** The full, ordered record event log for one session. */ @@ -38,14 +49,49 @@ export const sessionRecordsQueryFamily = atomFamily((sessionId: string) => }), ) +export interface SessionRecordsFetchResult { + records: SessionRecord[] | null + /** Present when `records` came from a disk restore / stale cache: the guaranteed background + * refetch is in flight; resolves with the fresh log (null when the refetch failed). */ + refreshed?: Promise +} + /** Imperative records fetch through the shared cache — dedupes with atom subscribers instead of - * issuing a raw parallel request. Resolves from cache within the stale window. */ + * issuing a raw parallel request. Resolves from cache within the stale window. When the result + * was restored from disk it resolves immediately (paint-fast) and `refreshed` delivers the + * revalidated log: the persister's `refetchOnRestore: "always"` fires the refetch even with no + * observers, and the chat's one-shot hydration copy needs that fresh result re-delivered. */ export const fetchSessionRecordsAtom = atom( null, - (get, _set, sessionId: string): Promise => { + async (get, _set, sessionId: string): Promise => { const projectId = get(projectIdAtom) ?? "" - if (!projectId || !sessionId) return Promise.resolve(null) - return get(queryClientAtom).fetchQuery(sessionRecordsQueryOptions(projectId, sessionId)) + if (!projectId || !sessionId) return {records: null} + const client = get(queryClientAtom) + const options = sessionRecordsQueryOptions(projectId, sessionId) + const records = await client.fetchQuery(options) + // The persister's post-restore task (a macrotask queued before fetchQuery resolved) + // rewrites dataUpdatedAt to the persisted timestamp and starts the always-revalidate + // fetch — wait for it before judging freshness, or a restore reads as fresh network data. + await new Promise((resolve) => setTimeout(resolve, 0)) + const state = client.getQueryState(options.queryKey) + const age = state?.dataUpdatedAt + ? Date.now() - state.dataUpdatedAt + : Number.POSITIVE_INFINITY + if (age <= SESSION_RECORDS_STALE_MS && state?.fetchStatus === "idle") return {records} + // Stale/restored result: join the persister's in-flight revalidation (cancelRefetch: + // false — don't restart it) or start the one refetch if it never fired. + const refreshed = client + .refetchQueries({queryKey: options.queryKey, exact: true}, {cancelRefetch: false}) + .then(() => { + const next = client.getQueryState(options.queryKey) + const nextAge = next?.dataUpdatedAt + ? Date.now() - next.dataUpdatedAt + : Number.POSITIVE_INFINITY + // Refetch failed (data is still the stale restore) → nothing fresh to deliver. + return nextAge <= SESSION_RECORDS_STALE_MS ? (next?.data ?? null) : null + }) + .catch(() => null) + return {records, refreshed} }, ) diff --git a/web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts b/web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts new file mode 100644 index 0000000000..a446f5a526 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts @@ -0,0 +1,160 @@ +import type {PersistedQuery} from "@tanstack/query-persist-client-core" +import {describe, expect, it} from "vitest" + +import type {NamedSecretRow} from "../../src/secret/core/types" +import { + VAULT_PERSIST_REDACTED, + redactPersistedVaultQuery, + redactVaultSecretRow, +} from "../../src/secret/state/persistence" + +const wrap = (data: unknown): PersistedQuery => ({ + state: { + data, + dataUpdatedAt: 1234, + dataUpdateCount: 1, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + fetchStatus: "idle", + isInvalidated: false, + status: "success", + }, + queryKey: ["vault", "secrets", "user-1", "project-1"], + queryHash: "hash", + buster: "v1", +}) + +describe("redactVaultSecretRow", () => { + it("redacts a standard provider key but keeps metadata", () => { + const row = { + title: "openai", + key: "sk-live-abc123", + name: "OPENAI_API_KEY", + id: "id-1", + type: "provider_key", + created_at: "2026-01-01", + } + const redacted = redactVaultSecretRow(row) + + expect(redacted.key).toBe(VAULT_PERSIST_REDACTED) + expect(redacted).toMatchObject({ + title: "openai", + name: "OPENAI_API_KEY", + id: "id-1", + type: "provider_key", + created_at: "2026-01-01", + }) + // Input row must not be mutated (it is live query-cache state). + expect(row.key).toBe("sk-live-abc123") + }) + + it("redacts every custom-provider credential field, keeps config metadata", () => { + const row = { + name: "my-bedrock", + id: "id-2", + type: "custom_provider", + provider: "bedrock", + apiKey: "ak-123", + apiBaseUrl: "https://bedrock.example.com", + region: "eu-west-1", + vertexProject: "proj", + vertexLocation: "us-central1", + vertexCredentials: '{"private_key":"----"}', + accessKeyId: "AKIA123", + accessKey: "aws-secret", + sessionToken: "token", + bearerToken: "bearer", + models: ["model-a"], + modelKeys: ["my-bedrock/bedrock/model-a"], + } + const redacted = redactVaultSecretRow(row) + + for (const field of [ + "apiKey", + "accessKeyId", + "accessKey", + "sessionToken", + "bearerToken", + "vertexCredentials", + ] as const) { + expect(redacted[field]).toBe(VAULT_PERSIST_REDACTED) + } + expect(redacted.apiBaseUrl).toBe("https://bedrock.example.com") + expect(redacted.region).toBe("eu-west-1") + expect(redacted.models).toEqual(["model-a"]) + expect(redacted.modelKeys).toEqual(["my-bedrock/bedrock/model-a"]) + }) + + it("leaves empty credential fields empty (presence semantics preserved)", () => { + const redacted = redactVaultSecretRow({name: "x", apiKey: "", key: undefined}) + expect(redacted.apiKey).toBe("") + expect(redacted.key).toBeUndefined() + }) + + it("redacts named-secret text content", () => { + const row: NamedSecretRow = { + name: "my-token", + format: "text", + content: "super-secret-token", + type: "custom_secret", + } + const redacted = redactVaultSecretRow(row) as NamedSecretRow + expect(redacted.content).toBe(VAULT_PERSIST_REDACTED) + }) + + it("redacts named-secret json content values but keeps keys", () => { + const row: NamedSecretRow = { + name: "my-env", + format: "json", + content: {API_KEY: "abc", REGION: "eu", RETRIES: 3}, + type: "custom_secret", + } + const redacted = redactVaultSecretRow(row) as NamedSecretRow + expect(redacted.content).toEqual({ + API_KEY: VAULT_PERSIST_REDACTED, + REGION: VAULT_PERSIST_REDACTED, + RETRIES: VAULT_PERSIST_REDACTED, + }) + // Original untouched. + expect(row.content).toEqual({API_KEY: "abc", REGION: "eu", RETRIES: 3}) + }) +}) + +describe("redactPersistedVaultQuery", () => { + it("redacts state.data rows without mutating the input state", () => { + const persisted = wrap([{name: "OPENAI_API_KEY", key: "sk-live"}]) + const redacted = redactPersistedVaultQuery(persisted) + + expect((redacted.state.data as {key: string}[])[0].key).toBe(VAULT_PERSIST_REDACTED) + expect((persisted.state.data as {key: string}[])[0].key).toBe("sk-live") + expect(redacted.state.dataUpdatedAt).toBe(1234) + expect(redacted.queryKey).toEqual(persisted.queryKey) + }) + + it("passes non-array data through untouched", () => { + const persisted = wrap(undefined) + expect(redactPersistedVaultQuery(persisted)).toBe(persisted) + }) + + it("never leaves a raw secret value anywhere in the serialized payload", () => { + const persisted = wrap([ + {name: "OPENAI_API_KEY", key: "sk-live-abc"}, + { + name: "custom", + type: "custom_provider", + apiKey: "ck-1", + accessKey: "aws-2", + vertexCredentials: "vc-3", + }, + {name: "blob", type: "custom_secret", format: "json", content: {TOKEN: "tok-4"}}, + ]) + const serialized = JSON.stringify(redactPersistedVaultQuery(persisted)) + for (const secret of ["sk-live-abc", "ck-1", "aws-2", "vc-3", "tok-4"]) { + expect(serialized).not.toContain(secret) + } + }) +}) diff --git a/web/packages/agenta-shared/src/api/persist/gc.ts b/web/packages/agenta-shared/src/api/persist/gc.ts index 2888e24374..00f90c4253 100644 --- a/web/packages/agenta-shared/src/api/persist/gc.ts +++ b/web/packages/agenta-shared/src/api/persist/gc.ts @@ -1,6 +1,6 @@ import {isPersistDebugEnabled, persistLog} from "./debug" import {idbQueryStorage} from "./idbStorage" -import {catalogPersister, immutablePersister} from "./persisters" +import {catalogPersister, immutablePersister, recordsPersister} from "./persisters" let scheduled = false @@ -18,6 +18,7 @@ export function schedulePersistedQueryGc(): void { : undefined await immutablePersister.persisterGc().catch(() => undefined) await catalogPersister.persisterGc().catch(() => undefined) + await recordsPersister.persisterGc().catch(() => undefined) if (before !== undefined) { const after = (await idbQueryStorage.entries?.())?.length ?? 0 persistLog("gc", `${before} entries → ${after} (${before - after} swept)`) diff --git a/web/packages/agenta-shared/src/api/persist/index.ts b/web/packages/agenta-shared/src/api/persist/index.ts index 063441ec3f..b7ee425ef6 100644 --- a/web/packages/agenta-shared/src/api/persist/index.ts +++ b/web/packages/agenta-shared/src/api/persist/index.ts @@ -1,4 +1,9 @@ export {PERSIST_SCHEMA_VERSION} from "./version" export {idbQueryStorage, clearPersistedQueryCache} from "./idbStorage" -export {immutablePersister, catalogPersister, type QueryPersister} from "./persisters" +export { + immutablePersister, + catalogPersister, + recordsPersister, + type QueryPersister, +} from "./persisters" export {schedulePersistedQueryGc} from "./gc" diff --git a/web/packages/agenta-shared/src/api/persist/persisters.ts b/web/packages/agenta-shared/src/api/persist/persisters.ts index 576e0462e3..16c2c93f54 100644 --- a/web/packages/agenta-shared/src/api/persist/persisters.ts +++ b/web/packages/agenta-shared/src/api/persist/persisters.ts @@ -37,4 +37,24 @@ export const catalogPersister = experimental_createQueryPersister({ + storage: idbQueryStorage, + buster: PERSIST_SCHEMA_VERSION, + maxAge: RECORDS_MAX_AGE_MS, + serialize: identity, + deserialize: identity, + refetchOnRestore: "always", + prefix: "agenta-rec", +}) + export type QueryPersister = typeof immutablePersister diff --git a/web/packages/agenta-shared/tests/unit/persist.test.ts b/web/packages/agenta-shared/tests/unit/persist.test.ts index 5814a195ac..7504e62eed 100644 --- a/web/packages/agenta-shared/tests/unit/persist.test.ts +++ b/web/packages/agenta-shared/tests/unit/persist.test.ts @@ -7,7 +7,11 @@ import type {QueryKey, QueryPersister} from "@tanstack/react-query" import {beforeEach, describe, expect, it, vi} from "vitest" import {clearPersistedQueryCache, idbQueryStorage} from "../../src/api/persist/idbStorage" -import {catalogPersister, immutablePersister} from "../../src/api/persist/persisters" +import { + catalogPersister, + immutablePersister, + recordsPersister, +} from "../../src/api/persist/persisters" import {PERSIST_SCHEMA_VERSION} from "../../src/api/persist/version" const DAY_MS = 24 * 60 * 60 * 1000 @@ -31,6 +35,7 @@ const neverFetch = () => const immStorageKey = (key: QueryKey) => `agenta-imm-${hashKey(key)}` const catStorageKey = (key: QueryKey) => `agenta-cat-${hashKey(key)}` +const recStorageKey = (key: QueryKey) => `agenta-rec-${hashKey(key)}` // persistQuery / afterRestore run on notifyManager.schedule (setTimeout 0) const flushMacrotasks = async (rounds = 3) => { @@ -271,6 +276,75 @@ describe("catalogPersister (Class B)", () => { }) }) +describe("recordsPersister (session records)", () => { + it("fetchQuery restore background-refetches even WITHOUT an observer (refetchOnRestore always)", async () => { + const key: QueryKey = ["session", "records", "proj-1", "sess-1"] + const staleLog = [{id: "r1"}] + const freshLog = [{id: "r1"}, {id: "r2"}] + await idbQueryStorage.setItem( + recStorageKey(key), + makePersisted(key, staleLog, Date.now() - 60_000), + ) + + const client = newClient() + const spy = vi.fn(async () => freshLog) + const restored = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(recordsPersister.persisterFn), + staleTime: 15_000, + }) + // paints from disk first... + expect(restored).toEqual(staleLog) + // ...then the afterRestore task fires query.fetch() despite zero observers + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(client.getQueryData(key)).toEqual(freshLog)) + }) + + it("revalidates even a restore fresher than staleTime — disk is never authoritative", async () => { + const key: QueryKey = ["session", "records", "proj-1", "sess-2"] + const diskLog = [{id: "r1"}] + await idbQueryStorage.setItem( + recStorageKey(key), + makePersisted(key, diskLog, Date.now() - 5_000), + ) + + const client = newClient() + const spy = vi.fn(async () => [{id: "r1"}, {id: "r2"}]) + const restored = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(recordsPersister.persisterFn), + staleTime: 15_000, + }) + expect(restored).toEqual(diskLog) + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)) + }) + + it("discards entries older than 7d and takes the network path", async () => { + const key: QueryKey = ["session", "records", "proj-1", "sess-3"] + const freshLog = [{id: "r-new"}] + await idbQueryStorage.setItem( + recStorageKey(key), + makePersisted(key, [{id: "r-old"}], Date.now() - 8 * DAY_MS), + ) + + const client = newClient() + const spy = vi.fn(async () => freshLog) + const result = await client.fetchQuery({ + queryKey: key, + queryFn: spy, + persister: asPersister(recordsPersister.persisterFn), + staleTime: 15_000, + }) + expect(result).toEqual(freshLog) + expect(spy).toHaveBeenCalledTimes(1) + + const stored = await waitForStored(recStorageKey(key)) + expect(stored.state.data).toEqual(freshLog) + }) +}) + describe("buster mismatch", () => { it("discards the stale entry, fetches, and re-persists with the current buster", async () => { const key: QueryKey = ["imm", "busted"] diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4bb2b22aa7..b32be1bb0b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -825,6 +825,9 @@ importers: '@phosphor-icons/react': specifier: '>=2.0.0' version: 2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/query-persist-client-core': + specifier: 5.100.9 + version: 5.100.9 '@tanstack/react-query': specifier: '>=5.0.0' version: 5.100.9(react@19.2.6) From cf348519b9c80f08b99b67f7978d2de0387bdd90 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 03:22:31 +0200 Subject: [PATCH 08/33] docs(design): record increment-4 round in playground-query-persistence plan --- .../playground-query-persistence/plan.md | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md index 4d926f1b68..bab223a0a9 100644 --- a/docs/design/playground-query-persistence/plan.md +++ b/docs/design/playground-query-persistence/plan.md @@ -319,10 +319,26 @@ EvaluatorPlaygroundHeader / MetadataSidebar / annotationSessionController null-f sites fixed (evaluatorColumns audited: NOT a defect — the table pre-seeds molecule entities). Mounts/files remains owned on a separate branch. +**Increment ④ round (commit 92a1173f3d):** vault secrets persisted as a REDACTED +projection (`/secrets/` returns plaintext key material — dedicated +`vaultSecretsPersister` stores truthy `"[redacted]"` sentinels; +`refetchOnRestore: "always"` so sentinels never linger into the provider edit +modal); session records paint-from-disk (`recordsPersister`, 7d, +`refetchOnRestore: "always"` — fires observer-less, superseding build-note #4's +caveat for persisters configured with "always"; one-shot loadSessionMessages gains +`onRefreshed` re-delivery behind the existing busy/strictly-ahead stream guards); +sidebar apps list + agent-flags gated at the sidebar source (page consumers +untouched); orgs list `enabled: neededForBoot || idle` (boot flows immediate, +workspace routes deferred). Backend smell filed for later: the secrets LIST +endpoint returning decrypted values is a server-side design issue independent of +client caching. + Still open: live browser verification (warm-reload paint without revision request; -commit → reload shows new revision; drawer catalogs instant on reopen; the jotai -"store mutation during atom read" dev warning A/B via the kill switch), then -increment ④ measurement on a prod build. +commit → reload shows new revision; drawer catalogs instant on reopen; chat history +instant paint + refresh; the jotai "store mutation during atom read" dev warning A/B +via the kill switch), prod-build measurement, optional playground-route bundle +analysis (`_app` was optimized in PR #4864; the playground route itself was never +profiled). ## Out of scope From 76084c34bacdb996d94b49a809d342dabe50baab Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 03:59:43 +0200 Subject: [PATCH 09/33] perf(frontend): evict the monolithic Fern client from _app again (-229KB parsed) The June bundle work (PR #4864) kept the monolithic AgentaApiClient (all 27 resource clients) out of _app via per-resource accessors. One line written since then regressed it: fetchSimpleApplication in workflow/api/api.ts imported getAgentaSdkClient from the @agenta/sdk root, whose barrel statically pulls the monolith - webpack concatenated 22 resource-client modules (~326KB parsed) back into _app. Measured: _app chunk 2672KB -> 2443KB parsed after re-pointing that call through a new getApplicationsClient() accessor. Prevention, since this is the second occurrence: - web/AGENTS.md Fern pattern now shows @agenta/sdk/resources accessors and names the root-barrel import as an anti-pattern (the old doc example was the template the regression copied) - packages eslint: no-restricted-imports error on the @agenta/sdk root (tests unaffected - package lint covers src/ only) - SessionInspector/api.ts (the only other root importer, async-chunk) migrated to getSessionsClient/getMountsClient accessors --- web/AGENTS.md | 27 +++++++++++-------- .../src/components/SessionInspector/api.ts | 26 +++++++++--------- .../agenta-entities/src/workflow/api/api.ts | 15 ++++++----- web/packages/agenta-sdk/src/resources.ts | 6 +++++ web/packages/eslint.config.mjs | 15 +++++++++++ 5 files changed, 57 insertions(+), 32 deletions(-) diff --git a/web/AGENTS.md b/web/AGENTS.md index 437c965ad4..8ba67d65af 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -32,7 +32,8 @@ client is the single source of truth for request/response shapes. It is regenera the backend OpenAPI spec and prevents the FE from drifting from the backend. References: -- Workspace SDK wrapper: `web/packages/agenta-sdk/src/index.ts` (`getAgentaSdkClient`) +- Per-resource accessors (the entry point to use): `web/packages/agenta-sdk/src/resources.ts` + (`getWorkflowsClient`, `getSessionsClient`, …; `@agenta/sdk/config` pins the host) - Generated client: `web/packages/agenta-api-client/` - Existing Fern-using domains: `@agenta/entities/{gatewayTool,secret,event,testset,workflow}` (PR #4425 migrated `workflow`) @@ -45,8 +46,7 @@ new package adopting Fern must add this dep first, otherwise `tsc --noEmit` fail ### Pattern ```typescript -import {getAgentaSdkClient} from "@agenta/sdk" -import {getAgentaApiUrl} from "@agenta/shared/api" +import {getSomeDomainClient} from "@agenta/sdk/resources" import {safeParseWithLogging} from "../../shared" import {someResponseSchema} from "../core" @@ -54,10 +54,9 @@ import {someResponseSchema} from "../core" export async function someApiCall({projectId, refId}: SomeParams): Promise { if (!projectId) return null - // Single source of truth for the request/response shape, kept in sync with the - // backend OpenAPI spec via Fern codegen. - const client = getAgentaSdkClient({host: getAgentaApiUrl()}) - const data = await client.someDomain.someMethod( + // Per-resource Fern client (single source of truth for the request/response + // shape, kept in sync with the backend OpenAPI spec via Fern codegen). + const data = await getSomeDomainClient().someMethod( {ref: {id: refId}}, {queryParams: {project_id: projectId}}, ) @@ -72,8 +71,12 @@ export async function someApiCall({projectId, refId}: SomeParams): Promise getAgentaSdkClient({host: getAgentaApiUrl()}) - const scope = (projectId?: string | null) => projectId ? {queryParams: {project_id: projectId}} : undefined @@ -26,7 +24,7 @@ export interface MountFileText { } export async function fetchStream(sessionId: string, projectId?: string | null) { - const res = await client().sessions.fetchSessionStream( + const res = await getSessionsClient().fetchSessionStream( {session_id: sessionId}, scope(projectId), ) @@ -34,16 +32,16 @@ export async function fetchStream(sessionId: string, projectId?: string | null) } export async function fetchRecords(sessionId: string, projectId?: string | null) { - return client().sessions.queryRecords({session_id: sessionId}, scope(projectId)) + return getSessionsClient().queryRecords({session_id: sessionId}, scope(projectId)) } export async function fetchState(sessionId: string, projectId?: string | null) { - const res = await client().sessions.getState({session_id: sessionId}, scope(projectId)) + const res = await getSessionsClient().getState({session_id: sessionId}, scope(projectId)) return res.session_state ?? null } export async function fetchMounts(sessionId: string, projectId?: string | null) { - return client().sessions.querySessionMounts({session_id: sessionId}, scope(projectId)) + return getSessionsClient().querySessionMounts({session_id: sessionId}, scope(projectId)) } // Fern has no query_agent_mount yet; migrate after client regeneration. @@ -61,7 +59,7 @@ export async function fetchMountFiles( projectId?: string | null, path?: string, ): Promise { - const data = await client().mounts.getMountFiles({mount_id: mountId, path}, scope(projectId)) + const data = await getMountsClient().getMountFiles({mount_id: mountId, path}, scope(projectId)) return data as MountFileListing } @@ -70,7 +68,7 @@ export async function fetchMountFileText( projectId: string | null | undefined, path: string, ): Promise { - const data = await client().mounts.getMountFiles( + const data = await getMountsClient().getMountFiles( {mount_id: mountId, read: path}, scope(projectId), ) @@ -91,12 +89,12 @@ export async function fetchMountFileBlob( } export async function fetchInteractions(sessionId: string, projectId?: string | null) { - return client().sessions.queryInteractions({query: {session_id: sessionId}}, scope(projectId)) + return getSessionsClient().queryInteractions({query: {session_id: sessionId}}, scope(projectId)) } /** ATTACH — steal the attached lock and watch the live turn (force, no prompt). */ export async function attachStream(sessionId: string, projectId?: string | null) { - return client().sessions.setSessionStream( + return getSessionsClient().setSessionStream( {session_id: sessionId, force: true}, scope(projectId), ) @@ -108,7 +106,7 @@ export async function detachStream( watcherId: string, projectId?: string | null, ) { - return client().sessions.detachSessionStream( + return getSessionsClient().detachSessionStream( {session_id: sessionId, watcher_id: watcherId}, scope(projectId), ) @@ -116,7 +114,7 @@ export async function detachStream( /** KILL — collapse the nest + tear the session down. */ export async function killStream(sessionId: string, projectId?: string | null) { - return client().sessions.deleteSessionStream({session_id: sessionId}, scope(projectId)) + return getSessionsClient().deleteSessionStream({session_id: sessionId}, scope(projectId)) } export async function respondInteraction( @@ -124,7 +122,7 @@ export async function respondInteraction( answer: Record, projectId?: string | null, ) { - return client().sessions.respondInteraction( + return getSessionsClient().respondInteraction( {interaction_id: interactionId, answer}, scope(projectId), ) diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index c341c2759d..8178d2d228 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -14,8 +14,11 @@ * - Create/Update: workflow + revision commit endpoints */ -import {getAgentaSdkClient} from "@agenta/sdk" -import {getLowPriorityWorkflowsClient, getWorkflowsClient} from "@agenta/sdk/resources" +import { + getApplicationsClient, + getLowPriorityWorkflowsClient, + getWorkflowsClient, +} from "@agenta/sdk/resources" import {getAgentaApiUrl, axios, lowPriorityWhenCached} from "@agenta/shared/api" import {dereferenceSchema, generateId} from "@agenta/shared/utils" import {z} from "zod" @@ -592,11 +595,9 @@ export async function fetchSimpleApplication( ): Promise { if (!projectId || !applicationId) return null - // Fern-generated client (single source of truth for the request/response - // shape). Its types under-declare the backend's `extra="allow"` - // `additional_context`, so it is read defensively below. - const client = getAgentaSdkClient({host: getAgentaApiUrl()}) - const data = await client.applications.fetchSimpleApplication( + // Per-resource Fern client — the root @agenta/sdk barrel would drag the + // monolithic AgentaApiClient (all 27 resource clients) into _app's bundle. + const data = await getApplicationsClient().fetchSimpleApplication( {application_id: applicationId}, {queryParams: {project_id: projectId}}, ) diff --git a/web/packages/agenta-sdk/src/resources.ts b/web/packages/agenta-sdk/src/resources.ts index 80c870fe6e..e4d6632565 100644 --- a/web/packages/agenta-sdk/src/resources.ts +++ b/web/packages/agenta-sdk/src/resources.ts @@ -8,6 +8,7 @@ * not all 27 resource clients. Resource clients self-normalize auth in their own * constructors, so they are equivalent to `getAgentaSdkClient().traces` etc. */ +import {ApplicationsClient} from "@agentaai/api-client/resources/applications" import {EvaluationsClient} from "@agentaai/api-client/resources/evaluations" import {EventsClient} from "@agentaai/api-client/resources/events" import {MountsClient} from "@agentaai/api-client/resources/mounts" @@ -20,6 +21,11 @@ import {WorkflowsClient} from "@agentaai/api-client/resources/workflows" import {buildClientOptions, withLowPriorityFetch} from "./config" +let _applications: ApplicationsClient | undefined +export function getApplicationsClient(): ApplicationsClient { + return (_applications ??= new ApplicationsClient(buildClientOptions())) +} + let _traces: TracesClient | undefined export function getTracesClient(): TracesClient { return (_traces ??= new TracesClient(buildClientOptions())) diff --git a/web/packages/eslint.config.mjs b/web/packages/eslint.config.mjs index 4c891dc28d..5851f3f411 100644 --- a/web/packages/eslint.config.mjs +++ b/web/packages/eslint.config.mjs @@ -55,6 +55,21 @@ const config = [ }, }, rules: { + // Root barrel statically imports the monolithic AgentaApiClient (all 27 + // resource clients, ~300KB parsed) into any eager import graph. Use the + // per-resource accessors instead. Regressed once (June fix, July relapse). + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@agenta/sdk", + message: + "Import per-resource accessors from '@agenta/sdk/resources' (or '@agenta/sdk/config' for host pinning) — the root barrel bundles all 27 Fern resource clients.", + }, + ], + }, + ], "prefer-const": "off", "no-self-assign": "off", "no-empty": "off", From 63c63ba3516800ade71db5b018542eb3bd8d96da Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 04:25:53 +0200 Subject: [PATCH 10/33] docs(design): app boot analysis - serial gate chain + un-preloaded playground chunk Two-track investigation of initial load (provider/auth chain + chunk waterfall and re-render amplifiers), with a ranked improvement plan. Headline findings: four serial null-render gates (SuperTokens init incl. an avoidable async chunk fetch; Layout chunk; protectedRouteReadyAtom which flips repeatedly and remounts the page subtree; the 10.19MB Playground chunk with zero warmup) and a render-blocking uncacheable /__env.js. Plan is analysis-only - no implementation in this commit. --- docs/design/app-boot-optimizations/plan.md | 149 +++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/design/app-boot-optimizations/plan.md diff --git a/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md new file mode 100644 index 0000000000..c378dc9d5c --- /dev/null +++ b/docs/design/app-boot-optimizations/plan.md @@ -0,0 +1,149 @@ +# App boot optimizations — collapsing the serial gate chain + +Status: ANALYSIS + PLAN (2026-07-19, not built) +Scope: initial-load time of the web app (agent playground as the reference route). +Constraints: Next.js pages router stays; no Next upgrade; no app-router migration; +SuperTokens stays. +Companion: docs/design/playground-query-persistence/plan.md (the data-layer work this +plan complements — data is now largely disk-served; boot is the remaining felt cost). + +## The boot model (what actually happens) + +The server delivers an effectively empty shell (theme pre-paint only — the inline +`_document` script sets `.dark` before paint). Everything else is client-JS, and it +runs as a **serial chain of four null-render gates**, each of which must fully release +before the next is even discovered: + +```text +HTML → [BLOCK] /__env.js (beforeInteractive, Cache-Control: no-store) + → parse _app JS (~3.56 MB uncompressed; page-route chunk 326 KB loads in parallel — fine) + → GATE 1 AuthProvider renders null + → effect: await import(frontendConfig) [CHUNK ROUND-TRIP] + → SuperTokens.init (cookie-only, no network) + → setIsInitialized → ENTIRE provider tree mounts (double render) + → GATE 2 Layout chunk (dynamic ssr:false, ~336 KB) [SEQUENTIAL CHUNK ROUND-TRIP, no preload] + → GATE 3 ProtectedRoute renders null until protectedRouteReadyAtom + (session + profile + project + org resolution; the atom FLIPS MULTIPLE + TIMES during auth resolution — each flip unmounts/remounts the whole + page subtree) + → GATE 4 Playground chunk (dynamic ssr:false, ~10.19 MB uncompressed, 37 files) + [SEQUENTIAL CHUNK ROUND-TRIP, ZERO preload/warmup anywhere] + → data gates (revision/inspect/records — now largely IndexedDB-served) +``` + +Key facts with evidence: + +- **The 10 MB Playground graph has no warmup.** It is discovered only after Gates 1–3 + release. The only prefetch in the codebase (`VariantsComponents/index.tsx:125`) + warms the 326 KB page-route chunk, not this lazy leaf. `preloadEditorPlugins` runs + *after* Playground mounts. +- **Gate 1 is self-inflicted latency**: the session check is cookie-only; the gate's + cost is an async chunk fetch (`frontendConfig`) plus an effect tick, serialized + before *everything* (state, theme, Layout, queries all wait). +- **Gate 3 is unstable**: `state/url/auth.ts` sets `protectedRouteReadyAtom` false at + 6 sites and true at 3 during boot; `ProtectedRoute` swaps `null ↔ children`, so each + early flip can unmount the page subtree mid-boot (and re-trigger the Gate-4 dynamic + mount). It also subscribes 5 boot-volatile sources directly. +- **`/__env.js`** is `beforeInteractive` + `no-store`: a render-blocking, never-cached + script fetch on every single load. +- **Healthy parts (leave alone):** playground URL rewrites use `history.replaceState` + and bypass all router events — zero `_app` churn during editing; `PreloadQueries` is + a correctly isolated warmup leaf; the theme pre-paint prevents wrong-theme flash; + `appStateSnapshotAtom` writes are signature-deduped to real navigations. + +### Re-render amplifiers (ranked by breadth × flip-count) + +1. `AppWithVariants` subscribes the whole `appStateSnapshotAtom` (`Layout.tsx:174`); + every navigation writes a fresh object (with `timestamp: Date.now()`) → + the widest subtree re-renders per navigation. Inline antd `ConfigProvider` + `theme={{algorithm…}}` object literals at `Layout.tsx:299-306`/`:332-338` compound + it (cssinjs re-eval). +2. `ThemeContext.Provider value={{…}}` is an inline object (`ThemeContextProvider.tsx:247-253`) + → every provider re-render fans out to all `useAppTheme` consumers (Layout, Sidebar, + ThemeContextBridge). `ThemeContextBridge` additionally rebuilds `{...token, isDark}` + per render. +3. Gate-3 flips (above) — mount/unmount is the most expensive re-render there is. +4. Sidebar: `memo`'d island defeated by internal subscriptions (`useRouter()`, theme + context, session/org/currentApp flips) → ~5+ full menu recomputes during boot. +5. `useSession` writes session atoms in effects; each boot flip cascades to every + session-gated consumer. +6. `currentWorkflowContextAtom` returns a fresh object per query phase (no selectAtom) + → PlaygroundRouter re-renders per transition; a `workflowKind` change remounts + Playground. +7. PlaygroundHeader/MainLayout inline `useMemo(() => atom(...))` atoms re-subscribe + when input identities change. +8. `AppGlobalWrappers` reconciles ~15 null dynamic children per router event (cheap, + bounded — low priority). + +## The plan (ranked; measure between tiers) + +### Tier 1 — collapse the serial chain (structural, low-risk, highest impact) + +**T1.1 Warm the Playground chunk immediately.** Hoist the `import()` thunk +(`const load = () => import("../Playground/Playground")`), share it with `dynamic()`, +and invoke it at PlaygroundRouter module-eval (or first idle) — the 10 MB download+parse +then runs IN PARALLEL with Gates 1–3 instead of after them. Same pattern for the Layout +chunk from `_app`. This is the single biggest structural win: it converts the two +sequential chunk round-trips into parallel work behind the auth/data gates. + +**T1.2 De-async Gate 1.** Import `frontendConfig` statically in AuthProvider (its +recipe deps — supertokens-auth-react — are already in the `_app` vendor bundle, so the +chunk split buys ~nothing) and call `SuperTokens.init` at module scope (it is +synchronous). Gate 1 then collapses to ~zero and the double mount of the entire +provider tree disappears. Verify: no SSR pitfalls (init guarded by `typeof window`), +`fromSupertokens === "needs-refresh"` path preserved. + +**T1.3 Latch Gate 3.** `protectedRouteReadyAtom`: once true, stay true for the session +(reset only on real sign-out), so mid-boot auth-resolution flips stop unmounting the +page subtree. Additionally render the page shell (skeleton) instead of `null` while +not-ready, so gate release is a fill-in rather than a mount storm. Collapse +ProtectedRoute's 5 subscriptions into one derived boolean atom. + +**T1.4 Unblock `/__env.js`.** The standalone server can inline the env payload into +`_document` at request time (it already templates HTML), removing a render-blocking, +uncacheable round-trip from every load. Fallback option: keep the script but allow a +short max-age + ETag. + +### Tier 2 — re-render hygiene (mechanical, low-risk) + +**T2.1** Memoize the four unstable identities: ThemeContext provider value, +ThemeContextBridge token object, `AgSWRConfig` config object, both nested +`ConfigProvider theme` literals in Layout. +**T2.2** Narrow `AppWithVariants`'s snapshot subscription to the slices it renders +(selectAtom with equality), and stop stamping `timestamp: Date.now()` into the +snapshot object (or exclude it from equality). +**T2.3** `currentWorkflowContextAtom` → stable-identity selector (selectAtom or +equalityFn) so query-phase transitions don't re-render PlaygroundRouter; same +treatment for ProtectedRoute's derived ready-boolean (T1.3). +**T2.4** Sidebar: replace `useRouter()` with a pathname selector; benefits from T2.1 +automatically. Hoist PlaygroundHeader's inline `useMemo(atom)` instances where inputs +churn. + +### Tier 3 — flagged, not recommended now + +- **Split the 10 MB Playground graph** (agent vs prompt branches; MainLayout statically + imports ExecutionItems/comparison views). Real but large refactor; T1.1 removes the + serialization pain first — re-measure before considering. +- **SSR/streaming shell** — excluded by constraints (pages router, ssr:false layers, + window-guarded Layout). +- **`_app` entity-cascade trim** (~450 KB) — previously attempted and reverted + (multi-root, registration-race risk). Unchanged verdict. + +## Expected effects (directional, verify by measuring) + +- T1.1: removes 10 MB of *serialized* download+parse from the critical path — in dev + (uncompressed, unminified) this is the dominant term; in prod (~gzip) still the + largest single structural win. +- T1.2: −1 chunk round-trip, −1 full-tree double mount at the very front of boot. +- T1.3: eliminates mid-boot page-subtree remounts (worst-case re-render class). +- T1.4: −1 blocking uncached request before hydration on every load. +- Tier 2: fewer/narrower re-renders during the boot window in which the main thread is + already contended with chunk parse — the wins compound with Tier 1 rather than + standing alone. + +## Measurement protocol + +Prod build (`next build && next start`), Performance panel: mark +(1) nav → first shell paint, (2) → PlaygroundLoadingShell, (3) → real config+chat. +Compare before/after per tier. In dev, the same ordering holds with larger absolute +numbers; use the same three marks. From 8580515f50688f7ccbde368c2c36537b7c72b2ae Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 04:39:39 +0200 Subject: [PATCH 11/33] perf(frontend): warm the serialized boot chunks (playground, layout, auth config) The boot analysis (docs/design/app-boot-optimizations/plan.md) found the initial load is a serial chain of null-render gates, and three lazily-split chunks were only DISCOVERED after earlier gates released - serializing their download+parse behind auth/session resolution instead of running in parallel with it: - Playground lazy graph (~3.2MB uncompressed, 63 files on the current build; an earlier stale manifest overstated this as 10MB): discovered only after the SuperTokens init gate, the Layout chunk, and the protected-route data gate. The import() thunk is now hoisted, shared by both dynamic() wrappers (webpack dedupes), and kicked at module eval - the graph downloads while the gates resolve. - Layout chunk (~336KB): same treatment from _app. - frontendConfig chunk (SuperTokens recipes): the code-split is deliberate (keeps prebuilt-UI recipe modules out of _app) and is preserved - but the chunk now warms during hydration, so AuthProvider's init await resolves from the in-flight request instead of adding a serial round-trip while the ENTIRE provider tree below it renders null. Verified no bundle regression: _app and route first-load byte-identical (modulo hash noise); all splits intact - the warmup changes when chunks download, not what is in them. Tier 1 items T1.3/T1.4 (protected-route latch, /__env.js inlining) and the Tier 2 re-render hygiene from the plan doc are intentionally not in this commit - land after this one is measured. --- web/oss/src/components/PlaygroundRouter/index.tsx | 10 ++++++++-- web/oss/src/components/pages/_app/index.tsx | 6 +++++- web/oss/src/lib/helpers/auth/AuthProvider.tsx | 14 +++++++++----- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/web/oss/src/components/PlaygroundRouter/index.tsx b/web/oss/src/components/PlaygroundRouter/index.tsx index 548adfe277..c4969a47f3 100644 --- a/web/oss/src/components/PlaygroundRouter/index.tsx +++ b/web/oss/src/components/PlaygroundRouter/index.tsx @@ -10,7 +10,13 @@ import {currentWorkflowContextAtom} from "@/oss/state/workflow" import PlaygroundLoadingShell from "./PlaygroundLoadingShell" -const Playground = dynamic(() => import("../Playground/Playground"), { +const loadPlayground = () => import("../Playground/Playground") +// Warm the playground graph immediately at module eval: without this the ~10MB chunk +// is only discovered after the auth + protected-route gates release, serializing its +// download+parse behind them instead of running in parallel. +if (typeof window !== "undefined") void loadPlayground() + +const Playground = dynamic(loadPlayground, { ssr: false, loading: PlaygroundLoadingShell, }) @@ -18,7 +24,7 @@ const Playground = dynamic(() => import("../Playground/Playground"), { // Same Playground component + chunk, but the onboarding loader (agent-forced shell + chat skeleton) as // the chunk-download fallback, so the ephemeral onboarding flow shows one continuous screen (matching // OnboardingEntry + the mint state) that morphs straight into the live panel. Webpack dedupes the chunk. -const OnboardingPlayground = dynamic(() => import("../Playground/Playground"), { +const OnboardingPlayground = dynamic(loadPlayground, { ssr: false, loading: OnboardingLoader, }) diff --git a/web/oss/src/components/pages/_app/index.tsx b/web/oss/src/components/pages/_app/index.tsx index 1b5a515870..06c2ddb9a7 100644 --- a/web/oss/src/components/pages/_app/index.tsx +++ b/web/oss/src/components/pages/_app/index.tsx @@ -42,7 +42,11 @@ const NoMobilePageWrapper = dynamic( }, ) const CustomPosthogProvider = dynamic(() => import("@/oss/lib/helpers/analytics/AgPosthogProvider")) -const Layout = dynamic(() => import("@/oss/components/Layout/Layout"), { +const loadLayout = () => import("@/oss/components/Layout/Layout") +// Warm the Layout chunk during hydration — it otherwise downloads only after the +// SuperTokens init gate releases, adding a serial round-trip before any chrome paints. +if (typeof window !== "undefined") void loadLayout() +const Layout = dynamic(loadLayout, { ssr: false, }) diff --git a/web/oss/src/lib/helpers/auth/AuthProvider.tsx b/web/oss/src/lib/helpers/auth/AuthProvider.tsx index bfa7cff972..8eaa41f120 100644 --- a/web/oss/src/lib/helpers/auth/AuthProvider.tsx +++ b/web/oss/src/lib/helpers/auth/AuthProvider.tsx @@ -8,16 +8,20 @@ import {getJWT} from "@/oss/services/api" import {AuthProviderType} from "./types" +// Lazy: `frontendConfig` statically imports the emailpassword/passwordless/ +// thirdparty recipes (the prebuilt-UI-bearing modules); the split keeps them out +// of the shared `_app` chunk. Warmed at module eval so the chunk downloads during +// hydration — the init effect's await then resolves without a serial round-trip +// (this gate blocks the ENTIRE provider tree below AuthProvider). +const loadFrontendConfig = () => import("@/oss/config/frontendConfig") +if (typeof window !== "undefined") void loadFrontendConfig() + const AuthProvider: AuthProviderType = ({children, pageProps}) => { const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { const initSuperTokens = async () => { installTurnstileFetchPatch() - // Lazy: `frontendConfig` statically imports the emailpassword/passwordless/ - // thirdparty recipes (the prebuilt-UI-bearing modules). Init already runs - // post-mount in this effect, so importing it here keeps those recipes out of - // the shared `_app` chunk. The session recipe stays eager via useSession. - const {frontendConfig} = await import("@/oss/config/frontendConfig") + const {frontendConfig} = await loadFrontendConfig() SuperTokensReact.init(frontendConfig()) // Wire the shared (`@agenta/shared/api`) axios — used by ALL // entities-package queries — with the same SuperTokens auth the OSS From 8c9df1e22b438b91ef26fc6083d906b98103b528 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 12:25:25 +0200 Subject: [PATCH 12/33] docs(design): correct playground graph size (stale-manifest 10MB -> 3.2MB), mark T1.1-T1.2 built --- docs/design/app-boot-optimizations/plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md index c378dc9d5c..566e0afedf 100644 --- a/docs/design/app-boot-optimizations/plan.md +++ b/docs/design/app-boot-optimizations/plan.md @@ -1,6 +1,6 @@ # App boot optimizations — collapsing the serial gate chain -Status: ANALYSIS + PLAN (2026-07-19, not built) +Status: ANALYSIS + PLAN (2026-07-19) — T1.1 + T1.2 BUILT (e423d17708, warmup variant for T1.2 preserving the recipe code-split); T1.3/T1.4/T2 pending measurement. Scope: initial-load time of the web app (agent playground as the reference route). Constraints: Next.js pages router stays; no Next upgrade; no app-router migration; SuperTokens stays. @@ -26,14 +26,14 @@ HTML → [BLOCK] /__env.js (beforeInteractive, Cache-Control: no-store) (session + profile + project + org resolution; the atom FLIPS MULTIPLE TIMES during auth resolution — each flip unmounts/remounts the whole page subtree) - → GATE 4 Playground chunk (dynamic ssr:false, ~10.19 MB uncompressed, 37 files) + → GATE 4 Playground chunk (dynamic ssr:false, ~3.2 MB uncompressed, 63 files (an earlier stale manifest overstated this as 10 MB)) [SEQUENTIAL CHUNK ROUND-TRIP, ZERO preload/warmup anywhere] → data gates (revision/inspect/records — now largely IndexedDB-served) ``` Key facts with evidence: -- **The 10 MB Playground graph has no warmup.** It is discovered only after Gates 1–3 +- **The Playground graph (~3.2 MB) has no warmup.** It is discovered only after Gates 1–3 release. The only prefetch in the codebase (`VariantsComponents/index.tsx:125`) warms the 326 KB page-route chunk, not this lazy leaf. `preloadEditorPlugins` runs *after* Playground mounts. @@ -81,7 +81,7 @@ Key facts with evidence: **T1.1 Warm the Playground chunk immediately.** Hoist the `import()` thunk (`const load = () => import("../Playground/Playground")`), share it with `dynamic()`, -and invoke it at PlaygroundRouter module-eval (or first idle) — the 10 MB download+parse +and invoke it at PlaygroundRouter module-eval (or first idle) — the ~3.2 MB download+parse then runs IN PARALLEL with Gates 1–3 instead of after them. Same pattern for the Layout chunk from `_app`. This is the single biggest structural win: it converts the two sequential chunk round-trips into parallel work behind the auth/data gates. @@ -121,7 +121,7 @@ churn. ### Tier 3 — flagged, not recommended now -- **Split the 10 MB Playground graph** (agent vs prompt branches; MainLayout statically +- **Split the Playground graph (~3.2 MB)** (agent vs prompt branches; MainLayout statically imports ExecutionItems/comparison views). Real but large refactor; T1.1 removes the serialization pain first — re-measure before considering. - **SSR/streaming shell** — excluded by constraints (pages router, ssr:false layers, From b7a7a4cc786e33d8a5cf5021cde8382045f70ccd Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 13:32:51 +0200 Subject: [PATCH 13/33] perf(frontend): name anonymous forwardRefs; skip tooltip wrapper on plain buttons A React DevTools profile of a playground reload showed 'ForwardRef(Anonymous)' as the single largest render line (594 renders / 222ms) - DevTools aggregates every unnamed forwardRef under one label, making the biggest cost center in the app unattributable. All seven anonymous forwardRefs now carry displayName (EnhancedButton x2, AddButton, RichChatInput, EditorInner, ColumnVisibilityHeader x2). The hunt also explained the Tooltip x173 / Trigger x231 churn in the same profile: EnhancedButton (both copies) unconditionally wrapped every button in an antd Tooltip even with empty tooltipProps, so every button render paid a Tooltip+Trigger render. The wrapper is now skipped when there is no tooltip title. The oss copy also gains the ref forwarding it declared but never applied. --- .../src/components/EnhancedUIs/Button/index.tsx | 12 ++++++++---- .../components/ColumnVisibilityHeader.tsx | 1 + web/packages/agenta-ui/src/Editor/Editor.tsx | 1 + .../components/ColumnVisibilityHeader.tsx | 1 + .../agenta-ui/src/RichChatInput/RichChatInput.tsx | 1 + .../components/presentational/EnhancedButton.tsx | 14 ++++++++------ .../presentational/buttons/AddButton.tsx | 1 + 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/web/oss/src/components/EnhancedUIs/Button/index.tsx b/web/oss/src/components/EnhancedUIs/Button/index.tsx index 9b7a533abd..eff798c295 100644 --- a/web/oss/src/components/EnhancedUIs/Button/index.tsx +++ b/web/oss/src/components/EnhancedUIs/Button/index.tsx @@ -6,12 +6,16 @@ import {EnhancedButtonProps} from "./types" const EnhancedButton = forwardRef( ({label, tooltipProps, ...props}: EnhancedButtonProps, ref) => { - return ( - - - + const button = ( + ) + // No tooltip content → skip the Tooltip/Trigger wrapper (a per-button render cost). + if (tooltipProps?.title == null || tooltipProps.title === "") return button + return {button} }, ) +EnhancedButton.displayName = "EnhancedButton" export default EnhancedButton diff --git a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx index 6bb9d61c6a..c19dc607e5 100644 --- a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx +++ b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx @@ -44,5 +44,6 @@ const ColumnVisibilityHeader = forwardRef( ) }, ) +EditorInner.displayName = "EditorInner" export const EditorProvider = ({ id: idProp, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx index 5f893a0ec4..3d27a061ba 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx @@ -41,5 +41,6 @@ const ColumnVisibilityHeader = forwardRef ) }, ) +RichChatInput.displayName = "RichChatInput" diff --git a/web/packages/agenta-ui/src/components/presentational/EnhancedButton.tsx b/web/packages/agenta-ui/src/components/presentational/EnhancedButton.tsx index ad301ba397..dec6ed91ab 100644 --- a/web/packages/agenta-ui/src/components/presentational/EnhancedButton.tsx +++ b/web/packages/agenta-ui/src/components/presentational/EnhancedButton.tsx @@ -10,14 +10,16 @@ export interface EnhancedButtonProps extends ButtonProps { const EnhancedButton = forwardRef( ({label, tooltipProps, ...props}: EnhancedButtonProps, ref) => { - return ( - - - + const button = ( + ) + // No tooltip content → skip the Tooltip/Trigger wrapper (a per-button render cost). + if (tooltipProps?.title == null || tooltipProps.title === "") return button + return {button} }, ) +EnhancedButton.displayName = "EnhancedButton" export default EnhancedButton diff --git a/web/packages/agenta-ui/src/components/presentational/buttons/AddButton.tsx b/web/packages/agenta-ui/src/components/presentational/buttons/AddButton.tsx index e51c58cc43..dc2d9ff434 100644 --- a/web/packages/agenta-ui/src/components/presentational/buttons/AddButton.tsx +++ b/web/packages/agenta-ui/src/components/presentational/buttons/AddButton.tsx @@ -47,5 +47,6 @@ const AddButton = forwardRef( ) }, ) +AddButton.displayName = "AddButton" export default AddButton From 8bf61c76bfb0f7a2e6d2a9f7aff51a2865cb0a03 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 13:40:30 +0200 Subject: [PATCH 14/33] perf(frontend): tier-2 boot re-render hygiene (profiler-confirmed amplifiers) Targets the re-render amplifiers the boot analysis predicted and a React profiler run confirmed (Context.Provider x632, Sidebar x10, PlaygroundHeader x11, LoadableComponent x121 renders during one reload): - stable identities: ThemeContext provider value + toggleAppTheme (useMemo/useCallback), ThemeContextBridge token object, SWRConfig merged config (incl. the default-parameter {} minted per render), Layout's inline ConfigProvider theme literal - widest subtree narrowed: AppWithVariants now subscribes a 3-field selectAtom slice (pathname/routeLayer/asPath) plus a string-valued baseAppURLAtom instead of the whole appStateSnapshotAtom + urlAtom (whose org/recent-app churn re-rendered the entire content layout); the snapshot's timestamp field is dropped entirely - grep showed zero consumers, so every write no longer guarantees an identity change - currentWorkflowContextAtom is equality-stable (previous-value reuse when all 6 fields match) - PlaygroundRouter and ~15 other consumers stop re-rendering on query-phase transitions; workflowKind changes still propagate - ProtectedRoute subscribes only protectedRouteReadyAtom; its five side-effectful boot hooks (session/profile/project mounts) moved to a memo'd null-rendering BootSubscriptions island - same effects, gate re-renders only on the boolean flip; no latching (that is T1.3) - Sidebar drops useRouter() for a string-valued appAsPathAtom; PlaygroundHeader's churning-input inline atoms hoisted to module level (selectedChildrenByParent, rootRevisionVersion, notAgent fallback) Per-file oss tsc error counts identical to pre-change baseline. --- web/oss/src/ThemeContextBridge.tsx | 8 +++- web/oss/src/components/Layout/Layout.tsx | 40 +++++++++++++------ .../Layout/ThemeContextProvider.tsx | 29 ++++++++++---- .../ProtectedRoute/ProtectedRoute.tsx | 18 +++++++-- web/oss/src/components/Sidebar/Sidebar.tsx | 9 +++-- web/oss/src/lib/api/SWRConfig.tsx | 9 ++++- web/oss/src/state/appState/atoms.ts | 13 +++--- web/oss/src/state/appState/index.ts | 1 + web/oss/src/state/appState/types.ts | 5 +-- .../src/state/workflow/selectors/workflow.ts | 25 +++++++++++- 10 files changed, 113 insertions(+), 44 deletions(-) diff --git a/web/oss/src/ThemeContextBridge.tsx b/web/oss/src/ThemeContextBridge.tsx index c5e2afd26d..920d287606 100644 --- a/web/oss/src/ThemeContextBridge.tsx +++ b/web/oss/src/ThemeContextBridge.tsx @@ -1,4 +1,4 @@ -import {ReactNode} from "react" +import {ReactNode, useMemo} from "react" import {theme as antdTheme} from "antd" import {ThemeProvider} from "react-jss" @@ -10,8 +10,12 @@ import {useAppTheme} from "@/oss/components/Layout/ThemeContextProvider" const ThemeContextBridge = ({children}: {children: ReactNode}) => { const {appTheme} = useAppTheme() const {token} = antdTheme.useToken() + const isDark = appTheme === "dark" - return {children} + // antd's useToken returns a per-theme-cached token object, so this only changes on theme swaps + const jssTheme = useMemo(() => ({...token, isDark}), [token, isDark]) + + return {children} } export default ThemeContextBridge diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 2c1956f7d9..97e45fce10 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -5,12 +5,12 @@ import clsx from "clsx" import {atom} from "jotai" import {useAtom, useAtomValue, useSetAtom, useStore} from "jotai" import {selectAtom} from "jotai/utils" +import {eagerAtom} from "jotai-eager" import {useRouter} from "next/router" import {ErrorBoundary} from "react-error-boundary" -import useURL from "@/oss/hooks/useURL" import {currentAppAtom} from "@/oss/state/app" -import {appStateSnapshotAtom, requestNavigationAtom, useAppState} from "@/oss/state/appState" +import {appStateSnapshotAtom, requestNavigationAtom} from "@/oss/state/appState" import {cacheWorkspaceOrgPair} from "@/oss/state/org/selectors/org" import {getProjectValues, useProjectData} from "@/oss/state/project" import { @@ -19,6 +19,7 @@ import { demoReturnHintPendingAtom, lastNonDemoProjectAtom, } from "@/oss/state/project/selectors/project" +import {urlAtom} from "@/oss/state/url" import CustomWorkflowBanner from "../CustomWorkflow/CustomWorkflowBanner" import ProtectedRoute from "../ProtectedRoute/ProtectedRoute" @@ -152,6 +153,20 @@ const useCommittedLayoutFlags = (): LayoutRouteFlags => { return committedFlags } +// Narrow slice of the app-state snapshot: exactly what AppWithVariants renders from +const appRouteSliceAtom = selectAtom( + appStateSnapshotAtom, + (snapshot) => ({ + pathname: snapshot.pathname, + asPath: snapshot.asPath, + routeLayer: snapshot.routeLayer, + }), + (a, b) => a.pathname === b.pathname && a.asPath === b.asPath && a.routeLayer === b.routeLayer, +) + +// String-valued so org/app churn inside urlAtom doesn't re-render AppWithVariants +const baseAppURLAtom = eagerAtom((get) => get(urlAtom).baseAppURL) + type StyleClasses = ReturnType const {Content} = Layout @@ -180,8 +195,8 @@ const AppWithVariants = memo( appTheme: string isPlayground?: boolean }) => { - const {baseAppURL} = useURL() - const appState = useAppState() + const baseAppURL = useAtomValue(baseAppURLAtom) + const appState = useAtomValue(appRouteSliceAtom) const isAnnotations = appState.pathname.includes("/annotations") const lastBasePathRef = useRef(null) const lastNonSettingsPathRef = useRef(null) @@ -244,6 +259,14 @@ const AppWithVariants = memo( setDemoReturnHintDismissed(true) }, [setDemoReturnHintDismissed]) + // Stable theme object so antd cssinjs doesn't re-evaluate per parent render + const contentThemeConfig = useMemo( + () => ({ + algorithm: appTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm, + }), + [appTheme], + ) + const handleBackToWorkspaceSwitch = useCallback(() => { if (!lastNonDemoProject?.workspaceId || !lastNonDemoProject?.projectId) { navigate({type: "href", href: "/w", method: "push"}) @@ -343,14 +366,7 @@ const AppWithVariants = memo( )} > - +
= ({children}) => { } }, [isDark]) + const toggleAppTheme = useCallback( + (themeType: ThemeModeType) => setThemeMode(themeType as ThemeMode), + [setThemeMode], + ) + + // Stable value so useAppTheme consumers only re-render on actual theme changes + const contextValue = useMemo( + () => ({appTheme, toggleAppTheme, themeMode}), + [appTheme, toggleAppTheme, themeMode], + ) + return ( - setThemeMode(themeType as ThemeMode), - themeMode, - }} - > + {children} ) diff --git a/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx b/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx index c596fd49fe..a517a1fced 100644 --- a/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -1,4 +1,4 @@ -import {type FC, type PropsWithChildren} from "react" +import {memo, type FC, type PropsWithChildren} from "react" import {useAtomValue} from "jotai" @@ -8,15 +8,27 @@ import {useProfileData} from "@/oss/state/profile" import {useProjectData} from "@/oss/state/project" import {protectedRouteReadyAtom} from "@/oss/state/url/test" -const ProtectedRoute: FC = ({children}) => { +// Null-rendering island for the boot-volatile session/project/profile/org hook mounts: +// their effects and query subscriptions must stay alive here, but their re-renders +// must not drag the page subtree along. +const BootSubscriptions = memo(function BootSubscriptions() { useSession() useProjectData() useProfileData() useAtomValue(selectedOrgAtom) useAtomValue(selectedOrgQueryAtom) + return null +}) + +const ProtectedRoute: FC = ({children}) => { const ready = useAtomValue(protectedRouteReadyAtom) - return ready ? children : null + return ( + <> + + {ready ? children : null} + + ) } export default ProtectedRoute diff --git a/web/oss/src/components/Sidebar/Sidebar.tsx b/web/oss/src/components/Sidebar/Sidebar.tsx index 9f6cf84611..89c31d2013 100644 --- a/web/oss/src/components/Sidebar/Sidebar.tsx +++ b/web/oss/src/components/Sidebar/Sidebar.tsx @@ -1,7 +1,6 @@ import {memo, useCallback, useEffect, useMemo} from "react" -import {useSetAtom} from "jotai" -import {useRouter} from "next/router" +import {useAtomValue, useSetAtom} from "jotai" import { clearSidebarPopupGroupsAtom, @@ -9,6 +8,7 @@ import { sidebarCollapsedAtom, sidebarOpenGroupsAtomFamily, } from "@/oss/lib/atoms/sidebar" +import {appAsPathAtom} from "@/oss/state/appState" import {useAppTheme} from "../Layout/ThemeContextProvider" @@ -18,7 +18,8 @@ import type {SidebarView} from "./types" const Sidebar: React.FC<{view: SidebarView}> = ({view}) => { const {appTheme} = useAppTheme() - const router = useRouter() + // Narrow asPath subscription: useRouter() re-renders on every route event + const currentPath = useAtomValue(appAsPathAtom) const setSidebarPopupGroupOpen = useSetAtom(setSidebarPopupGroupOpenAtom) const clearSidebarPopupGroups = useSetAtom(clearSidebarPopupGroupsAtom) const scope = useMemo( @@ -43,7 +44,7 @@ const Sidebar: React.FC<{view: SidebarView}> = ({view}) => { { - const mergedConfig = {...config, ...passedConfig} +const EMPTY_CONFIG: SWRConfiguration = {} + +const AgSWRConfig = ({children, config: passedConfig = EMPTY_CONFIG}: AgSWRConfigProps) => { + // Stable identity so SWR's context consumers don't re-render per provider render + const mergedConfig = useMemo(() => ({...config, ...passedConfig}), [passedConfig]) return {children} } diff --git a/web/oss/src/state/appState/atoms.ts b/web/oss/src/state/appState/atoms.ts index 3d173341c2..bd9c2f6697 100644 --- a/web/oss/src/state/appState/atoms.ts +++ b/web/oss/src/state/appState/atoms.ts @@ -13,10 +13,7 @@ import type { const initialParsedLocation = createInitialParsedLocation() -const initialSnapshot: AppStateSnapshot = { - ...initialParsedLocation, - timestamp: Date.now(), -} +const initialSnapshot: AppStateSnapshot = initialParsedLocation // Eagerly initialize projectIdAtom and sessionAtom from the URL-parsed initial // location. Both entity-package queries and oss queries gate on these atoms, @@ -49,10 +46,7 @@ if (initialParsedLocation.projectId) { export const appStateSnapshotAtom = atom(initialSnapshot) export const setLocationAtom = atom(null, (_get, set, location: ParsedAppLocation) => { - set(appStateSnapshotAtom, { - ...location, - timestamp: Date.now(), - }) + set(appStateSnapshotAtom, location) // Sync projectId to shared atom so entity packages can read it set(setSharedProjectIdAtom, location.projectId ?? null) // Keep sessionAtom in sync only on a true project route (path-param @@ -76,6 +70,9 @@ export const appIdentifiersAtom = atom((get) => { export const routeLayerAtom = atom((get) => get(appStateSnapshotAtom).routeLayer) +// String-valued route selector: subscribers re-render only when asPath changes +export const appAsPathAtom = atom((get) => get(appStateSnapshotAtom).asPath) + export const navigationRequestAtom = atom(null) export const requestNavigationAtom = atom( diff --git a/web/oss/src/state/appState/index.ts b/web/oss/src/state/appState/index.ts index d56046a767..a119953a35 100644 --- a/web/oss/src/state/appState/index.ts +++ b/web/oss/src/state/appState/index.ts @@ -14,6 +14,7 @@ export {parseRouterState} from "./parse" export { appStateSnapshotAtom, appIdentifiersAtom, + appAsPathAtom, routeLayerAtom, setLocationAtom, navigationRequestAtom, diff --git a/web/oss/src/state/appState/types.ts b/web/oss/src/state/appState/types.ts index 527d624e02..89b41dce43 100644 --- a/web/oss/src/state/appState/types.ts +++ b/web/oss/src/state/appState/types.ts @@ -20,9 +20,8 @@ export interface ParsedAppLocation extends AppIdentifiers { restPath: string[] } -export interface AppStateSnapshot extends ParsedAppLocation { - timestamp: number -} +// No timestamp field: stamping Date.now() forced a new identity on every write +export type AppStateSnapshot = ParsedAppLocation export type NavigationMethod = "push" | "replace" diff --git a/web/oss/src/state/workflow/selectors/workflow.ts b/web/oss/src/state/workflow/selectors/workflow.ts index 92446f9948..6b134bff59 100644 --- a/web/oss/src/state/workflow/selectors/workflow.ts +++ b/web/oss/src/state/workflow/selectors/workflow.ts @@ -5,7 +5,7 @@ import { type Workflow, type WorkflowFlags, } from "@agenta/entities/workflow" -import {atom} from "jotai" +import {atom, type Getter} from "jotai" import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" @@ -84,7 +84,7 @@ export interface CurrentWorkflowContext { isError: boolean } -export const currentWorkflowContextAtom = atom((get) => { +const computeCurrentWorkflowContext = (get: Getter): CurrentWorkflowContext => { const id = get(routerAppIdAtom) if (!id) { @@ -148,6 +148,27 @@ export const currentWorkflowContextAtom = atom((get) => isNotFound: false, isError: false, } +} + +const workflowContextEquals = (a: CurrentWorkflowContext, b: CurrentWorkflowContext): boolean => + a.workflow === b.workflow && + a.workflowId === b.workflowId && + a.workflowKind === b.workflowKind && + a.isResolving === b.isResolving && + a.isNotFound === b.isNotFound && + a.isError === b.isError + +let previousWorkflowContext: CurrentWorkflowContext | null = null + +// Reuse the previous object when all fields are unchanged, so query-phase identity +// churn in the underlying detail query doesn't re-render subscribers. +export const currentWorkflowContextAtom = atom((get) => { + const next = computeCurrentWorkflowContext(get) + if (previousWorkflowContext && workflowContextEquals(previousWorkflowContext, next)) { + return previousWorkflowContext + } + previousWorkflowContext = next + return next }) /** From 229e9526fb167a3f1098a884c6fb8dc8b6f54e68 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 13:40:59 +0200 Subject: [PATCH 15/33] docs(design): mark tier-2 + profiler extras built in app-boot plan --- docs/design/app-boot-optimizations/plan.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md index 566e0afedf..578deac1b8 100644 --- a/docs/design/app-boot-optimizations/plan.md +++ b/docs/design/app-boot-optimizations/plan.md @@ -1,6 +1,14 @@ # App boot optimizations — collapsing the serial gate chain -Status: ANALYSIS + PLAN (2026-07-19) — T1.1 + T1.2 BUILT (e423d17708, warmup variant for T1.2 preserving the recipe code-split); T1.3/T1.4/T2 pending measurement. +Status: ANALYSIS + PLAN (2026-07-19) — T1.1 + T1.2 BUILT (e423d17708, warmup variant +for T1.2 preserving the recipe code-split). T2 BUILT (33e06521eb — all four items; +ProtectedRoute deviation: its five hooks are side-effectful mounts, moved to a memo'd +null island rather than an atom; snapshot `timestamp` dropped entirely, zero consumers). +Profiler-driven extras (25f527a016): seven anonymous forwardRefs named — the profile's +top line "ForwardRef(Anonymous)" ×594 was their aggregate, dominated by EnhancedButton — +and EnhancedButton no longer wraps tooltip-less buttons in Tooltip/Trigger (the +Tooltip ×173 / Trigger ×231 churn). T1.3/T1.4 pending; profiler re-run pending +(expect Context.Provider/Sidebar/PlaygroundHeader render counts to drop). Scope: initial-load time of the web app (agent playground as the reference route). Constraints: Next.js pages router stays; no Next upgrade; no app-router migration; SuperTokens stays. From ca0449ff8f10eef778c5378f0287611abbee034b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 17:39:33 +0200 Subject: [PATCH 16/33] perf(frontend): latch the protected-route gate, boot shell, prewarm boot atom graph (T1.3) protectedRouteReadyAtom flipped false at six sites during boot auth resolution, and ProtectedRoute swapped null <-> children on it - so workspace/org switches and mid-boot re-syncs unmounted and remounted the entire page subtree (the profiler's mount-storm commits). Now: - ready = raw || (latch && sessionExists): the latch turns sticky on first true, absorbing boot-time churn; the live-session term makes it impossible for a dead session to keep a page visible. Sign-out paths clear both the raw flag and the latch. Five auth scenarios traced (cold logged-in, cold logged-out redirect - children never mount, sign-out, UNAUTHORISED mid-use, workspace switch) - pre-ready renders a BootShell (sidebar-rail ghost + empty content pane, collapse-state aware) instead of null, so gate release fills in rather than mount-storming; the auth page keeps a blank shell - the ~157ms first evaluation of the boot query-atom graph (query atom construction, persister wiring, observer creation - profiler-attributed to ProtectedRoute's first read) moves off the 322ms provider-mount commit: prewarmBootQueryGraph() runs at the start of AuthProvider's init effect, overlapping the frontendConfig chunk fetch. Safe pre-auth: the warmed queries are enabled-gated on a session atom that is provably false until SessionListener mounts --- web/oss/src/components/Layout/Layout.tsx | 2 +- .../ProtectedRoute/ProtectedRoute.tsx | 32 ++++++++++++++++--- web/oss/src/lib/helpers/auth/AuthProvider.tsx | 3 ++ .../src/state/boot/prewarmBootQueryGraph.ts | 27 ++++++++++++++++ web/oss/src/state/url/auth.ts | 20 +++++++++++- web/oss/src/state/url/test.ts | 2 +- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 web/oss/src/state/boot/prewarmBootQueryGraph.ts diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 97e45fce10..034e6fd16e 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -409,7 +409,7 @@ const App: React.FC = ({children}) => { ) : ( - + = ({children}) => { - const ready = useAtomValue(protectedRouteReadyAtom) +// First-boot placeholder so gate release is a fill-in, not a mount from blank +const BootShell = memo(function BootShell({shell}: {shell: "app" | "blank"}) { + const collapsed = useAtomValue(sidebarCollapsedAtom) + + if (shell === "blank") return
+ + return ( +
+
+
+
+ ) +}) + +const ProtectedRoute: FC> = ({ + children, + shell = "blank", +}) => { + const ready = useAtomValue(protectedRouteLatchedReadyAtom) return ( <> - {ready ? children : null} + {ready ? children : } ) } diff --git a/web/oss/src/lib/helpers/auth/AuthProvider.tsx b/web/oss/src/lib/helpers/auth/AuthProvider.tsx index 8eaa41f120..49cc8ff9df 100644 --- a/web/oss/src/lib/helpers/auth/AuthProvider.tsx +++ b/web/oss/src/lib/helpers/auth/AuthProvider.tsx @@ -5,6 +5,7 @@ import SuperTokensReact, {SuperTokensWrapper} from "supertokens-auth-react" import {installTurnstileFetchPatch} from "@/oss/lib/helpers/auth/turnstile" import {getJWT} from "@/oss/services/api" +import {prewarmBootQueryGraph} from "@/oss/state/boot/prewarmBootQueryGraph" import {AuthProviderType} from "./types" @@ -20,6 +21,8 @@ const AuthProvider: AuthProviderType = ({children, pageProps}) => { const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { const initSuperTokens = async () => { + // Overlaps the boot atom graph's first evaluation with the config chunk fetch + prewarmBootQueryGraph() installTurnstileFetchPatch() const {frontendConfig} = await loadFrontendConfig() SuperTokensReact.init(frontendConfig()) diff --git a/web/oss/src/state/boot/prewarmBootQueryGraph.ts b/web/oss/src/state/boot/prewarmBootQueryGraph.ts new file mode 100644 index 0000000000..1fa1587c4c --- /dev/null +++ b/web/oss/src/state/boot/prewarmBootQueryGraph.ts @@ -0,0 +1,27 @@ +import {queryClient} from "@agenta/shared/api" +import {getDefaultStore} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" + +import {selectedOrgQueryAtom} from "@/oss/state/org/selectors/org" +import {profileQueryAtom} from "@/oss/state/profile/selectors/user" +import {projectsQueryAtom} from "@/oss/state/project/selectors/project" +import {protectedRouteLatchedReadyAtom} from "@/oss/state/url/auth" + +const noop = () => undefined + +let warmed = false + +// First evaluation of the boot atom graph off the big provider-mount commit. Safe +// pre-auth: every query atom is enabled-gated on sessionExistsAtom (still false here). +export const prewarmBootQueryGraph = () => { + if (warmed || typeof window === "undefined") return + warmed = true + + const store = getDefaultStore() + // Same singleton HydrateAtoms hydrates later; needed now so observers bind to it + store.set(queryClientAtom, queryClient) + store.sub(profileQueryAtom, noop) + store.sub(projectsQueryAtom, noop) + store.sub(selectedOrgQueryAtom, noop) + store.sub(protectedRouteLatchedReadyAtom, noop) +} diff --git a/web/oss/src/state/url/auth.ts b/web/oss/src/state/url/auth.ts index 2174bdb2db..c687ada1b4 100644 --- a/web/oss/src/state/url/auth.ts +++ b/web/oss/src/state/url/auth.ts @@ -28,7 +28,22 @@ const INVITE_STORAGE_KEY = "invite" const POST_SIGNUP_PENDING_KEY = "postSignupPending" const POST_SIGNUP_TTL_MS = 2 * 60 * 1000 -export const protectedRouteReadyAtom = atom(false) +const baseProtectedRouteReadyAtom = atom(false) +// Sticky once-ready flag so mid-session auth re-resolution flips don't unmount the page (T1.3) +const protectedRouteReadyLatchAtom = atom(false) +export const protectedRouteReadyAtom = atom( + (get) => get(baseProtectedRouteReadyAtom), + (_get, set, next: boolean) => { + set(baseProtectedRouteReadyAtom, next) + if (next) set(protectedRouteReadyLatchAtom, true) + }, +) +// Gate ProtectedRoute reads: raw readiness, or a still-live session that was ready before +export const protectedRouteLatchedReadyAtom = atom( + (get) => + get(baseProtectedRouteReadyAtom) || + (get(protectedRouteReadyLatchAtom) && get(sessionExistsAtom)), +) export const activeInviteAtom = atom(null) export const parseInviteFromUrl = (url: URL): InvitePayload | null => { @@ -381,8 +396,10 @@ export const syncAuthStateFromUrl = (nextUrl?: string) => { return } + // Signed out: drop the latch so a dead session can never keep the page visible if (isAuthRoute) { store.set(protectedRouteReadyAtom, true) + store.set(protectedRouteReadyLatchAtom, false) return } @@ -393,6 +410,7 @@ export const syncAuthStateFromUrl = (nextUrl?: string) => { }) } store.set(protectedRouteReadyAtom, false) + store.set(protectedRouteReadyLatchAtom, false) } catch (err) { console.error("Failed to sync auth state from URL:", nextUrl, err) } diff --git a/web/oss/src/state/url/test.ts b/web/oss/src/state/url/test.ts index cebb1640b0..21ca711100 100644 --- a/web/oss/src/state/url/test.ts +++ b/web/oss/src/state/url/test.ts @@ -53,7 +53,7 @@ const syncVariantStateFromUrl = makeLazyUrlSync(() => import("./variant").then((m) => m.syncVariantStateFromUrl), ) -export {activeInviteAtom, protectedRouteReadyAtom} from "./auth" +export {activeInviteAtom, protectedRouteLatchedReadyAtom, protectedRouteReadyAtom} from "./auth" export {clearSessionParamAtom, clearSessionQueryParam, sessionIdAtom} from "./session" export {testcaseIdAtom, clearTestcaseQueryParam, clearTestcaseParamAtom} from "./testcase" export {clearTraceParamAtom, clearTraceQueryParam, traceIdAtom} from "./trace" From bed6d66139307768700d111d4c9cb4560c6503b7 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 17:39:33 +0200 Subject: [PATCH 17/33] perf(frontend): config-accordion render hygiene (profiler-driven) The agent config accordion subtree rendered its anonymous forwardRef children ~211 times during one playground load (AgentTemplateControl x6 at 77ms self, Memo(PlaygroundConfigSection) x15). Same taxonomy as the tier-2 sweep, applied to the config panel: - PlaygroundConfigSection molecule adapter atoms (data/serverData/ schemaQuery) wrapped equality-stable (deepEqual / field-wise) - upstream selectors mint fresh objects on every query fetch-state flip during boot - PlaygroundVariantConfig narrows its subscriptions (isPending flag and uri/flags fields via selectAtom instead of whole query/data objects); stable element/empty-object constants so memo children stop re-rendering - DrillInUIContext value stabilized: OSSdrillInUIProvider memoizes the components object (annotated via cast - the context's index-signature slots vs rich concrete components is a pre-existing type gap, and the two pre-existing whole-object tsc errors at HEAD are now resolved); useLLMProviderConfig memoizes footerContent/drawer/config; deployment policy object gets a stable identity - MoleculeDrillInContext: fields fallback no longer mints a fresh dep per render; TriggerManagementSection + useModelHarness subscription hygiene; AddTextLink et al follow the displayName convention Verification: entity-ui/playground-ui/playground builds + lint green, oss tsc zero errors on all touched files (2 better than baseline), entities 909/909. The accordion agent was cut off by a session limit before its verification pass; gates were re-run and two type issues fixed during integration --- .../DrillInView/OSSdrillInUIProvider.tsx | 51 +++++++----- .../PlaygroundVariantConfig/index.tsx | 56 ++++++++++--- web/oss/src/hooks/useLLMProviderConfig.tsx | 58 +++++++------ .../SchemaControls/AddTextLink.tsx | 2 + .../SchemaControls/AgentTemplateControl.tsx | 6 +- .../TriggerManagementSection.tsx | 33 ++++++-- .../agentTemplate/useModelHarness.tsx | 18 ++--- .../components/MoleculeDrillInContext.tsx | 6 +- .../components/PlaygroundConfigSection.tsx | 81 ++++++++++++------- 9 files changed, 203 insertions(+), 108 deletions(-) diff --git a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx index 69a7a3a30d..474fd4a1fc 100644 --- a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx +++ b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx @@ -52,6 +52,7 @@ import { } from "@agenta/entities/workflow" import { DrillInUIProvider, + type DrillInUIComponents, type GatewayToolsBridge, type WorkflowConfigPart, type WorkflowConfigPayload, @@ -678,25 +679,29 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) { const {llmProviderConfig, overlay: llmProviderOverlay} = useLLMProviderConfig() const toolsEnabled = isToolsEnabled() const workflowReference = useWorkflowReferenceBridge() - // Deployment policy, never changes at runtime — not memoized. Gates the Provider credentials - // section's "Use subscription" toggle (design.md D6, docs/design/connect-model-drawer). - const deployment = {isCloud: isDemo()} + // Deployment policy never changes at runtime; a stable identity keeps the context value stable. + const deployment = useMemo(() => ({isCloud: isDemo()}), []) + + // Stable context value: every DrillInUIContext consumer re-renders when this identity changes. + const baseComponents = useMemo( + () => + ({ + llmProviderConfig, + EditorProvider, + SharedEditor, + workflowReference, + openTrace, + deployment, + // Rich concrete components vs the context's index-signature slots (pre-existing gap) + }) as DrillInUIComponents, + // openTrace is a module-level const (stable) — no dep needed. + [llmProviderConfig, workflowReference, deployment], + ) if (!toolsEnabled) { return ( <> - - {children} - + {children} {llmProviderOverlay} ) @@ -773,9 +778,10 @@ function GatewayToolsEnabledProvider({ [connections, isLoading, error, setCatalogDrawerOpen], ) - return ( - + ({ llmProviderConfig, EditorProvider, SharedEditor, @@ -783,11 +789,12 @@ function GatewayToolsEnabledProvider({ workflowReference, openTrace, deployment, - }} - > - {children} - + // Rich concrete components vs the context's index-signature slots (pre-existing gap) + }) as DrillInUIComponents, + [llmProviderConfig, gatewayTools, workflowReference, deployment], ) + + return {children} } export default OSSdrillInUIProvider diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index 2eb9970883..50dbd3b063 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -23,6 +23,7 @@ import {hasPendingHydrationAtomFamily, isAgentModeAtomFamily} from "@agenta/play import {Select} from "antd" import clsx from "clsx" import {atom, useAtomValue, useSetAtom} from "jotai" +import {selectAtom} from "jotai/utils" import dynamic from "next/dynamic" import {extractJsonPaths, safeParseJson} from "@/oss/lib/helpers/extractJsonPaths" @@ -46,6 +47,12 @@ const StorageFilesHeader = dynamic(() => import("@/oss/components/Drives/Storage // Stable empty catalog read for non-evaluator workflows (avoids the templates fetch). const EMPTY_TEMPLATES_DATA_ATOM = atom([]) +// Stable element: an inline `` prop would defeat memo(PlaygroundConfigSection). +const AGENT_CONFIG_SKELETON = + +// Stable empty detection value for non-evaluator workflows (context value identity). +const EMPTY_FIELDS_DETECTION = {} + /** * PlaygroundVariantConfig manages the configuration interface for a single variant. * All entity types (including ephemeral workflows from traces) go through PlaygroundConfigSection. @@ -82,17 +89,41 @@ const PlaygroundVariantConfig: React.FC< // prompt chrome (view switcher) would flash for an agent. Treat as agent-header mode when it's an // agent, the early app-id signal says agent, OR agent-ness is still unknown (variant not settled). const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + // Narrow to the pending flag: the full query object churns identity on every fetch-state flip. const variantQueryPending = useAtomValue( - useMemo(() => workflowMolecule.selectors.query(variantId || ""), [variantId]), - ).isPending + useMemo( + () => selectAtom(workflowMolecule.selectors.query(variantId || ""), (q) => q.isPending), + [variantId], + ), + ) const isAgentHeaderMode = isAgent || earlyAgentState === "agent" || variantQueryPending // Refine prompt modal state const [refineModalOpen, setRefineModalOpen] = useState(false) const [refinePromptKey, setRefinePromptKey] = useState(null) - // Get workflow data for evaluator detection - const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId)) + // Narrow reads for evaluator detection — a whole-data subscription re-renders this + // component (and its section tree) on every boot-time data resolution. + const workflowUri = useAtomValue( + useMemo( + () => + selectAtom( + workflowMolecule.selectors.data(variantId), + (d) => d?.data?.uri as string | undefined, + ), + [variantId], + ), + ) + const workflowParams = useAtomValue( + useMemo( + () => + selectAtom( + workflowMolecule.selectors.data(variantId), + (d) => d?.data?.parameters as Record | undefined, + ), + [variantId], + ), + ) const isEvaluator = useAtomValue(workflowMolecule.selectors.isEvaluator(variantId)) const dispatchUpdate = useSetAtom(workflowMolecule.actions.updateConfiguration) @@ -107,10 +138,9 @@ const PlaygroundVariantConfig: React.FC< // which checks `entity.flags.is_evaluator`). const evaluatorKey = useMemo(() => { if (!isEvaluator) return null - const uri = runnableData?.data?.uri as string | undefined - if (!uri || !uri.startsWith("agenta:builtin:")) return null - return parseEvaluatorKeyFromUri(uri) - }, [isEvaluator, runnableData?.data?.uri]) + if (!workflowUri || !workflowUri.startsWith("agenta:builtin:")) return null + return parseEvaluatorKeyFromUri(workflowUri) + }, [isEvaluator, workflowUri]) // Read the evaluator template catalog only for evaluator workflows — apps // never use it, and an unconditional read fetches GET /evaluators/catalog/ @@ -176,7 +206,7 @@ const PlaygroundVariantConfig: React.FC< // Fields detection callback for JSON Multi-Field Match evaluator. // Reads the first testcase row and extracts JSON paths from the correct_answer field. const fieldsDetectionValue = useMemo(() => { - if (!evaluatorKey) return {} + if (!evaluatorKey) return EMPTY_FIELDS_DETECTION return { hasTestcaseData, detectFieldsFromTestcase: (): string[] | null => { @@ -186,7 +216,7 @@ const PlaygroundVariantConfig: React.FC< if (!firstTestcase?.data) return null // Read correct_answer_key from the evaluator config - const params = runnableData?.data?.parameters as Record | undefined + const params = workflowParams const correctAnswerKey = (params?.correct_answer_key as string) ?? ((params?.advanced_config as Record) @@ -205,7 +235,7 @@ const PlaygroundVariantConfig: React.FC< return extractJsonPaths(parsed) }, } - }, [evaluatorKey, hasTestcaseData, runnableData?.data?.parameters]) + }, [evaluatorKey, hasTestcaseData, workflowParams]) // View mode for config section (form/json/yaml) // When controlled externally (e.g. from the drawer), use the provided props. @@ -243,7 +273,7 @@ const PlaygroundVariantConfig: React.FC< embedded={embedded} variantNameOverride={variantNameOverride} revisionOverride={revisionOverride} - evaluatorLabel={evaluatorInfo?.label} + evaluatorLabel={evaluatorInfo?.label ?? undefined} hasPresets={hasPresets} onLoadPreset={() => setIsPresetModalOpen(true)} extraActions={isAgentHeaderMode ? undefined : viewModeSelector} @@ -280,7 +310,7 @@ const PlaygroundVariantConfig: React.FC< // section-row shape while the schema loads, instead of the // generic prompt-config pulse boxes. loadingFallback={ - isAgentHeaderMode ? : undefined + isAgentHeaderMode ? AGENT_CONFIG_SKELETON : undefined } /> diff --git a/web/oss/src/hooks/useLLMProviderConfig.tsx b/web/oss/src/hooks/useLLMProviderConfig.tsx index bec1578e83..548141eff3 100644 --- a/web/oss/src/hooks/useLLMProviderConfig.tsx +++ b/web/oss/src/hooks/useLLMProviderConfig.tsx @@ -50,34 +50,42 @@ export function useLLMProviderConfig() { setInitialProviderKind(null) }, []) - const footerContent = ( - <> - - - +
+ {icons.map((IconComp, idx) => ( + + ))} +
+ + + ), + [], ) - const configureProviderDrawer = ( - + const configureProviderDrawer = useMemo( + () => ( + + ), + [isConfigProviderOpen, initialProviderKind, closeConfigureProvider], ) const llmProviderConfig = useMemo( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AddTextLink.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AddTextLink.tsx index fb3dc2284c..2aaedd3c2d 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AddTextLink.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AddTextLink.tsx @@ -20,3 +20,5 @@ export const AddTextLink = forwardRef< ) }) + +AddTextLink.displayName = "AddTextLink" diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 96f2d502f0..a0b12d1125 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -21,7 +21,7 @@ * Sections are schema-driven: each renders only when its field exists in the resolved * schema, so the panel tracks the backend contract instead of hard-coding fields. */ -import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react" import {toolActionAvailabilityKey, useToolActionAvailability} from "@agenta/entities/gatewayTool" import type {SchemaProperty} from "@agenta/entities/shared" @@ -122,7 +122,7 @@ const ModelHarnessSectionDrawerBody = ({ // the agent populates them (see `useAutoExpandOnPopulate`). const CONTROLLED_SECTION_KEYS = new Set(["tools", "mcp", "skills", "triggers"]) -export function AgentTemplateControl({ +export const AgentTemplateControl = memo(function AgentTemplateControl({ schema, value, onChange, @@ -1045,4 +1045,4 @@ export function AgentTemplateControl({ )}
) -} +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx index 3d1179a906..b2b1031887 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx @@ -56,7 +56,7 @@ import { import {Button, Dropdown, Tooltip} from "antd" import type {MenuProps} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" -import {atomWithStorage} from "jotai/utils" +import {atomWithStorage, selectAtom} from "jotai/utils" import {AddItemMenu, type AddItemGroup} from "../../drawers/shared/AddItemMenu" import {loadRecentSamples, waitForNewDelivery} from "../../gatewayTrigger/drawers/shared/deliveries" @@ -305,17 +305,36 @@ function referencesMatch( export function useAgentTriggers(entityId: string | null) { // The drill-in entity only carries `parameters`; read the parent ids straight from // the workflow molecule (keyed by the revision id, which is the entityId). - const revision = useAtomValue( - useMemo(() => workflowMolecule.selectors.resolvedData(entityId ?? ""), [entityId]), + // Narrowed to the four fields used — a whole-resolvedData subscription re-renders the + // config panel on every boot-time data resolution. + const revisionMeta = useAtomValue( + useMemo( + () => + selectAtom( + workflowMolecule.selectors.resolvedData(entityId ?? ""), + (revision) => ({ + appId: revision?.workflow_id ?? null, + variantId: revision?.workflow_variant_id ?? revision?.variant_id ?? null, + appSlug: (revision as {slug?: string} | null)?.slug ?? null, + name: (revision as {name?: string} | null)?.name ?? null, + }), + (a, b) => + a.appId === b.appId && + a.variantId === b.variantId && + a.appSlug === b.appSlug && + a.name === b.name, + ), + [entityId], + ), ) - const appId = revision?.workflow_id ?? null - const variantId = revision?.workflow_variant_id ?? revision?.variant_id ?? null + const appId = revisionMeta.appId + const variantId = revisionMeta.variantId // The app slug — needed for "By environment" binding, which resolves via the // application slug + environment (see triggers/service.py `_normalize_references`). - const appSlug = (revision as {slug?: string} | null)?.slug ?? null + const appSlug = revisionMeta.appSlug // Readable label for the default binding so the drawer's bound-workflow field // shows the agent's name instead of a raw id. Falls back when the name is unresolved. - const defaultBoundLabel = (revision as {name?: string} | null)?.name ?? "Current agent" + const defaultBoundLabel = revisionMeta.name ?? "Current agent" const agentIds = useMemo(() => { const ids = new Set() diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 8d6b6b6316..06afe68817 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -19,7 +19,7 @@ import {SelectLLMProviderBase} from "@agenta/ui/select-llm-provider" import {cn} from "@agenta/ui/styles" import {Check, Cube, Lightbulb, ShieldCheck, Sparkle, Warning} from "@phosphor-icons/react" import {Select, Typography} from "antd" -import {useAtomValue} from "jotai" +import {atom, useAtomValue} from "jotai" import {RailField, railInfoLabel} from "../../../drawers/shared/RailField" import {SectionRail} from "../../../drawers/shared/SectionRail" @@ -47,6 +47,11 @@ import {enumLabel} from "./agentTemplateUtils" import ProviderCredentialsSection from "./ProviderCredentialsSection" import {useBuildKit} from "./useBuildKit" +// Only assert "needs a key" once the vault query has resolved (an array). While it's pending, +// `standardSecretsAtom` returns the static provider catalog with empty keys, so a reload would +// flash a false "Connect key" warning on the section, rail item, and config-panel row. +const vaultLoadedAtom = atom((get) => Array.isArray(get(vaultSecretsQueryAtom).data)) + type PermissionPolicy = "allow_reads" | "allow" | "ask" | "deny" const PERMISSION_POLICY_OPTIONS: {value: PermissionPolicy; label: string; help: string}[] = [ @@ -182,10 +187,9 @@ export function useModelHarness({ const capabilities = harnessRefKey ? capabilitiesFromCatalog : null const mcpSupported = harnessSupportsUserMcp(capabilities, harnessValue) - // The vault query backs `vaultLoaded` below (gates the "needs a key" flag) and the custom_provider - // model groups (`vaultModelGroups`); connections themselves are always the project default now, - // so there is no named-connection list here. - const vaultQuery = useAtomValue(vaultSecretsQueryAtom) + // Narrowed to the loaded flag (all this hook reads) — the raw query atom churns identity on + // every fetch-state flip during boot. + const vaultLoaded = useAtomValue(vaultLoadedAtom) const modeOptions = useMemo( () => allowedConnectionModes(capabilities, harnessValue), @@ -215,10 +219,6 @@ export function useModelHarness({ ) ?? null ) }, [standardSecrets, selectedProviderFamily]) - // Only assert "needs a key" once the vault query has resolved (an array). While it's pending, - // `standardSecretsAtom` returns the static provider catalog with empty keys, so a reload would - // flash a false "Connect key" warning on the section, rail item, and config-panel row. - const vaultLoaded = Array.isArray(vaultQuery.data) // Self-managed agents never need a vault key — the harness signs itself in. Neither does a // named custom-provider connection (agenta mode with a slug): it carries its own credentials, // so a missing STANDARD vault key for the family is not this connection's problem. diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/MoleculeDrillInContext.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/MoleculeDrillInContext.tsx index 1372ae3dec..f75e7a950b 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/MoleculeDrillInContext.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/MoleculeDrillInContext.tsx @@ -279,11 +279,13 @@ export function MoleculeDrillInProvider = useMemo( () => ({ ...defaultFieldBehaviors, - ...moleculeBehaviors, + ...(moleculeBehaviors ?? {}), // Props override molecule config ...(editable !== undefined ? {editable} : {}), ...(collapsible !== undefined ? {collapsible} : {}), diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index 07f41d8cf7..4cbf7fc42e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -36,9 +36,11 @@ import {SharedEditor} from "@agenta/ui/shared-editor" import {ArrowLeft, CaretDown, CaretRight, MagicWand} from "@phosphor-icons/react" import {Button, Dropdown, Popover, Tabs, Tooltip, Typography} from "antd" import clsx from "clsx" +import deepEqual from "fast-deep-equal" import type {Atom, WritableAtom} from "jotai" import {atom} from "jotai" import {useAtom, useAtomValue, useSetAtom} from "jotai" +import {selectAtom} from "jotai/utils" import yaml from "js-yaml" import { @@ -291,6 +293,11 @@ function memoAtom(factory: (id: string) => Atom): (id: string) => Atom } } +/** Content-stable wrapper: keep the previous value when a recompute yields equal content. */ +function stableAtom(base: Atom, equals: (a: T, b: T) => boolean): Atom { + return selectAtom(base, (v) => v, equals) +} + // ============================================================================ // DEFAULT ADAPTER (workflowMolecule — direct molecule access) // ============================================================================ @@ -413,34 +420,48 @@ const configUpdateRouterAtom = atom( function buildWorkflowMoleculeAdapter(): ConfigSectionMoleculeAdapter { return { atoms: { + // deepEqual-stable: upstream selectors mint fresh objects per query flip during boot. data: memoAtom((id: string) => - atom((get) => { - const config = get(workflowMolecule.selectors.configuration(id)) - const full = get(workflowMolecule.selectors.resolvedData(id)) - return mergeSiblingFields(config as Record | null, full) - }), + stableAtom( + atom((get) => { + const config = get(workflowMolecule.selectors.configuration(id)) + const full = get(workflowMolecule.selectors.resolvedData(id)) + return mergeSiblingFields(config as Record | null, full) + }), + deepEqual, + ), ), serverData: memoAtom((id: string) => - atom((get) => { - const config = get(workflowMolecule.selectors.serverConfiguration(id)) - const full = get(workflowMolecule.selectors.serverData(id)) - return mergeSiblingFields(config as Record | null, full) - }), + stableAtom( + atom((get) => { + const config = get(workflowMolecule.selectors.serverConfiguration(id)) + const full = get(workflowMolecule.selectors.serverData(id)) + return mergeSiblingFields(config as Record | null, full) + }), + deepEqual, + ), ), draft: (id: string) => workflowMolecule.atoms.draft(id), isDirty: (id: string) => workflowMolecule.selectors.isDirty(id), schemaQuery: memoAtom((id: string) => - atom((get) => { - const q = get(workflowMolecule.selectors.query(id)) - const rawSchema = get(workflowMolecule.selectors.parametersSchema(id)) - const schema = isEntitySchema(rawSchema) ? rawSchema : null - return { - isPending: q.isPending, - isError: q.isError, - error: q.error as Error | null, - data: {agConfigSchema: schema}, - } - }), + stableAtom( + atom((get) => { + const q = get(workflowMolecule.selectors.query(id)) + const rawSchema = get(workflowMolecule.selectors.parametersSchema(id)) + const schema = isEntitySchema(rawSchema) ? rawSchema : null + return { + isPending: q.isPending, + isError: q.isError, + error: q.error as Error | null, + data: {agConfigSchema: schema}, + } + }), + (a, b) => + a.isPending === b.isPending && + a.isError === b.isError && + a.error === b.error && + a.data?.agConfigSchema === b.data?.agConfigSchema, + ), ), agConfigSchema: memoAtom((id: string) => atom((get) => { @@ -780,7 +801,7 @@ function PlaygroundConfigSection({ // Stabilize via serialized key to prevent infinite re-render loops when the // parameters object reference changes but content is identical (e.g., during // entity loading in URL-driven drawer initialization). - const parametersKey = JSON.stringify(parameters) + const parametersKey = useMemo(() => JSON.stringify(parameters), [parameters]) const {displayParameters, metadataMap} = useMemo(() => { return { displayParameters: stripAgentaMetadata(parameters), @@ -1853,6 +1874,16 @@ function PlaygroundConfigSection({ ], ) + // Stable slots object — an inline literal would churn the drill-in provider's context value. + const drillInSlots = useMemo( + () => ({ + fieldHeader: fieldHeaderSlot, + fieldActions: fieldActionsSlot, + fieldContent: fieldContentSlot, + }), + [fieldHeaderSlot, fieldActionsSlot, fieldContentSlot], + ) + // ========== LOADING / EMPTY STATE ========== const isConfigLoading = schemaQuery.isPending && !hasRenderableConfigSections(activeData) @@ -1955,11 +1986,7 @@ function PlaygroundConfigSection({ rootTitle="Configuration" showBreadcrumb={false} collapsible={false} - slots={{ - fieldHeader: fieldHeaderSlot, - fieldActions: fieldActionsSlot, - fieldContent: fieldContentSlot, - }} + slots={drillInSlots} /> )} From fad37e2c0901594cd2352902d3552fbca445fe79 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Sun, 19 Jul 2026 17:39:46 +0200 Subject: [PATCH 18/33] docs(design): mark T1.3 + accordion hygiene built in app-boot plan --- docs/design/app-boot-optimizations/plan.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md index 578deac1b8..cbf0ff710b 100644 --- a/docs/design/app-boot-optimizations/plan.md +++ b/docs/design/app-boot-optimizations/plan.md @@ -4,6 +4,13 @@ Status: ANALYSIS + PLAN (2026-07-19) — T1.1 + T1.2 BUILT (e423d17708, warmup v for T1.2 preserving the recipe code-split). T2 BUILT (33e06521eb — all four items; ProtectedRoute deviation: its five hooks are side-effectful mounts, moved to a memo'd null island rather than an atom; snapshot `timestamp` dropped entirely, zero consumers). +T1.3 BUILT (c14e768072 — latch `raw || (latch && sessionExists)`, BootShell sidebar +ghost pre-ready, prewarmBootQueryGraph() overlapping the config-chunk fetch; the 157ms +"ProtectedRoute first read" was the boot query-atom graph's initial evaluation). +Config-accordion hygiene BUILT (620054a6c6 — equality-stable adapter atoms, +narrowed PlaygroundVariantConfig subscriptions, stable DrillInUIContext value; the +accordion agent was cut off by a session limit and two type fixes landed at +integration). Remaining: T1.4 /__env.js inlining; profiler re-run. Profiler-driven extras (25f527a016): seven anonymous forwardRefs named — the profile's top line "ForwardRef(Anonymous)" ×594 was their aggregate, dominated by EnhancedButton — and EnhancedButton no longer wraps tooltip-less buttons in Tooltip/Trigger (the From b6fa89538d03da81a8c5ab7883e696ba01f8b968 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 02:52:12 +0200 Subject: [PATCH 19/33] perf(frontend): fix warm-reload list race, prewarm current-workflow data, unblock org detail Third measured iteration on playground time-to-active (HAR + profiler pair from a warm reload): - the full revisions list STILL fired on warm reloads with a selection: bindRevisionsReady checked the selection at bind time, but the persisted last-selection is only restored inside tryApplyDefaults - which ran AFTER the list store.sub had mounted the query. The fetch won the race on every warm reload. Defaults are now applied first and the list subscription is created only when nothing was applied; the genuinely-selection-less cold load keeps the old retry mechanism - the current app's detail + latest-revision fetches fired at ~1450ms only because nothing subscribed them until the page tree mounted behind the gates and the chunk-parse window; their gates are fully URL-derivable at boot. prewarmBootQueryGraph gains a task queue (tasks run in the same synchronous burst as the profile prewarm once the real queryClient is bound) and PlaygroundRouter registers a current-workflow prewarm at module eval next to the chunk warmup. Both requests now leave with profile - selectedOrgQueryAtom was enabled-gated on the /profile response (the exact serialization 2ede5faa10 removed for projects); now gates on jwtReady like projectsQueryAtom. Org detail heads the level-3 cluster (plans/roles/ workspace resolution) - workflowDetailQueryAtomFamily persists via catalogPersister (30s staleTime kept). Invalidation-audited: no code invalidates that key, so disk restore + background revalidate adds no new staleness class. Kills the Playground -> ConfigureEvaluatorPage swap-flash for evaluator apps and feeds currentAppQueryAtom consumers instantly on warm reloads Also settled by this round's investigation: the 532-1061ms network dead zone is main-thread parse of the warmed chunk graph (the T1.1 tradeoff), not a data gate - the orgs-list idle gate captured nothing boot-critical. A sessionStorage ready-latch was evaluated and REJECTED (it would render protected content before SuperTokens confirms the session) --- .../src/components/PlaygroundRouter/index.tsx | 7 ++++- .../src/state/boot/prewarmBootQueryGraph.ts | 21 +++++++++++++++ web/oss/src/state/org/selectors/org.ts | 13 ++++------ web/oss/src/state/url/playground.ts | 10 ++++--- .../state/workflow/prewarmCurrentWorkflow.ts | 26 +++++++++++++++++++ .../src/workflow/state/store.ts | 3 +++ 6 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 web/oss/src/state/workflow/prewarmCurrentWorkflow.ts diff --git a/web/oss/src/components/PlaygroundRouter/index.tsx b/web/oss/src/components/PlaygroundRouter/index.tsx index c4969a47f3..b4a3f45dae 100644 --- a/web/oss/src/components/PlaygroundRouter/index.tsx +++ b/web/oss/src/components/PlaygroundRouter/index.tsx @@ -7,6 +7,7 @@ import {useRouter} from "next/router" import {PLAYGROUND_NATIVE_ONBOARDING} from "@/oss/components/pages/agent-home/assets/constants" import OnboardingLoader from "@/oss/components/pages/agent-home/PlaygroundOnboarding/OnboardingLoader" import {currentWorkflowContextAtom} from "@/oss/state/workflow" +import {prewarmCurrentWorkflowQueries} from "@/oss/state/workflow/prewarmCurrentWorkflow" import PlaygroundLoadingShell from "./PlaygroundLoadingShell" @@ -14,7 +15,11 @@ const loadPlayground = () => import("../Playground/Playground") // Warm the playground graph immediately at module eval: without this the ~10MB chunk // is only discovered after the auth + protected-route gates release, serializing its // download+parse behind them instead of running in parallel. -if (typeof window !== "undefined") void loadPlayground() +if (typeof window !== "undefined") { + void loadPlayground() + // Same reasoning for the current app's detail + latest-revision DATA (see module docs). + prewarmCurrentWorkflowQueries() +} const Playground = dynamic(loadPlayground, { ssr: false, diff --git a/web/oss/src/state/boot/prewarmBootQueryGraph.ts b/web/oss/src/state/boot/prewarmBootQueryGraph.ts index 1fa1587c4c..66b1e1cbe2 100644 --- a/web/oss/src/state/boot/prewarmBootQueryGraph.ts +++ b/web/oss/src/state/boot/prewarmBootQueryGraph.ts @@ -11,6 +11,19 @@ const noop = () => undefined let warmed = false +type PrewarmTask = () => void +const pendingTasks: PrewarmTask[] = [] + +// Route-scoped warmups (page-chunk module eval) queue here until the boot graph is live, +// so their query subscriptions bind to the real queryClient regardless of chunk order. +export const registerBootPrewarmTask = (task: PrewarmTask) => { + if (warmed) { + task() + return + } + pendingTasks.push(task) +} + // First evaluation of the boot atom graph off the big provider-mount commit. Safe // pre-auth: every query atom is enabled-gated on sessionExistsAtom (still false here). export const prewarmBootQueryGraph = () => { @@ -24,4 +37,12 @@ export const prewarmBootQueryGraph = () => { store.sub(projectsQueryAtom, noop) store.sub(selectedOrgQueryAtom, noop) store.sub(protectedRouteLatchedReadyAtom, noop) + + pendingTasks.splice(0).forEach((task) => { + try { + task() + } catch { + // prewarm is best-effort + } + }) } diff --git a/web/oss/src/state/org/selectors/org.ts b/web/oss/src/state/org/selectors/org.ts index 184c8e803a..8ac795e1ab 100644 --- a/web/oss/src/state/org/selectors/org.ts +++ b/web/oss/src/state/org/selectors/org.ts @@ -10,6 +10,7 @@ import {fetchProject} from "@/oss/services/project" import {appIdentifiersAtom, appStateSnapshotAtom, requestNavigationAtom} from "@/oss/state/appState" import {userAtom} from "@/oss/state/profile/selectors/user" import {sessionExistsAtom} from "@/oss/state/session" +import {jwtReadyAtom} from "@/oss/state/session/jwt" const WORKSPACE_ORG_MAP_KEY = "workspaceOrgMap" const LAST_USED_WORKSPACE_ID_KEY = "lastUsedWorkspaceId" @@ -336,19 +337,15 @@ export const selectedOrgQueryAtom = atomWithQuery((get) => { const snapshot = get(appStateSnapshotAtom) const queryOrgId = snapshot.query["organization_id"] const id = (typeof queryOrgId === "string" && queryOrgId) || get(selectedOrgIdAtom) - const userId = (get(userAtom) as User | null)?.id + // Gate on a usable JWT, not on the profile response: waiting for `userAtom` serialized + // this fetch behind the /profile/ round-trip on every boot (same fix as projectsQueryAtom). + const jwtReady = Boolean(get(jwtReadyAtom).data) const isWorkspaceRoute = snapshot.routeLayer === "workspace" || snapshot.routeLayer === "project" || snapshot.routeLayer === "app" const isAcceptRoute = snapshot.pathname.startsWith("/workspaces/accept") - const enabled = - !!id && - id !== null && - get(sessionExistsAtom) && - !!userId && - isWorkspaceRoute && - !isAcceptRoute + const enabled = !!id && get(sessionExistsAtom) && jwtReady && isWorkspaceRoute && !isAcceptRoute return { queryKey: ["selectedOrg", id], diff --git a/web/oss/src/state/url/playground.ts b/web/oss/src/state/url/playground.ts index bf992fb315..0f44118427 100644 --- a/web/oss/src/state/url/playground.ts +++ b/web/oss/src/state/url/playground.ts @@ -1024,6 +1024,10 @@ playgroundSyncAtom.onMount = (set) => { store.set(playgroundInitializedAtom, true) } } else { + // Synchronous restore FIRST (persisted last-selection / cached latest): on a warm + // reload the selection atom is empty at bind time even though a selection exists, + // and subscribing the list-data atom below would mount the FULL revisions query. + tryApplyDefaults() // Deferred by a microtask: these subs fire inside TanStack query notifications, // i.e. mid atom-read — applying the selection there mutates the store during a // read (jotai's "Detected store mutation during atom read"). @@ -1031,15 +1035,13 @@ playgroundSyncAtom.onMount = (set) => { queueMicrotask(tryApplyDefaults), ) // Subscribe to entity data so we retry when it finishes loading. - // Only needed when no URL selection exists and we must find a default. - if (currentAppId) { + // Only when no selection could be restored — this sub mounts the full list query. + if (currentAppId && !hasAppliedDefaults) { currentLatestRevUnsub = store.sub( workflowRevisionsByWorkflowListDataAtomFamily(currentAppId), () => queueMicrotask(tryApplyDefaults), ) } - // Immediate check in case already ready - tryApplyDefaults() } } bindRevisionsReady() diff --git a/web/oss/src/state/workflow/prewarmCurrentWorkflow.ts b/web/oss/src/state/workflow/prewarmCurrentWorkflow.ts new file mode 100644 index 0000000000..67a35e94ff --- /dev/null +++ b/web/oss/src/state/workflow/prewarmCurrentWorkflow.ts @@ -0,0 +1,26 @@ +import { + workflowDetailQueryAtomFamily, + workflowLatestRevisionQueryAtomFamily, +} from "@agenta/entities/workflow" +import {getDefaultStore} from "jotai" + +import {appIdentifiersAtom} from "@/oss/state/appState" +import {registerBootPrewarmTask} from "@/oss/state/boot/prewarmBootQueryGraph" + +const noop = () => undefined + +let registered = false + +// Fire the current app's detail + latest-revision fetches in the boot burst (their gates — +// session, projectId, appId — are all URL-derived), instead of waiting for PlaygroundRouter +// to mount behind the auth gates + chunk parse, which serializes two round-trips. +export const prewarmCurrentWorkflowQueries = () => { + if (registered || typeof window === "undefined") return + registered = true + registerBootPrewarmTask(() => { + const appId = getDefaultStore().get(appIdentifiersAtom).appId + if (!appId) return + getDefaultStore().sub(workflowDetailQueryAtomFamily(appId), noop) + getDefaultStore().sub(workflowLatestRevisionQueryAtomFamily(appId), noop) + }) +} diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index a2d8405742..ec201b3ca7 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -467,6 +467,9 @@ export const workflowDetailQueryAtomFamily = atomFamily((workflowId: string | nu }, enabled: get(sessionAtom) && !!projectId && !!workflowId, staleTime: 30_000, + // Class C paint-fast: PlaygroundRouter branches on this tiny artifact, so serve it + // from disk on reload; restored entries older than staleTime revalidate in background. + persister: catalogPersister.persisterFn, } }), ) From 99a07118acd2ce0f63da8aa48dd531b8fbf526f5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 02:52:25 +0200 Subject: [PATCH 20/33] docs(design): record iteration-3 findings (parse-bound dead zone, list race) --- docs/design/app-boot-optimizations/plan.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md index cbf0ff710b..080a573c7d 100644 --- a/docs/design/app-boot-optimizations/plan.md +++ b/docs/design/app-boot-optimizations/plan.md @@ -10,7 +10,17 @@ ghost pre-ready, prewarmBootQueryGraph() overlapping the config-chunk fetch; the Config-accordion hygiene BUILT (620054a6c6 — equality-stable adapter atoms, narrowed PlaygroundVariantConfig subscriptions, stable DrillInUIContext value; the accordion agent was cut off by a session limit and two type fixes landed at -integration). Remaining: T1.4 /__env.js inlining; profiler re-run. +integration). Iteration 3 (1a8b541815): warm-reload list race fixed (bindRevisionsReady mounted the +list sub before tryApplyDefaults restored the persisted selection); current-workflow +detail + latest-revision prewarmed in the profile burst (registerBootPrewarmTask); +org detail un-gated from /profile (jwtReady, mirrors projects); workflow detail +persisted (nothing invalidates the key — audited). SETTLED: the 532→1061ms network +dead zone is main-thread PARSE of the warmed chunk graph (T1.1 tradeoff), not a data +gate; protectedRouteReadyAtom has ZERO network deps on warm workspace routes (pure +effect timing); sessionStorage ready-latch REJECTED (renders protected content before +SuperTokens confirms). Remaining ranked: chunk-parse window (Tier-3 split or prod +minification), ready-flip→mount ~400ms (hygiene), session records pre-mount (needs +session id before mount). Remaining: T1.4 /__env.js inlining; profiler re-run. Profiler-driven extras (25f527a016): seven anonymous forwardRefs named — the profile's top line "ForwardRef(Anonymous)" ×594 was their aggregate, dominated by EnhancedButton — and EnhancedButton no longer wraps tooltip-less buttons in Tooltip/Trigger (the From 44e5135c4863c14d288669dbe098f964e3d3574a Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 04:25:31 +0200 Subject: [PATCH 21/33] docs(design): playground render-gate ladder audit (bottom-up, with relax verdicts) --- docs/design/app-boot-optimizations/gates.md | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/design/app-boot-optimizations/gates.md diff --git a/docs/design/app-boot-optimizations/gates.md b/docs/design/app-boot-optimizations/gates.md new file mode 100644 index 0000000000..105b974079 --- /dev/null +++ b/docs/design/app-boot-optimizations/gates.md @@ -0,0 +1,76 @@ +# Playground render-gate ladder (bottom-up audit, 2026-07-20) + +Why: on warm reloads the config panel showed loading states even though its data is +IndexedDB-restorable. Central mechanic: **persister restores are asynchronous** — a +disk-served query still passes through `isPending: true` (and `data === undefined`) +for a frame or more, so any `isPending → skeleton` gate treats instant-disk data like +a cold network fetch. `enabled: false` queries report `isPending: true` forever +(drafts — but see the draft analysis: the molecule's `selectors.query` wrapper +special-cases drafts to `isPending: false`, molecule.ts:164-172, so panel gates never +hit that trap directly). + +Legend: [SAFE-RELAX] = gate can key on data presence / synchronous signal; +[RESTRUCTURE] = relaxable with reordering; [KEEP] = correctness latch, leave alone. + +## Level 1 — config-section internals (entity-ui) + +| Gate | Condition | Verdict | +|---|---|---| +| PlaygroundConfigSection ~1888 config skeleton | `schemaQuery.isPending && !hasRenderableConfigSections(activeData)` | **[SAFE-RELAX → RELAXED]** — the data-presence term is sufficient; the `isPending` term only shows the skeleton during the restore frame | +| PCS ~695 revision-switch latch | keep previous render until target data or settled | [KEEP] — swap-correctness latch, prefers data-presence already | +| PCS ~1906 "No configuration needed" | data-null empty state | [RESTRUCTURE] — reached wrongly for drafts pre-hydration; held off by gate 2.1 | +| SchemaPropertyRenderer ~448 Suspense(AgentTemplateControl) | lazy chunk import | **[SAFE-RELAX → RELAXED]** — preload on the synchronous early-agent signal instead of idle | +| useModelHarness vault/capabilities gates | vault + inspect catalogs | [KEEP] — gate a warning / pick a control variant; never hold the body | + +## Level 2 — config panel host (PlaygroundVariantConfig) + +| Gate | Condition | Verdict | +|---|---|---| +| ~281 `hasPendingHydration` skeleton | pending URL-snapshot hydration; clears when the SOURCE revision query is `!isPending && data` (playground.ts ~900-913) | **[RESTRUCTURE]** — the draft warm-reload skeleton. Must hold (removing it falls through to the wrong "No configuration needed" state), but should resolve off disk-present source *data*; `workflowLocalServerDataAtomFamily` (store.ts ~2361) is in-memory-only and could be reseeded synchronously | +| ~99 `isAgentHeaderMode` incl. `variantQueryPending` disjunct | agent-vs-prompt chrome | **[SAFE-RELAX → RELAXED]** — `earlyAgentState` covers the persisted case synchronously; the pending disjunct mislabels prompt apps as agent for a tick | +| ~341 agent operations skeleton | `hasPendingHydration \|\| (!isAgent && earlyAgentState!=="agent")` | [RESTRUCTURE] — tied to 2.1 | + +## Level 3 — MainLayout + +| Gate | Condition | Verdict | +|---|---|---| +| ~402 `configEntityIds.length === 0` placeholder | selection not yet applied | **[RESTRUCTURE]** — persisted selection is synchronously readable but applied in `playgroundSyncAtom.onMount` (post-first-commit) → one-frame placeholder | +| ~299 EmptyState (`status === "empty"`) | initialized + empty | [KEEP] — correctly distinct from the idle frame | +| ~519 AgentChatSkeleton | `isAgentConfig && singleEntityQuery.isPending` | **[SAFE-RELAX → RELAXED]** — key on data presence | +| ~183 / ~222 agent host + config latches | isPending-conservative latches | [KEEP] — protect live chat / splitter geometry across swaps | +| ~497 GenerationPanelPlaceholder | same empty-selection frame as 3.1 | [RESTRUCTURE] — same fix | + +## Level 4 — Playground root + +`playgroundSyncAtom` mounts the sync engine in `onMount` — one commit too late for the +synchronous selection restore ([RESTRUCTURE], the root cause of the Level-3 frames). +Onboarding loader is flag-gated ([KEEP]). No other config-subtree gates. + +## Level 5 — PlaygroundRouter + +Chunk `loading` shells are warmed (brief). The evaluator-vs-app branch resolves from +the now-persisted + prewarmed detail query ([SAFE-RELAX] residual: avoid an +`isPending`-shaped shell frame; largely mitigated). + +## Levels 6-8 — ProtectedRoute → Layout → `_app` + +Documented in plan.md (boot model): latched ready-gate + BootShell, warmed chunks, +de-async'd auth init, prewarmed boot query graph. Ready-atom has zero network deps on +warm workspace routes; remaining wait is main-thread parse. + +## Draft warm-reload chain (the reported symptom) + +Draft selected → `workflowLocalServerDataAtomFamily` is in-memory-null after reload → +panel data null → gate 2.1 holds the skeleton pending hydration → hydration waits for +the SOURCE revision's query to be `!isPending && data` → that query's `initialData` +reads the in-memory detail cache (empty on reload) and fills from the persister +asynchronously. Net: the skeleton's length = source-revision disk-restore latency, +even though the data is on disk. Fix path: hydration resolves on disk-present data + +synchronous reseed of the local-server-data atom (+ gate 1.1 relax so restored +parameters render immediately). + +## Status + +Relaxed in this round: 1.1 (isPending term dropped), 1.4 (early preload), 2.2 (sync +early-agent signal), 3.3 (data-presence). Restructures pending: 2.1 hydration +fast-path, 3.1/3.6 selection-before-first-render. From 1c40c3467b0fc53deb9b90380608f12568245ca6 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 04:41:31 +0200 Subject: [PATCH 22/33] perf(frontend): relax playground render gates for disk-served warm reloads Bottom-up gate audit (docs/design/app-boot-optimizations/gates.md) found the config panel's loading states outlived the IndexedDB-restorable data behind them. Five relaxations, each re-derived from the code: - selection before first render: state/url/playground.ts now runs the full syncPlaygroundStateFromUrl at module eval (guarded to playground routes), so the persisted selection / URL revisions / snapshot hash are applied BEFORE the first React commit instead of in playgroundSyncAtom.onMount one commit later. Kills the one-frame config/generation placeholder boxes; precedence (hash -> ?revisions= -> persisted -> defaults) is preserved by reusing the sync itself; later replays are idempotent via the existing dedupe guards. Bonus: the snapshot-over-localStorage priority now actually holds on hard reloads (the hash is processed before either onMount) - draft hydration fast-path: pending URL-snapshot hydrations now apply as soon as the SOURCE revision's query DATA is present (the !isPending term dropped at both apply sites) - the draft warm-reload skeleton now ends at IDB restore latency instead of full query settle. Presence checks stay on the molecule query's server baseline (not the merged selector) so a draft can never patch over an empty baseline - agent chrome decided by the synchronous persisted early-agent signal; the variantQueryPending disjunct is removed (it mislabeled prompt apps as agent chrome for the whole restore window) - AgentChatSkeleton only when pending AND no data - AgentTemplateControl chunk preloads on the early-agent signal (first effect) instead of requestIdleCallback Explicitly NOT relaxed after re-derivation: the PlaygroundConfigSection skeleton gate (dropping isPending would paint 'No configuration needed' during every restore window and permanently skeleton config-less workflows) and the draft local-server-data reseed (impossible before the source body; compare-mode clones already seed at hydration-apply). gates.md carries the full ladder with KEEP/RELAXED verdicts per gate. --- docs/design/app-boot-optimizations/gates.md | 16 ++++++--- .../Components/AgentCatalogPrefetcher.tsx | 15 +++----- .../Components/MainLayout/index.tsx | 8 ++++- .../PlaygroundVariantConfig/index.tsx | 22 +++++------- web/oss/src/state/url/playground.ts | 34 ++++++++++++++++--- 5 files changed, 62 insertions(+), 33 deletions(-) diff --git a/docs/design/app-boot-optimizations/gates.md b/docs/design/app-boot-optimizations/gates.md index 105b974079..6ad0e2a8fc 100644 --- a/docs/design/app-boot-optimizations/gates.md +++ b/docs/design/app-boot-optimizations/gates.md @@ -16,7 +16,7 @@ Legend: [SAFE-RELAX] = gate can key on data presence / synchronous signal; | Gate | Condition | Verdict | |---|---|---| -| PlaygroundConfigSection ~1888 config skeleton | `schemaQuery.isPending && !hasRenderableConfigSections(activeData)` | **[SAFE-RELAX → RELAXED]** — the data-presence term is sufficient; the `isPending` term only shows the skeleton during the restore frame | +| PlaygroundConfigSection ~1888 config skeleton | `schemaQuery.isPending && !hasRenderableConfigSections(activeData)` | **[KEEP — audit verdict revised]** — dropping `isPending` makes the "No configuration needed" empty state paint during every restore window (data absent + pending is a real loading state, not a false gate); the skeleton already clears the moment restored data lands | | PCS ~695 revision-switch latch | keep previous render until target data or settled | [KEEP] — swap-correctness latch, prefers data-presence already | | PCS ~1906 "No configuration needed" | data-null empty state | [RESTRUCTURE] — reached wrongly for drafts pre-hydration; held off by gate 2.1 | | SchemaPropertyRenderer ~448 Suspense(AgentTemplateControl) | lazy chunk import | **[SAFE-RELAX → RELAXED]** — preload on the synchronous early-agent signal instead of idle | @@ -71,6 +71,14 @@ parameters render immediately). ## Status -Relaxed in this round: 1.1 (isPending term dropped), 1.4 (early preload), 2.2 (sync -early-agent signal), 3.3 (data-presence). Restructures pending: 2.1 hydration -fast-path, 3.1/3.6 selection-before-first-render. +Built in this round: 1.4 (immediate preload on the early-agent signal), 2.1 (hydration +applies on source-data presence; the `!isPending` term dropped), 2.2/2.4 (agent chrome +keyed on the synchronous early-agent signal, `variantQueryPending` disjunct removed), +3.1/3.6 (module-eval URL sync in `state/url/playground.ts` seeds the selection before +the first React commit), 3.3 (skeleton only when pending AND no data). NOT relaxed: +1.1 — verdict revised to KEEP (see the Level-1 table); with 2.1 + 3.1 the restored data +is present by/near first commit, so the remaining skeleton time equals the IDB restore +latency, which is the correct floor. 2.1's local-server-data reseed idea is moot: the +draft body cannot exist before its source body (IDB reads are async, drafts are +reconstructed from patches by design), and compare-mode clones are already seeded at +hydration-apply time — now the earliest possible moment. diff --git a/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx index 07b0eea72e..9104001051 100644 --- a/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx +++ b/web/oss/src/components/Playground/Components/AgentCatalogPrefetcher.tsx @@ -17,17 +17,12 @@ import {useAtomValue} from "jotai" const AgentCatalogPrefetcher = () => { useAtomValue(agTypeSchemaAtomFamily("agent-template")) useAtomValue(harnessCapabilitiesAtomFamily("")) - // Warm the code-split agent-template control during idle time, so its download + - // execution doesn't land in the same main-thread burst as the revision/schema - // resolving (which froze the paint right as the panels transitioned to content). + // Warm the code-split agent-template control immediately (this component mounts only when the + // early-agent signal says agent): idle scheduling let the config sections mount before the lazy + // chunk resolved, flashing the Suspense fallback on warm reloads. The import is async, so the + // download runs in parallel with the revision/schema restore rather than blocking it. useEffect(() => { - const idle = (window as Window & typeof globalThis).requestIdleCallback - if (typeof idle === "function") { - const id = idle(() => void preloadAgentTemplateControl(), {timeout: 2000}) - return () => window.cancelIdleCallback?.(id) - } - const t = window.setTimeout(() => void preloadAgentTemplateControl(), 300) - return () => window.clearTimeout(t) + void preloadAgentTemplateControl() }, []) return null } diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index ee6e5870aa..199269a310 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -516,7 +516,13 @@ const PlaygroundMainView = ({ // Agent identified early (persisted agent-type map) but the // revision hasn't resolved the flag yet — hold the chat pane's // shape instead of a blank canvas until the host mounts. - if (isAgentConfig && singleEntityQuery.isPending) { + // Data presence ends the skeleton: a restored body is renderable + // even if a pending-shaped state lingers. + if ( + isAgentConfig && + singleEntityQuery.isPending && + !singleEntityQuery.data + ) { return } return displayedEntities.includes(variantId) || diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index 50dbd3b063..cacf6d46de 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -86,17 +86,11 @@ const PlaygroundVariantConfig: React.FC< // form/JSON/YAML view switch doesn't apply — hide it for agents (kept for prompt/eval variants). const isAgent = useAtomValue(isAgentModeAtomFamily(variantId)) // `isAgentModeAtomFamily` is false until the revision's is_agent flag loads, so on load the heavy - // prompt chrome (view switcher) would flash for an agent. Treat as agent-header mode when it's an - // agent, the early app-id signal says agent, OR agent-ness is still unknown (variant not settled). + // prompt chrome (view switcher) would flash for an agent. The persisted early app-id signal is + // synchronous, so it decides during the load; a query-pending disjunct would mislabel PROMPT + // apps as agent chrome for the whole restore window. const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) - // Narrow to the pending flag: the full query object churns identity on every fetch-state flip. - const variantQueryPending = useAtomValue( - useMemo( - () => selectAtom(workflowMolecule.selectors.query(variantId || ""), (q) => q.isPending), - [variantId], - ), - ) - const isAgentHeaderMode = isAgent || earlyAgentState === "agent" || variantQueryPending + const isAgentHeaderMode = isAgent || earlyAgentState === "agent" // Refine prompt modal state const [refineModalOpen, setRefineModalOpen] = useState(false) @@ -335,11 +329,11 @@ const PlaygroundVariantConfig: React.FC< )} {/* Sections 2 + 3 (agent only): Triggers and Mounts — operational, never part of the - committable config, each with its own Configuration-style sticky header. Skeleton - keeps the three-section shape while hydration is pending OR agent-ness is still - unknown (the real sections would fire trigger queries for a maybe-prompt app). */} + committable config, each with its own Configuration-style sticky header. Rendered only + when the entity is a known/early-signalled agent (so a maybe-prompt app never fires + trigger queries); skeleton keeps the three-section shape while hydration is pending. */} {isAgentHeaderMode && - (hasPendingHydration || (!isAgent && earlyAgentState !== "agent") ? ( + (hasPendingHydration ? ( ) : ( { const tryApplyHydrations = () => { const query = store.get(queryAtom) - if (!query.isPending && query.data) { + // Data presence alone gates the apply: an IDB-restored body is usable the + // moment it lands, and `isPending` adds nothing (data implies settled). + if (query.data) { // Apply all pending hydrations for this source via the // ordered helper — it processes createLocalDraft entries // before applyDraftPatch entries so local copies are @@ -901,10 +903,10 @@ playgroundSyncAtom.onMount = (set) => { const query = store.get( workflowMolecule.selectors.query(hydration.sourceRevisionId), ) as { - isPending: boolean - data: any + data: unknown } - if (!query.isPending && query.data) { + // Same data-presence gate as tryApplyHydrations above. + if (query.data) { readySourceIds.add(hydration.sourceRevisionId) } } @@ -1373,3 +1375,27 @@ playgroundSyncAtom.onMount = (set) => { sourceIdSubs.clear() } } + +// ============================================================================ +// MODULE-EVAL SEED: restore the selection BEFORE the first React commit +// ============================================================================ +// On a hard reload of a playground URL this module evaluates (inside the lazy +// playground chunk) before Playground/MainLayout ever render, but the URL sync +// normally only runs from `urlQuerySyncAtom.onMount` / route events — one commit +// AFTER the first render, so MainLayout paints a one-frame placeholder (gates +// 3.1/3.6). Run the full URL sync here: it applies the exact precedence order +// (#pgSnapshot hash → ?revisions= → persisted last selection → cached latest) +// and every later replay is idempotent — the hash path dedupes via +// lastWrittenSnapshotHash, the revisions path no-ops via applyPlaygroundSelection's +// equality check, and defaults skip when a selection exists. Client-side navs are +// already synchronous (beforeHistoryChange fires before the new page renders). +if (isBrowser) { + try { + const {pathname} = window.location + if (pathname.includes("/playground") && !pathname.includes("/playground-test")) { + syncPlaygroundStateFromUrl() + } + } catch { + // Non-fatal: the onMount/route-event sync replays the same restore later. + } +} From 0aaf921eb69dd0fd74ff79388ce8e1b9f9b80b2c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 16:39:44 +0200 Subject: [PATCH 23/33] perf(frontend): cut session-UI boot render cost (rail latch, hover-gate, row memo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiler-driven (agent playground boot). Three render-hygiene fixes on the session rail/tab surfaces: - Latch-mount SessionRail on first maximize. It sits in a `size={0}` + `inert` splitter panel until the chat is maximized, but was rendered unconditionally — mounting the whole session list (~1000 renders, ~10% of boot React work) plus fetching its lazy chunk, all into a zero-width panel. Now mounted on first open and kept mounted after, so toggling doesn't remount or lose scroll. - Hover-gate the rename/delete/close clusters. Each button drags a Tooltip + Trigger + icon subtree; a full history of rows paid all of that on boot behind `opacity-0` for pixels nobody sees (~288 Button renders). Mount on hover/focus instead; keyboard access preserved via the focusable row + focus-leave guard. - Memoize SessionRailRow/SessionTag with id-taking callbacks and hoisted static icons, so a streamed token or liveness poll no longer re-renders every row (rows went from ~2 re-renders/mount to 0). --- .../AgentChatSlice/AgentChatPanel.tsx | 32 +++-- .../AgentChatSlice/components/SessionRail.tsx | 134 ++++++++++++------ .../components/SessionTagBar.tsx | 99 ++++++++----- 3 files changed, 175 insertions(+), 90 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 4e0658af88..975b6654a3 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,4 +1,4 @@ -import {lazy, Suspense, useEffect, useRef, useState, type CSSProperties} from "react" +import {lazy, Suspense, useCallback, useEffect, useRef, useState, type CSSProperties} from "react" import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {Splitter, Tabs} from "antd" @@ -67,8 +67,20 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { const closeSession = useSetAtom(closeSessionAtomFamily(scope)) const renameSession = useSetAtom(renameSessionAtomFamily(scope)) const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope)) + // Stable identity: the tag bar forwards this straight to each memo'd chip. + const handleRename = useCallback( + (id: string, title: string) => renameSession({id, title}), + [renameSession], + ) const pruneSessionHusks = useSetAtom(pruneSessionHusksAtomFamily(scope)) const chatMaximized = useAtomValue(chatPanelMaximizedAtom) + // The rail pane is `size={0}` + `inert` until maximized, so mounting it on boot renders the + // whole session list (rows, dots, hover actions) into a zero-width panel. Latch it on first + // open and keep it mounted after, so toggling back and forth doesn't remount or lose scroll. + const [railMounted, setRailMounted] = useState(chatMaximized) + useEffect(() => { + if (chatMaximized) setRailMounted(true) + }, [chatMaximized]) // Shared entrance latch: the composer's Reveal plays for the first conversation this // panel mounts; every additional session pane skips it (no per-switch flash). const composerRevealPlayedRef = useRef(false) @@ -158,13 +170,15 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { {/* Rail pane is width-0 unless maximized, so no visible fallback is needed. */} {/* min-w matches RAIL_MIN_WIDTH (Tailwind needs the literal). */} - - - + {railMounted && ( + + + + )}
@@ -211,7 +225,7 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { onAdd={addSession} addDisabled={addLocked} onClose={closeSession} - onRename={(id, title) => renameSession({id, title})} + onRename={handleRename} showSessions={!chatMaximized} extra={ chatMaximized ? undefined : ( diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index 1b6679914d..b49469693b 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from "react" +import {memo, useCallback, useRef, useState} from "react" import {MagnifyingGlass, PencilSimple, Plus, Trash} from "@phosphor-icons/react" import {Button, Empty, Input, Tooltip} from "antd" @@ -26,29 +26,73 @@ import { import SessionTabLabel, {type SessionTabLabelHandle} from "./SessionTabLabel" import {SessionStatusDot} from "./SessionTagBar" +// Static icon elements: an inline `` prop is a fresh element every render, which defeats +// antd Button's own memoization and shows up as a changed `icon` prop on every row. +const PENCIL_ICON = +const TRASH_ICON = + interface SessionRailRowProps { session: AgentChatSession label: string active: boolean - onSelect: () => void - onDelete: () => void - onRename: (title: string) => void + // Id-taking so the parent can pass stable setters; a per-row closure would change identity + // every render and re-render the whole row (Tooltip/Button/status-dot subtree) with it. + onSelect: (id: string) => void + onDelete: (id: string) => void + onRename: (id: string, title: string) => void } /** History row: status dot, label (double-click or pencil to rename), timestamp, with an inspect * action on the active row and hover-revealed rename/delete; collapses its height + gap margin on * enter/exit so nothing snaps. */ -const SessionRailRow = ({ +const SessionRailRow = memo(function SessionRailRow({ session, label, active, onSelect, onDelete, onRename, -}: SessionRailRowProps) => { +}: SessionRailRowProps) { const labelRef = useRef(null) // Hide the action cluster while the inline rename input owns the row, so it gets full width. const [renaming, setRenaming] = useState(false) + // The rename/delete cluster is hover-only. Mount it on hover/focus instead of rendering it + // hidden behind `opacity-0`: each button drags a Tooltip + Trigger + icon subtree, and a full + // history of rows paid all of that on boot for pixels nobody sees. + const [hot, setHot] = useState(false) + const onEnter = useCallback(() => setHot(true), []) + const onLeave = useCallback(() => setHot(false), []) + const onBlurRow = useCallback((e: React.FocusEvent) => { + // Keep the cluster while focus moves INTO it (row → rename button). + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHot(false) + }, []) + const sessionId = session.id + const handleSelect = useCallback(() => onSelect(sessionId), [onSelect, sessionId]) + const handleDelete = useCallback(() => onDelete(sessionId), [onDelete, sessionId]) + const handleRename = useCallback( + (title: string) => onRename(sessionId, title), + [onRename, sessionId], + ) + const startRename = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + labelRef.current?.startEditing() + }, []) + const confirmDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleDelete() + }, + [handleDelete], + ) + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleSelect() + } + }, + [handleSelect], + ) return ( { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - onSelect() - } - }} + onClick={handleSelect} + onKeyDown={onKeyDown} + onMouseEnter={onEnter} + onMouseLeave={onLeave} + onFocus={onEnter} + onBlur={onBlurRow} className={clsx( "group flex cursor-pointer items-center gap-2 rounded-md border border-solid px-2 py-1.5 transition-colors", active ? "ag-surface-selected" : "ag-row-hover border-transparent", @@ -81,7 +124,7 @@ const SessionRailRow = ({ )}
-
- {/* Inspection is build-mode only, so the chat-mode rail has no inspect entry. */} - -
+ {hot && !renaming && ( +
+ {/* Inspection is build-mode only, so the chat-mode rail has no inspect entry. */} + +
+ )}
) -} +}) export interface SessionRailProps { /** The resolved active session id (source of truth for the chat), used for row highlight. */ @@ -153,6 +192,11 @@ const SessionRail = ({activeId, addDisabled = false, className}: SessionRailProp const [query, setQuery] = useState("") const q = query.trim().toLowerCase() + // `openSession`/`deleteSession` are already stable id-taking setters; rename needs a wrapper. + const handleRename = useCallback( + (id: string, title: string) => renameSession({id, title}), + [renameSession], + ) const currentActiveId = activeId ?? resolvedActiveId // Hide never-initiated husks (untitled, no messages) unless they're an open tab — so a blank @@ -233,9 +277,9 @@ const SessionRail = ({activeId, addDisabled = false, className}: SessionRailProp session={session} label={label} active={session.id === currentActiveId} - onSelect={() => openSession(session.id)} - onDelete={() => deleteSession(session.id)} - onRename={(title) => renameSession({id: session.id, title})} + onSelect={openSession} + onDelete={deleteSession} + onRename={handleRename} /> ))} diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx index 436747e1a3..1b14f700c1 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from "react" +import {memo, useCallback, useEffect, useRef, useState} from "react" import {PencilSimple, Plus, X} from "@phosphor-icons/react" import {Button, Tooltip} from "antd" @@ -78,6 +78,10 @@ export const SessionStatusDot = ({ ) } +// Static icon elements — see the note in SessionRail: an inline `` is a new prop each render. +const PENCIL_ICON = +const X_ICON = + interface SessionTagProps { session: AgentChatSession index: number @@ -86,15 +90,17 @@ interface SessionTagProps { /** True when this session already existed at the bar's first mount (reload restore) — an * activation here jumps instantly; a session added afterwards keeps the smooth scroll. */ presentAtMount: boolean - onSelect: () => void - onClose: () => void - onRename: (title: string) => void + // Id-taking so the bar can forward its own stable setters straight through; per-chip closures + // would change identity every render and drag each chip's Tooltip/Button subtree with them. + onSelect: (id: string) => void + onClose: (id: string) => void + onRename: (id: string, title: string) => void } /** One session chip: status dot + truncated label (double-click or pencil to rename) + hover * actions. The rename/close buttons float OVER the label's tail (Chrome-tab style) instead of * reserving in-flow width, so revealing them on hover never reflows the label or shifts pixels. */ -const SessionTag = ({ +const SessionTag = memo(function SessionTag({ session, index, active, @@ -103,13 +109,47 @@ const SessionTag = ({ onSelect, onClose, onRename, -}: SessionTagProps) => { +}: SessionTagProps) { const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) const label = session.title || text || `Chat ${index + 1}` const tabRef = useRef(null) const labelRef = useRef(null) // Hide the hover actions while the inline rename input owns the row. const [renaming, setRenaming] = useState(false) + // Mount the hover actions on hover/focus rather than rendering them behind `opacity-0` — see + // the matching note in SessionRail: each button carries a Tooltip + Trigger + icon subtree. + const [hot, setHot] = useState(false) + const onEnter = useCallback(() => setHot(true), []) + const onLeave = useCallback(() => setHot(false), []) + const onBlurChip = useCallback((e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHot(false) + }, []) + const sessionId = session.id + const handleSelect = useCallback(() => onSelect(sessionId), [onSelect, sessionId]) + const handleRename = useCallback( + (title: string) => onRename(sessionId, title), + [onRename, sessionId], + ) + const startRename = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + labelRef.current?.startEditing() + }, []) + const handleClose = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onClose(sessionId) + }, + [onClose, sessionId], + ) + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleSelect() + } + }, + [handleSelect], + ) // Keep the active tab visible. Jump INSTANTLY only on the bar's initial reveal of a session that // was already present at mount (reload restoring a far-away active tab) — the strip's scroll-smooth // would otherwise play a long scroll across the whole strip. A session added later, or any user @@ -157,13 +197,12 @@ const SessionTag = ({ role="tab" aria-selected={active} tabIndex={0} - onClick={onSelect} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - onSelect() - } - }} + onClick={handleSelect} + onKeyDown={onKeyDown} + onMouseEnter={onEnter} + onMouseLeave={onLeave} + onFocus={onEnter} + onBlur={onBlurChip} className={clsx( // Floor the width so short labels ("hi") still leave a clickable label zone to the // left of the hover actions (rename/close overlay the right ~58px) — otherwise a @@ -180,21 +219,15 @@ const SessionTag = ({ {/* Hover actions overlay the label's tail — absolutely positioned so no width is reserved at rest (no pixel shift). The gradient fades the covered text out under the buttons instead of hard-clipping it. */} - {!renaming && ( -
+ {hot && !renaming && ( +
} - onClick={(e) => { - e.stopPropagation() - labelRef.current?.startEditing() - }} + icon={PENCIL_ICON} + onClick={startRename} className="!h-5 !w-5 !min-w-0 shrink-0 !p-0" /> @@ -216,11 +246,8 @@ const SessionTag = ({
) -} +}) export interface SessionTagBarProps { sessions: AgentChatSession[] @@ -349,9 +376,9 @@ const SessionTagBar = ({ active={session.id === activeId} closable={closable} presentAtMount={presentAtMountRef.current.has(session.id)} - onSelect={() => onSelect(session.id)} - onClose={() => onClose(session.id)} - onRename={(title) => onRename(session.id, title)} + onSelect={onSelect} + onClose={onClose} + onRename={onRename} /> ))} From f62f27d2506877e5b2ffe6bcdecfab5ac9fa1e3f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 16:39:58 +0200 Subject: [PATCH 24/33] perf(frontend): dedup + stop cancelling session/workflow boot queries HAR-driven (agent playground boot). - revalidateSessionRecordsAtom now invalidates with `cancelRefetch: false`, mirroring revalidateSessionMountsAtom. A turn finishing (incl. the SDK auto-resuming the restored last turn on reload) fired this while the first records fetch was still in flight; the default cancelled it and started an identical request. Removed the cancelled sessions/records (+ mounts) pair. - workflowDetailQueryAtomFamily and workflowArtifactScopedQueryAtomFamily issue the same POST /workflows/query (same body, different cache key). Cross-prime each other's key so whichever runs first satisfies the other, removing the duplicate by-id workflows/query on boot. --- .../src/session/state/records.ts | 10 +++++--- .../src/workflow/state/store.ts | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-entities/src/session/state/records.ts b/web/packages/agenta-entities/src/session/state/records.ts index ee4e921052..37e88c506f 100644 --- a/web/packages/agenta-entities/src/session/state/records.ts +++ b/web/packages/agenta-entities/src/session/state/records.ts @@ -111,7 +111,11 @@ export const sessionRecordFileRecencyAtomFamily = atomFamily((sessionId: string) export const revalidateSessionRecordsAtom = atom(null, (get, _set, sessionId: string) => { const projectId = get(projectIdAtom) ?? "" if (!projectId || !sessionId) return - void get(queryClientAtom).invalidateQueries({ - queryKey: sessionRecordsQueryKey(projectId, sessionId), - }) + // `cancelRefetch: false` — mirrors `revalidateSessionMountsAtom`. A turn finishing (incl. the + // SDK auto-resuming the restored last turn on reload) fires this while the FIRST records fetch + // is still in flight; the default cancels that request and starts an identical one. + void get(queryClientAtom).invalidateQueries( + {queryKey: sessionRecordsQueryKey(projectId, sessionId)}, + {cancelRefetch: false}, + ) }) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index ec201b3ca7..797d9fef0c 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -449,6 +449,7 @@ export const appWorkflowsListQueryAtom = atomWithQuery((get) => { export const workflowDetailQueryAtomFamily = atomFamily((workflowId: string | null) => atomWithQuery((get) => { const projectId = get(workflowProjectIdAtom) + const queryClient = get(queryClientAtom) return { queryKey: ["workflows", "detail", projectId, workflowId], queryFn: async (): Promise => { @@ -463,7 +464,17 @@ export const workflowDetailQueryAtomFamily = atomFamily((workflowId: string | nu workflowRefs: [{id: workflowId}], includeArchived: true, }) - return (response.workflows?.[0] as Workflow | undefined) ?? null + const workflow = (response.workflows?.[0] as Workflow | undefined) ?? null + // Same endpoint + body as the artifact query, under a different key. Prime it so + // whichever of the two runs first satisfies the other (this fired twice on boot: + // once from the boot prewarm, once when the playground mounted). + if (workflow?.id) { + queryClient.setQueryData( + ["workflows", "artifact", workflow.id, projectId], + workflow, + ) + } + return workflow }, enabled: get(sessionAtom) && !!projectId && !!workflowId, staleTime: 30_000, @@ -1077,11 +1088,19 @@ const workflowArtifactBatchFetcher = createBatchFetcher - atomWithQuery(() => ({ + atomWithQuery((get) => ({ queryKey: ["workflows", "artifact", workflowId, projectId], queryFn: async (): Promise => { if (!projectId || !workflowId) return null - return workflowArtifactBatchFetcher({projectId, workflowId}) + const workflow = await workflowArtifactBatchFetcher({projectId, workflowId}) + // Reverse of the priming in `workflowDetailQueryAtomFamily`: same request, other key. + if (workflow) { + get(queryClientAtom).setQueryData( + ["workflows", "detail", projectId, workflowId], + workflow, + ) + } + return workflow }, enabled: !!projectId && From 622697ec0bc9c953e0216a0be078b0b523a66fd8 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 16:59:33 +0200 Subject: [PATCH 25/33] perf(frontend): persist latest-revision query to IndexedDB (paint-from-disk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of persisting playground list queries. The revision BODY was already disk-served (immutablePersister), but a warm reload still blocked on the latest-revision round-trip (1.7-3.2s on a slow backend) to resolve the fallback selector, the "latest" tag, and the dirty/commit affordance. - Attach catalogPersister to workflowLatestRevisionQueryAtomFamily: a warm reload paints the last-known latest from disk, then fires exactly one background revalidate (restored entry is older than the 30s staleTime). Live commits stay correct — invalidateAgentCommittedRevisionCache prefix-invalidates this key on the data-committed-revision stream signal. - The two setQueryData prime paths (revisions-by-workflow queryFn, and the commit handler) bypass the persister, so they now also persistQueryByKey to IDB — otherwise a session that only ever primed the latest (dedicated query disabled) would leave nothing on disk to restore. Mirrors primeWorkflowRevisionDetailCache. Class-B semantics: bounded staleness only on cold reload — a revision committed on another device is briefly absent until the single revalidation lands. --- .../src/workflow/state/store.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 797d9fef0c..c86aab8be4 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -651,10 +651,13 @@ export const workflowRevisionsByWorkflowQueryAtomFamily = atomFamily((workflowId const revisions = response.workflow_revisions ?? [] const latestByRecency = pickMostRecentWorkflowRevision(revisions) if (latestByRecency) { - queryClient.setQueryData( - ["workflows", "latestRevision", workflowId, projectId], - latestByRecency, - ) + const latestKey = ["workflows", "latestRevision", workflowId, projectId] + queryClient.setQueryData(latestKey, latestByRecency) + // setQueryData bypasses the persister; mirror to IDB so a warm reload paints the + // latest from disk even when this prime keeps the dedicated query disabled. + void catalogPersister + .persistQueryByKey(latestKey, queryClient) + .catch(() => undefined) // Persist agent-ness for the next cold load (playgroundEarlyAgentStateAtom). // This priming DISABLES the dedicated latest-revision query (its only other // writer), so without writing here the map starves and every cold load @@ -954,6 +957,12 @@ export const workflowLatestRevisionQueryAtomFamily = atomFamily((workflowId: str initialData: detailCached ?? undefined, enabled: get(sessionAtom) && !!projectId && !!workflowId && !detailCached, staleTime: 30_000, + // Class B paint-fast: a warm reload resolves the fallback selector + "latest" tag from + // disk instead of blocking on the (backend-slow) revisions round-trip; the restored + // entry is older than staleTime so exactly one background revalidate fires. Live commits + // stay correct via invalidateAgentCommittedRevisionCache (prefix-invalidates this key). + // The setQueryData prime paths below bypass the persister, so they mirror to disk too. + persister: catalogPersister.persisterFn, } }), ) @@ -2691,7 +2700,11 @@ export function seedCreatedWorkflowCache( store.set(workflowLocalServerDataAtomFamily(revision.id), revision) queryClient.setQueryData(["workflows", "revision", revision.id, projectId], revision) - queryClient.setQueryData(["workflows", "latestRevision", appId, projectId], revision) + const latestKey = ["workflows", "latestRevision", appId, projectId] + queryClient.setQueryData(latestKey, revision) + // Mirror to IDB (setQueryData bypasses the persister) so a warm reload after a commit paints + // the just-committed latest from disk. + void catalogPersister.persistQueryByKey(latestKey, queryClient).catch(() => undefined) queryClient.setQueryData( ["workflows", "revisionsByWorkflow", appId, projectId], From 58e9b3ac44220eb36aaabb7ba75ec9f26fa59a24 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 17:11:46 +0200 Subject: [PATCH 26/33] test(frontend): pin isFetched-under-restore semantics (Phase 2 gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before persisting the workflow variant/revision LIST queries, establish what the selection auto-select logic will see when a list paints from disk. - persist.test.ts: a catalog-restored list query reports isPending:false (so isPending-gated consumers never flash on the background revalidate) but isFetched:true — EVEN for an empty restored list. - autoSelectLatestChild.restore.test.ts: feeds those restore-shaped states into the real resolveAutoSelectLatestChild. Non-empty restore selects the first child; an EMPTY restore returns "complete" with no selection (the isFetched===false wait-guard is bypassed by restore) instead of waiting for the revalidate that brings the real revision. Gate result: persisting the list queries is NOT a blind persister-add — the auto-select path must distinguish restored-but-revalidating from settled first. --- .../autoSelectLatestChild.restore.test.ts | 48 +++++++++++ .../agenta-shared/tests/unit/persist.test.ts | 85 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 web/packages/agenta-entity-ui/tests/unit/autoSelectLatestChild.restore.test.ts diff --git a/web/packages/agenta-entity-ui/tests/unit/autoSelectLatestChild.restore.test.ts b/web/packages/agenta-entity-ui/tests/unit/autoSelectLatestChild.restore.test.ts new file mode 100644 index 0000000000..06eeb9140c --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/autoSelectLatestChild.restore.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from "vitest" + +import {resolveAutoSelectLatestChild} from "../../src/selection/hooks/modes/autoSelectLatestChild" + +/** + * Phase-2 gate (consequence half). The companion persister test in @agenta/shared proves that a + * catalog-restored list query reports `isPending: false` but `isFetched: true` — EVEN when the + * restored list is empty. These cases feed exactly those restore-shaped states into the real + * auto-select resolver to show what the current logic decides, so we know whether persisting the + * variant/revision LIST queries is a clean add or needs a selection-hook change first. + */ +const getId = (c: {id: string}) => c.id + +describe("resolveAutoSelectLatestChild under persister restore", () => { + it("non-empty restore (isPending:false, isFetched:true, children present) → selects first", () => { + // A warm reload restores the revision list from disk; the newest is first. + const decision = resolveAutoSelectLatestChild({ + children: [{id: "rev-2"}, {id: "rev-1"}], + query: {isPending: false, isError: false, error: null, isFetched: true}, + getId, + }) + expect(decision).toEqual({status: "select", child: {id: "rev-2"}}) + }) + + it("HAZARD: empty restore (isFetched:true, no children) COMPLETES with no selection", () => { + // Restore hydrated isFetched:true from disk, but the list is empty (persisted before any + // revision, or a stale-empty entry). A background revalidate is about to bring the real + // revision — but the resolver has already given up, because the `isFetched === false` + // wait-guard only holds for a never-fetched query, not a restored one. + const decision = resolveAutoSelectLatestChild({ + children: [], + query: {isPending: false, isError: false, error: null, isFetched: true}, + getId, + }) + // Documents the current (unsafe-for-persistence) behavior: it does NOT wait. + expect(decision).toEqual({status: "complete"}) + }) + + it("CONTRAST: cold empty (isFetched:false, no children) correctly waits", () => { + // The pre-persistence path: a never-fetched empty list still waits for the query to settle. + const decision = resolveAutoSelectLatestChild({ + children: [], + query: {isPending: false, isError: false, error: null, isFetched: false}, + getId, + }) + expect(decision).toEqual({status: "wait"}) + }) +}) diff --git a/web/packages/agenta-shared/tests/unit/persist.test.ts b/web/packages/agenta-shared/tests/unit/persist.test.ts index 7504e62eed..f54ced3e6a 100644 --- a/web/packages/agenta-shared/tests/unit/persist.test.ts +++ b/web/packages/agenta-shared/tests/unit/persist.test.ts @@ -345,6 +345,91 @@ describe("recordsPersister (session records)", () => { }) }) +// Phase-2 gate: before persisting the workflow LIST queries (variants / revisions), establish +// what `isPending`/`isFetched`/`isStale` look like on a catalog-restored list query — those are +// exactly the flags the selection auto-select logic (resolveAutoSelectLatestChild) reads. The +// decision consequence is asserted against the real function in @agenta/entity-ui; this pins the +// upstream fact the persister produces. +describe("catalog list-query restore — flags at restore (Phase 2 gate)", () => { + it("non-empty restore: isPending false, isFetched TRUE, data present before any revalidate", async () => { + const key: QueryKey = ["workflows", "revisionsByWorkflow", "wf-1", "proj-1"] + const diskList = {count: 2, refs: [{id: "rev-2"}, {id: "rev-1"}]} + await idbQueryStorage.setItem( + catStorageKey(key), + makePersisted(key, diskList, Date.now() - 120_000), // stale → will revalidate + ) + + const clientB = newClient() + const spy = vi.fn(async () => ({count: 3, refs: [{id: "rev-3"}, {id: "rev-2"}, {id: "rev-1"}]})) + const observer = new QueryObserver(clientB, { + queryKey: key, + queryFn: spy, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 30_000, + retry: false, + }) + + // Snapshot the observer state at the FIRST emission carrying restored data — i.e. what a + // consumer sees the instant paint-from-disk lands, independent of the background refetch. + let atRestore: ReturnType["getCurrentResult"]> | null = null + const unsubscribe = observer.subscribe((result) => { + if (atRestore === null && result.data !== undefined) atRestore = result + }) + + try { + await vi.waitFor(() => expect(atRestore).not.toBeNull()) + const r = atRestore! + expect(r.data).toEqual(diskList) // painted from disk, not from the network spy + expect(r.isPending).toBe(false) // ← SWR-safe: isPending consumers never flash + expect(r.isFetched).toBe(true) // ← the finding: restore marks the query fetched + // ...and the background revalidate still fires exactly once (restored entry was stale). + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)) + } finally { + unsubscribe() + } + }) + + it("EMPTY restore also reports isFetched TRUE — the auto-select hazard", async () => { + const key: QueryKey = ["workflows", "revisionsByWorkflow", "wf-empty", "proj-1"] + const emptyList = {count: 0, refs: [] as {id: string}[]} + await idbQueryStorage.setItem( + catStorageKey(key), + makePersisted(key, emptyList, Date.now() - 120_000), + ) + + const clientB = newClient() + const spy = vi.fn(async () => ({count: 1, refs: [{id: "rev-1"}]})) + const observer = new QueryObserver(clientB, { + queryKey: key, + queryFn: spy, + persister: asPersister(catalogPersister.persisterFn), + staleTime: 30_000, + retry: false, + }) + + let atRestore: ReturnType["getCurrentResult"]> | null = null + const unsubscribe = observer.subscribe((result) => { + if (atRestore === null && result.data !== undefined) atRestore = result + }) + + try { + await vi.waitFor(() => expect(atRestore).not.toBeNull()) + const r = atRestore! + expect(r.data).toEqual(emptyList) + expect(r.isPending).toBe(false) + // An empty disk list restores as fetched:true — so resolveAutoSelectLatestChild's + // `isFetched === false` wait-guard is bypassed and it would COMPLETE with no selection + // before the revalidate below brings the real revision. This is the gate result: Phase 2 + // needs a selection-hook change (distinguish restored-but-revalidating from settled), + // NOT a blind persister-add. + expect(r.isFetched).toBe(true) + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)) + } finally { + unsubscribe() + } + }) +}) + describe("buster mismatch", () => { it("discards the stale entry, fetches, and re-persists with the current buster", async () => { const key: QueryKey = ["imm", "busted"] From b028a1ce774627801ec7e6334dcce459d628ce5c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 20:49:46 +0200 Subject: [PATCH 27/33] =?UTF-8?q?fix(frontend):=20remove=20the=20persist?= =?UTF-8?q?=20kill=20switch=20=E2=80=94=20persistence=20is=20always=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `agenta:persist:disable` localStorage flag silently no-oped every IndexedDB read/write while GC kept reporting entries, so a flag left set from earlier A/B debugging disabled the whole paint-from-disk layer with no visible signal — every reload went cold and the persistence work looked like it did nothing. Persistence was already default-on (the flag only had to be absent); removing the check makes it unconditional and self-heals any browser with a stale flag still in localStorage. Drops isPersistDisabled + the two getItem/setItem guards + its test. The debug flag (agenta:persist:debug) stays. --- .../agenta-shared/src/api/persist/debug.ts | 10 ------ .../src/api/persist/idbStorage.ts | 6 ++-- .../tests/unit/persistNullGuard.test.ts | 31 +------------------ 3 files changed, 4 insertions(+), 43 deletions(-) diff --git a/web/packages/agenta-shared/src/api/persist/debug.ts b/web/packages/agenta-shared/src/api/persist/debug.ts index 8548916108..b35204e3be 100644 --- a/web/packages/agenta-shared/src/api/persist/debug.ts +++ b/web/packages/agenta-shared/src/api/persist/debug.ts @@ -1,7 +1,6 @@ import type {PersistedQuery} from "@tanstack/query-persist-client-core" const DEBUG_FLAG = "agenta:persist:debug" -const DISABLE_FLAG = "agenta:persist:disable" /** Enable via `localStorage.setItem("agenta:persist:debug", "1")` + reload; disable with removeItem. */ export const isPersistDebugEnabled = (): boolean => { @@ -12,15 +11,6 @@ export const isPersistDebugEnabled = (): boolean => { } } -/** Kill switch for A/B debugging: `localStorage.setItem("agenta:persist:disable", "1")` + reload. */ -export const isPersistDisabled = (): boolean => { - try { - return typeof localStorage !== "undefined" && localStorage.getItem(DISABLE_FLAG) === "1" - } catch { - return false - } -} - const shortKey = (key: string): string => { // Key format: `${prefix}-${queryHash}`; the hash is the stringified queryKey. const dash = key.indexOf("-[") diff --git a/web/packages/agenta-shared/src/api/persist/idbStorage.ts b/web/packages/agenta-shared/src/api/persist/idbStorage.ts index c04d5def09..44106802b7 100644 --- a/web/packages/agenta-shared/src/api/persist/idbStorage.ts +++ b/web/packages/agenta-shared/src/api/persist/idbStorage.ts @@ -2,7 +2,7 @@ import type {AsyncStorage, PersistedQuery} from "@tanstack/query-persist-client- import {clear, createStore, del, entries, get, set} from "idb-keyval" import type {UseStore} from "idb-keyval" -import {isPersistDisabled, persistLog} from "./debug" +import {persistLog} from "./debug" const DB_NAME = "agenta-query-cache" const STORE_NAME = "queries" @@ -28,7 +28,7 @@ const hasPersistableData = (value: PersistedQuery): boolean => export const idbQueryStorage: AsyncStorage = { getItem: async (key) => { const s = getStore() - if (!s || isPersistDisabled()) return undefined + if (!s) return undefined try { const value = await get(key, s) if (value !== undefined && !hasPersistableData(value)) { @@ -44,7 +44,7 @@ export const idbQueryStorage: AsyncStorage = { }, setItem: async (key, value) => { const s = getStore() - if (!s || isPersistDisabled()) return + if (!s) return if (!hasPersistableData(value)) { persistLog("skip", key) return diff --git a/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts b/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts index 9e0210eec5..2756f66d57 100644 --- a/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts +++ b/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts @@ -5,7 +5,7 @@ import type {PersistedQuery} from "@tanstack/query-persist-client-core" import {hashKey} from "@tanstack/react-query" import type {QueryKey} from "@tanstack/react-query" import {createStore, get, set} from "idb-keyval" -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" +import {beforeEach, describe, expect, it, vi} from "vitest" import {clearPersistedQueryCache, idbQueryStorage} from "../../src/api/persist/idbStorage" import {PERSIST_SCHEMA_VERSION} from "../../src/api/persist/version" @@ -61,32 +61,3 @@ describe("nullish-data guard", () => { }) }) }) - -describe("kill switch (agenta:persist:disable)", () => { - const disabledStorage = { - getItem: (k: string) => (k === "agenta:persist:disable" ? "1" : null), - setItem: () => undefined, - removeItem: () => undefined, - } - - beforeEach(() => { - vi.stubGlobal("localStorage", disabledStorage) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it("reads miss and writes no-op while the flag is set", async () => { - await idbQueryStorage.setItem("agenta-imm-off", makePersisted(["k6"], {a: 1})) - expect(await get("agenta-imm-off", rawStore)).toBeUndefined() - - // Plant a real entry: reads must still miss while disabled - await set("agenta-imm-present", makePersisted(["k7"], {b: 2}), rawStore) - expect(await idbQueryStorage.getItem("agenta-imm-present")).toBeUndefined() - - // Entry survives (kill switch hides, does not destroy) - vi.unstubAllGlobals() - expect(await idbQueryStorage.getItem("agenta-imm-present")).toBeTruthy() - }) -}) From d9f64394a1a646b7c3d1578e0db142f70cfa6c05 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 20 Jul 2026 21:20:10 +0200 Subject: [PATCH 28/33] docs(design): branch summary for the data & render optimizations work What was tackled (persistence layer, cold-load request reduction, boot render gates, session-UI render cost, request dedup, latest-revision persistence), what was deliberately left alone and why (Phase 2 list persistence, the sidebar Menu, deep config-panel work, org persistence, the billing 502, the serial boot gate chain), and the learnings worth not relearning. Explicitly scoped OUT: the drive/file/mount surfaces, which ship separately in PR #5400 that this branch is stacked on. --- docs/design/data-optimizations/README.md | 230 +++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/design/data-optimizations/README.md diff --git a/docs/design/data-optimizations/README.md b/docs/design/data-optimizations/README.md new file mode 100644 index 0000000000..6e17d16deb --- /dev/null +++ b/docs/design/data-optimizations/README.md @@ -0,0 +1,230 @@ +# Data & render optimizations — branch summary + +Branch: `fe-refactor/data-optimizations` +Status: **built and profiler/HAR-verified** (not yet merged) +Scope: warm-reload and cold-load cost of the web app, with the **agent playground** as the +reference route. + +**Out of scope for this branch:** the drive / file / mount surfaces (virtualization, the unified +Files drawer, the paged listing endpoint). That work ships separately in +[PR #5400](https://github.com/Agenta-AI/agenta/pull/5400), which this branch is stacked on. No +commit here touches `Drives/`, `mounts`, or `/files`. + +Companion docs (deeper detail on two sub-efforts): + +- [app-boot-optimizations/plan.md](../app-boot-optimizations/plan.md) — the serial boot gate chain +- [app-boot-optimizations/gates.md](../app-boot-optimizations/gates.md) — render-gate ladder audit +- [playground-query-persistence/plan.md](../playground-query-persistence/plan.md) — the IndexedDB layer + +--- + +## What we tackled + +### 1. Per-query IndexedDB persistence (paint-from-disk) + +A per-query persister layer over TanStack Query, backed by one IndexedDB database +(`agenta-query-cache` / store `queries`), with four classes: + +| persister | prefix | maxAge | on restore | used for | +|---|---|---|---|---| +| `immutablePersister` | `agenta-imm` | ∞ | never refetch | immutable-by-key bodies (workflow revisions, trace summaries) | +| `catalogPersister` | `agenta-cat` | 14d | one revalidate if stale | catalogs/schemas/profile/projects/workflow detail | +| `recordsPersister` | `agenta-rec` | 7d | **always** revalidate | session record logs (append-only, disk never authoritative) | +| `vaultSecretsPersister` | `agenta-vault` | — | **always** revalidate | LLM provider rows, **redacted** before write | + +Everything not on that list is memory-only and refetched per cold load. + +### 2. Cold-load request reduction + +HAR-driven removal of redundant and head-of-line requests: duplicate by-id workflow fetches, +an eager revisions-list fetch, a per-reload-rotating inspect persist key, session records +fetched 3×, and null-fetch stragglers. Secondary reads demoted to `priority: "low"`. + +### 3. Boot render-gate hygiene + +Latching the protected-route gate, prewarming the boot atom graph and the serialized boot chunks, +un-gating org detail from `/profile`, config-accordion render hygiene, naming anonymous +`forwardRef`s, and skipping the Tooltip wrapper on tooltip-less buttons. Detail in the +companion docs. + +### 4. Session-UI boot render cost (`e3495d2292`) + +Three fixes on the session rail/tab surfaces, all profiler-confirmed: + +- **Latch-mount `SessionRail`.** It lives in a `size={0}` + `inert` splitter panel until the chat is + maximized, but was rendered unconditionally — mounting the whole session list (~1000 renders, + **10–15% of all boot React work**) plus fetching its lazy chunk, into a zero-width panel. Now + mounted on first open and kept mounted after. +- **Hover-gate the rename/delete/close clusters.** Each button drags a Tooltip + Trigger + icon + subtree; a full history of rows paid all of it on boot behind `opacity-0` — **984 renders, + 7.8–12.3% of React work**, for pixels nobody sees. Now mounted on hover/focus, with keyboard + access preserved via the focusable row plus a focus-leave guard. +- **Memoize the rows** (`SessionRailRow`, `SessionTag`) with id-taking callbacks and hoisted static + icon elements, so a streamed token or liveness poll no longer re-renders every row. + +Measured (same session, baseline → after): `Button` 375 → 87 renders, `Tooltip` 439 → 177, +`IconBase` 538 → 258, `SessionStatusDot` 176 → 50. Both row components went from ~2 re-renders per +mount to **0**, reproducible across three independent captures. + +### 5. Request dedup + stop cancelling boot queries (`7f4905a0df`) + +- `revalidateSessionRecordsAtom` invalidated with TanStack's default `cancelRefetch: true`, killing + an in-flight records fetch and restarting an identical one. It fires from `onFinish` when the SDK + auto-resumes the restored last turn on reload — i.e. every warm boot. Now `cancelRefetch: false`, + matching `revalidateSessionMountsAtom`, whose comment documents the same hazard. +- `workflowDetailQueryAtomFamily` and `workflowArtifactScopedQueryAtomFamily` issue the **same** + `POST /workflows/query` (same body) under two cache keys. They now cross-prime each other, so + whichever runs first satisfies the other. + +### 6. Latest-revision persistence — "Phase 1" (`96b7f1363f`) + +The revision *body* was already disk-served, but a warm reload still blocked on the latest-revision +round-trip (1.7–5 s on a slow backend) to resolve the fallback selector, the "latest" tag, and the +dirty/commit affordance. Now `catalogPersister` is attached to +`workflowLatestRevisionQueryAtomFamily`, **plus** the two `setQueryData` prime paths mirror to IDB +via `persistQueryByKey` — without that, a session that only ever *primed* the latest (dedicated +query disabled) would leave nothing on disk to restore. + +Verified: the blocking `workflows/revisions/query` is **absent** from the warm-reload HAR. + +### 7. Removed the persistence kill switch (`f19395f798`) + +See *Learnings* — this is the one that made everything else actually work. + +--- + +## What we deliberately did **not** tackle + +- **Phase 2 — persisting the variant/revision *list* queries.** Scoped and gated, then parked. Two + reasons: once Phase 1 landed, the lists were no longer on the critical path (they're fast, + Low-priority background fetches), and the gate test proved it is *not* a clean persister-add — see + the `isFetched` learning below. Revisit only if the switcher is measurably slow. +- **The sidebar `Menu` churn.** Attempted and **reverted**. Memoizing inside `SidebarMenu` was a + no-op because the inputs are unstable upstream (`menuProps` is a fresh object literal per render; + `section.items` comes from `scope.useSections()`, which rebuilds every render). A real fix means + refactoring the sidebar scope engine — its churn is also partly legitimate (route/`openKeys` + resolving during boot). Not worth the risk/value. +- **Deep config-panel work** (`AgentTemplateControl`, `ConfigAccordionSection`, + `SchemaPropertyRenderer` — still the largest React cost). `sections` is a ~20-dependency inline + array; wholesale `useMemo` is a real stale-UI risk on core config code. We verified the collapsed + bodies aren't the prize either (the heavy sections are drawer-openers that render `children` as + `null`). Squeezing further means structural work, not pattern-reuse. +- **Persisting `organizations` / `organizations/{id}`.** High-priority and unpersisted, so they're + candidates — but org data drives workspace/permissions, so stale-then-revalidate has a real + correctness surface. Deferred pending evidence it's a felt cost. +- **The `billing/subscription` 502.** Investigated, no code change needed. The FE already handles it + deliberately: never retries 502/503/504, deferred to browser idle at Low priority, degrades to + hobby/free, and is config-gated behind `isBillingEnabled()`. The 502 is a **local EE dev artifact** + (billing flagged on via `NEXT_PUBLIC_AGENTA_BILLING_ENABLED` while Stripe/the billing service + isn't configured); the handler itself returns a clean 404 for "no subscription". +- **The serial boot gate chain** (`profile → organizations → project-scoped queries → mounts`). This + is the single biggest remaining lever, but it belongs to the app-boot workstream, not the data + layer. Partly addressed (org detail un-gated from `/profile`); the rest is a separate project. +- **`sessions/mounts` + `sessions/records` cancelled pair (unmount variant).** The + *invalidate*-triggered cancel is fixed. A second variant — both queries aborted mid-flight by a + component unmount, then cleanly refired — appeared in slow-backend captures but not in the healthy + warm one. Not root-caused. + +--- + +## Important learnings + +### A silent kill switch disabled the entire persistence layer + +**This cost the most time by far.** The layer was built but never live-verified, and a stale +`localStorage["agenta:persist:disable"] = "1"` flag (from earlier A/B debugging) made every +IndexedDB read and write a silent no-op. The failure was invisible because: + +- `getItem`/`setItem` **early-returned before logging**, so the debug output showed nothing. +- `entries()` (used by GC) **did not check the flag**, so GC kept happily reporting + `[persist] GC 17 entries` — proving data existed on disk while nothing could read it. + +Every "warm" reload was therefore cold, and every measurement of the persistence work read as +"it does nothing." The fix was to **remove the kill switch entirely** — persistence is now +unconditional, and removing the *check* (not just the flag) self-heals any browser that still has +the stale flag set. + +**Takeaway:** a debug kill switch that fails silently is worse than no kill switch. If one must +exist, it has to be loud. + +### Verify the layer runs before optimizing on top of it + +We shipped Phase 1 and measured it against captures where persistence wasn't executing at all — +and nearly concluded the work was ineffective. Confirm the foundation is live (one `[persist] HIT` +line would have done it) before attributing results to changes built on it. + +### `isPending` vs `isFetching` — most consumers were already SWR-safe + +The worry that paint-from-disk would be undone by components blanking to skeletons on the background +revalidate turned out to be **mostly unfounded**. TanStack v5's `isPending` is `false` whenever data +exists, including mid-revalidate, and the codebase gates on `isPending` almost everywhere +(`isFetching` appears once, off the reload path). `PlaygroundConfigSection` even belt-and-suspenders +it: `schemaQuery.isPending && !hasRenderableConfigSections(activeData)`. + +The audit was worth doing — but the correct outcome was **"change 2 selection hooks, not 30 +components."** + +### `isFetched` is `true` after a persister restore — even for an empty list + +The Phase 2 gate test ([persist.test.ts](../../../web/packages/agenta-shared/tests/unit/persist.test.ts), +[autoSelectLatestChild.restore.test.ts](../../../web/packages/agenta-entity-ui/tests/unit/autoSelectLatestChild.restore.test.ts)) +pinned this: a restored query reports `isPending: false` (good) but `isFetched: true` — because the +persisted state carries `dataUpdateCount: 1`. That bypasses `resolveAutoSelectLatestChild`'s +`isFetched === false` wait-guard, so an **empty** restore returns `"complete"` with no selection +instead of waiting for the revalidate that brings the real revision. This is a *correctness* hazard, +not a visual one, and it's why Phase 2 is not a blind persister-add. + +### `setQueryData` bypasses the persister + +Anything primed imperatively never reaches disk. Both the revision-body cache and the +latest-revision cache need an explicit `persistQueryByKey` mirror alongside their `setQueryData` +calls, or the disk copy silently never exists. + +### Measure render *counts*, not self-time, across runs + +Per-render self time moved 1.25–2.3× between captures purely from backend/machine contention, which +twice led to a wrong conclusion (once "Button regressed 2x" — it hadn't). Render **counts** and +**re-renders-per-mount** are contention-independent and were the only reliable cross-run metric. +Normalizing self-time against a global mean is *not* valid: the global mean is dominated by trivial +fibers that inflate differently than heavy components. + +### Hidden UI is not free + +Two of the largest wins were subtrees that were never visible: a whole session rail mounted into a +zero-width panel, and ~106 hover-action buttons rendered behind `opacity-0`. CSS-hiding +(`opacity-0`, `width: 0`, `inert`) still costs full mount + reconciliation. Gate on *mount*, not on +*paint*. + +### Don't memoize without verifying the inputs are stable + +The reverted sidebar change: a `useMemo`/`useCallback` whose dependencies change every render is +dead weight. Always confirm the upstream props are referentially stable first — the profiler will +show it (`changed props: {menuProps, items, …}` every commit). + +### Tooling notes + +- **React DevTools profiler exports** use variable-length opcodes in the `operations` stream that + break naive parsers. A tolerant decoder (resync by trying candidate skips and validating a + lookahead) is needed to recover fiber names — without it, everything reads as `#1234`. +- **Never `rm -rf node_modules/.pnpm` alone.** It empties the virtual store but leaves pnpm's state + file, after which every `pnpm install` reports "Already up to date" and refuses to rematerialize, + leaving all binaries as dangling symlinks. Use `pnpm install --force`, or remove all of + `node_modules`. +- A **duplicated `@tanstack/query-core`** would put the persister and the app's QueryClient on + different instances and silently break restore. `pnpm-workspace.yaml` pins it to a single version; + keep it in lockstep with `@tanstack/react-query`. + +--- + +## Verification status + +| area | status | +|---|---| +| Session-UI render hygiene | **Verified** — 5 profiler captures, reproducible | +| Request dedup / cancel fix | **Verified** — cancelled pair absent from warm HAR | +| Phase 1 latest-revision persistence | **Verified** — `[persist] HIT` logs + blocking request gone | +| Persistence layer end-to-end | **Verified** — HITs for profile, projects, detail, body, records, vault | +| Phase 2 | Not built (gate tests only) | + +Known pre-existing failure on the base PR, not from this branch: `session-mounts-store.test.ts` +(2 tests) fails identically on `origin/fe-refactor/drive-surfaces`. From ad69fb802e165ce78a80963cae08326377af7ce9 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Tue, 21 Jul 2026 01:28:29 +0200 Subject: [PATCH 29/33] fix(frontend): align agent config skeletons with the real section layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the agent playground config panel's loading skeletons match the rendered panel so nothing shifts when the schema resolves: - ConfigAccordionSection: collapse the extra-slot's vertical footprint (-my-2) so an add-button no longer inflates section headers — every section row (config + Triggers) is now a uniform height. - AgentConfigSkeleton: mirror the real header row (gap-2, 16px icon, min-h-44) so a bordered row totals 45px and the last row 44px. - Both loading gates (hydration + schema) render the skeleton inside the same px-4 inset as the real agent_config field wrapper. - Route modern `agent-template` fields to the always-expanded body branch; the wrapper check only matched the legacy `agent_config` name, so they fell through to the collapsible else branch with a wider inset. - Tighten the region-header-to-content gap to px-4 pt-1 pb-3 (was py-3) across config, Triggers, and Files, plus their skeletons. --- .../PlaygroundVariantConfig/index.tsx | 8 ++++++- .../AgentOperationsSections.tsx | 8 +++---- .../SchemaControls/AgentTemplateControl.tsx | 2 ++ .../agentTemplate/AgentConfigSkeleton.tsx | 10 +++++--- .../components/PlaygroundConfigSection.tsx | 24 ++++++++++++------- .../section/ConfigAccordionSection.tsx | 4 ++++ 6 files changed, 39 insertions(+), 17 deletions(-) diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index cacf6d46de..cff56488e5 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx @@ -274,7 +274,13 @@ const PlaygroundVariantConfig: React.FC< /> {hasPendingHydration ? ( isAgentHeaderMode ? ( - + // Same px-4 pb-3 pt-1 inset the schema-loading gate and the real + // agent_config field wrapper use — without it the skeleton renders flush + // (432px wide, no inset) and its rows jump when the next gate / real content + // lands. +
+ +
) : (
diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentOperationsSections.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentOperationsSections.tsx index 619e3c1b64..40ca3bb121 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentOperationsSections.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentOperationsSections.tsx @@ -41,7 +41,7 @@ export function AgentOperationsSkeleton({sticky = true}: {sticky?: boolean}) { Triggers
-
+
@@ -51,7 +51,7 @@ export function AgentOperationsSkeleton({sticky = true}: {sticky?: boolean}) { Files
-
+
@@ -90,7 +90,7 @@ export function AgentOperationsSections({ {countSummary(triggerCount, "trigger")}
-
+
@@ -100,7 +100,7 @@ export function AgentOperationsSections({ Files {storageHeader}
-
+
{storage ?? ( // Static fallback for surfaces that don't slot the live Files body. diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index a0b12d1125..f9cbad6b6f 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -703,6 +703,8 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ } // Compact "+" for a section header's `extra` slot (stops propagation, so it never toggles open). + // The header keeps a uniform height regardless of this button — ConfigAccordionSection collapses + // the extra slot's vertical footprint (see its `-my-2`), so no per-button sizing is needed here. const headerAddButton = (label: string, onClick: () => void) => (