diff --git a/docs/design/app-boot-optimizations/gates.md b/docs/design/app-boot-optimizations/gates.md new file mode 100644 index 0000000000..6ad0e2a8fc --- /dev/null +++ b/docs/design/app-boot-optimizations/gates.md @@ -0,0 +1,84 @@ +# 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)` | **[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 | +| 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 + +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/docs/design/app-boot-optimizations/plan.md b/docs/design/app-boot-optimizations/plan.md new file mode 100644 index 0000000000..080a573c7d --- /dev/null +++ b/docs/design/app-boot-optimizations/plan.md @@ -0,0 +1,174 @@ +# 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). 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). 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 +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. +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, ~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 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. +- **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 ~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. + +**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 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, + 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. 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`. diff --git a/docs/design/playground-query-persistence/plan.md b/docs/design/playground-query-persistence/plan.md new file mode 100644 index 0000000000..bab223a0a9 --- /dev/null +++ b/docs/design/playground-query-persistence/plan.md @@ -0,0 +1,351 @@ +# 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. + +**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. + +**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). + +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. + +**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; 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 + +- 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/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 { 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/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/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 1b0629dbd2..e7590fba71 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" @@ -16,17 +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 => { - 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 +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 querySessionRecords({sessionId, projectId, lowPriority: true}) + 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/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/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index 1b6679914d..269c350557 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,77 @@ 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((e: React.MouseEvent) => { + // Don't unmount the cluster out from under keyboard focus: a mixed mouse+keyboard user + // can tab into a button, then move the mouse off the row. Symmetric with onBlurRow. + if (!e.currentTarget.contains(document.activeElement)) 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 +128,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 +196,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 +281,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..ce7e4ff127 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,50 @@ 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((e: React.MouseEvent) => { + // Don't unmount the cluster out from under keyboard focus (symmetric with onBlurChip). + if (!e.currentTarget.contains(document.activeElement)) 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 +200,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 +222,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 +249,8 @@ const SessionTag = ({
) -} +}) export interface SessionTagBarProps { sessions: AgentChatSession[] @@ -349,9 +379,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} /> ))} 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/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/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/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 { 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}) => { ) : ( - + = ({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/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/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/components/Playground/Components/PlaygroundVariantConfig/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/index.tsx index 2eb9970883..cff56488e5 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. @@ -79,20 +86,38 @@ 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) - const variantQueryPending = useAtomValue( - useMemo(() => workflowMolecule.selectors.query(variantId || ""), [variantId]), - ).isPending - const isAgentHeaderMode = isAgent || earlyAgentState === "agent" || variantQueryPending + const isAgentHeaderMode = isAgent || earlyAgentState === "agent" // 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 +132,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 +200,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 +210,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 +229,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,14 +267,20 @@ 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} /> {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. +
+ +
) : (
@@ -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 } /> @@ -305,11 +335,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 ? ( ) : ( 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() + // Same reasoning for the current app's detail + latest-revision DATA (see module docs). + prewarmCurrentWorkflowQueries() +} + +const Playground = dynamic(loadPlayground, { ssr: false, loading: PlaygroundLoadingShell, }) @@ -18,7 +29,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/ProtectedRoute/ProtectedRoute.tsx b/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx index c596fd49fe..ea6d33e01c 100644 --- a/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/web/oss/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -1,22 +1,58 @@ -import {type FC, type PropsWithChildren} from "react" +import {memo, type FC, type PropsWithChildren} from "react" +import clsx from "clsx" import {useAtomValue} from "jotai" import {useSession} from "@/oss/hooks/useSession" +import {sidebarCollapsedAtom} from "@/oss/lib/atoms/sidebar" import {selectedOrgAtom, selectedOrgQueryAtom} from "@/oss/state/org" import {useProfileData} from "@/oss/state/profile" import {useProjectData} from "@/oss/state/project" -import {protectedRouteReadyAtom} from "@/oss/state/url/test" +import {protectedRouteLatchedReadyAtom} 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) - const ready = useAtomValue(protectedRouteReadyAtom) + return null +}) - return ready ? children : null +// 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 : } + + ) } export default ProtectedRoute diff --git a/web/oss/src/components/SessionInspector/api.ts b/web/oss/src/components/SessionInspector/api.ts index 2fcd4b1a43..80e992e269 100644 --- a/web/oss/src/components/SessionInspector/api.ts +++ b/web/oss/src/components/SessionInspector/api.ts @@ -1,10 +1,8 @@ -import {getAgentaSdkClient} from "@agenta/sdk" +import {getMountsClient, getSessionsClient} from "@agenta/sdk/resources" import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" -const client = () => 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/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}) => { ( 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/components/pages/_app/index.tsx b/web/oss/src/components/pages/_app/index.tsx index d24912d071..06c2ddb9a7 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" @@ -39,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, }) @@ -53,6 +60,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/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/oss/src/hooks/useSession.ts b/web/oss/src/hooks/useSession.ts index e06725fd63..4d3fd7f1bd 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" @@ -13,6 +14,11 @@ import {resetProjectData} from "@/oss/state/project" import {authFlowAtom, sessionExistsAtom, sessionLoadingAtom} from "@/oss/state/session" import {clearInvite} from "@/oss/state/url/auth" +// Session existence is global (one per tab), so a module-level guard is correct here: it +// dedupes the auth-loss teardown across useSession's several call sites and fires it only on +// the authed→unauthed edge, not on every logged-out render. +let lastSessionExists: boolean | null = null + // sessionExistsAtom is a re-export of @agenta/shared/state's sessionAtom, // so a single setter covers both oss and entity-package readers. export const useSession: () => { @@ -32,9 +38,15 @@ export const useSession: () => { useEffect(() => { setSessionLoading(res.loading) if (!res.loading) { - setSessionExists((res as any).doesSessionExist) + const exists = Boolean((res as any).doesSessionExist) + setSessionExists(exists) + // Central auth-loss teardown: every sign-out path (not just logout()) funnels + // through SuperTokens session loss, so clearing persisted PII/secrets from + // IndexedDB on the authed→unauthed edge here means no per-path clear can be missed. + if (lastSessionExists === true && !exists) void clearPersistedQueryCache() + lastSessionExists = exists if (authFlow !== "authing") { - setAuthFlow((res as any).doesSessionExist ? "authed" : "unauthed") + setAuthFlow(exists ? "authed" : "unauthed") } } }, [ @@ -75,7 +87,9 @@ export const useSession: () => { console.error(error) } - // Clear React Query cache to prevent unauthorized requests + // Clear React Query cache to prevent unauthorized requests. The persisted IDB + // cache is cleared centrally by the auth-loss effect above (signOut() flips the + // SuperTokens session, which fires the authed→unauthed edge). queryClient.clear() // Reset Jotai atoms diff --git a/web/oss/src/lib/api/SWRConfig.tsx b/web/oss/src/lib/api/SWRConfig.tsx index 912c7e9b7d..e62844e942 100644 --- a/web/oss/src/lib/api/SWRConfig.tsx +++ b/web/oss/src/lib/api/SWRConfig.tsx @@ -1,3 +1,5 @@ +import {useMemo} from "react" + import {SWRConfig, type SWRConfiguration} from "swr" import axios from "@/oss/lib/api/assets/axiosConfig" @@ -25,8 +27,11 @@ const config: SWRConfiguration = { }) as AgentaFetcher, } -const AgSWRConfig = ({children, config: passedConfig = {}}: AgSWRConfigProps) => { - 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/lib/helpers/auth/AuthProvider.tsx b/web/oss/src/lib/helpers/auth/AuthProvider.tsx index bfa7cff972..49cc8ff9df 100644 --- a/web/oss/src/lib/helpers/auth/AuthProvider.tsx +++ b/web/oss/src/lib/helpers/auth/AuthProvider.tsx @@ -5,19 +5,26 @@ 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" +// 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 () => { + // Overlaps the boot atom graph's first evaluation with the config chunk fetch + prewarmBootQueryGraph() 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 diff --git a/web/oss/src/state/access/atoms.ts b/web/oss/src/state/access/atoms.ts index 936ec8be85..79d1789313 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" @@ -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/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/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/boot/prewarmBootQueryGraph.ts b/web/oss/src/state/boot/prewarmBootQueryGraph.ts new file mode 100644 index 0000000000..66b1e1cbe2 --- /dev/null +++ b/web/oss/src/state/boot/prewarmBootQueryGraph.ts @@ -0,0 +1,48 @@ +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 + +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 = () => { + 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) + + 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 0d631124f0..8ac795e1ab 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" @@ -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" @@ -120,6 +121,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 +132,7 @@ export const orgsQueryAtom = atomWithQuery((get) => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, - enabled: !!userId, + enabled: !!userId && (neededForBoot || get(idleReadyAtom)), } }) @@ -207,6 +212,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} @@ -326,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/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/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/playground.ts b/web/oss/src/state/url/playground.ts index bf992fb315..674b64502f 100644 --- a/web/oss/src/state/url/playground.ts +++ b/web/oss/src/state/url/playground.ts @@ -852,7 +852,9 @@ playgroundSyncAtom.onMount = (set) => { 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) } } @@ -1024,6 +1026,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 +1037,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() @@ -1371,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. + } +} 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" 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/oss/src/state/workflow/selectors/workflow.ts b/web/oss/src/state/workflow/selectors/workflow.ts index 92446f9948..d2fe3bb234 100644 --- a/web/oss/src/state/workflow/selectors/workflow.ts +++ b/web/oss/src/state/workflow/selectors/workflow.ts @@ -5,7 +5,8 @@ import { type Workflow, type WorkflowFlags, } from "@agenta/entities/workflow" -import {atom} from "jotai" +import {atom, type Getter} from "jotai" +import {selectAtom} from "jotai/utils" import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" @@ -84,7 +85,7 @@ export interface CurrentWorkflowContext { isError: boolean } -export const currentWorkflowContextAtom = atom((get) => { +const computeCurrentWorkflowContext = (get: Getter): CurrentWorkflowContext => { const id = get(routerAppIdAtom) if (!id) { @@ -148,7 +149,25 @@ 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 + +// Reuse the previous object when all fields are unchanged, so query-phase identity churn in +// the underlying detail query doesn't re-render subscribers. selectAtom keeps this memoization +// store-scoped (each Jotai store gets its own), matching the stableAtom pattern in +// PlaygroundConfigSection, instead of a module-level cache shared across every store/test. +export const currentWorkflowContextAtom = selectAtom( + atom(computeCurrentWorkflowContext), + (ctx) => ctx, + workflowContextEquals, +) /** * Early, app-id-keyed agent signal for the playground shell/header/layout. 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/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/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/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/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/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/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/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..f733c887e8 --- /dev/null +++ b/web/packages/agenta-entities/src/secret/state/persistence.ts @@ -0,0 +1,100 @@ +/** + * 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 + // `!= null` (not `!== undefined`): a null content has no secret to redact, and skipping it + // avoids `Object.keys(null)` throwing inside this serialize hook. + if (named.content != null) { + 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 + // Nothing to persist yet (pending/empty) — pass through unchanged. + if (data == null) return persisted + // Fail-safe: the vault query returns LlmProvider[]. Any other shape (a misapplied persister, + // a future API change) is dropped rather than persisted, so plaintext secrets can never reach + // IndexedDB unredacted through an unexpected payload. + if (!Array.isArray(data)) { + return {...persisted, state: {...persisted.state, data: undefined}} + } + + 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 c5529a4e54..81683d5110 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -69,7 +69,9 @@ export { sessionRecordsQueryFamily, sessionRecordFileRecencyAtomFamily, 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 ce6a7cb166..37e88c506f 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" @@ -16,21 +18,83 @@ 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. +// 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) => ({ + // 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. */ 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, } }), ) +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. 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, + async (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + 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} + }, +) + /** 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 @@ -47,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/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/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-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-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index cfcfbcc0df..b4e0816915 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/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index e1855d1a38..c146764c45 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -13,7 +13,9 @@ */ import {getEnabledSandboxProviders} from "@agenta/shared/api" +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" @@ -40,7 +42,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], @@ -51,6 +53,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/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 0e2c064d60..aa82cb76c6 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/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..c86aab8be4 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) + } } /** @@ -445,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 => { @@ -459,10 +464,23 @@ 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, + // 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, } }), ) @@ -567,6 +585,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) // ============================================================================ @@ -615,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 @@ -918,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, } }), ) @@ -946,11 +991,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) @@ -1045,11 +1097,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 && @@ -1115,7 +1175,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 +1229,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 +1238,8 @@ export const workflowQueryAtomFamily = atomFamily((revisionId: string) => !detailCached && !isLocalDraftId(revisionId) && !isPlaceholderId(revisionId), - staleTime: 30_000, + staleTime: Infinity, + gcTime: 60 * 60_000, } }), ) @@ -1197,7 +1260,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 +1311,20 @@ 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) + // 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: ["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 uri+serviceUrl+project): paint from disk, then one revalidate when stale. + persister: catalogPersister.persisterFn, enabled: isEnabled, staleTime: 60_000, } @@ -1310,7 +1369,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], @@ -1319,8 +1378,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, } }) @@ -1394,27 +1455,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, } @@ -2643,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], 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..ed5861a261 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts @@ -0,0 +1,173 @@ +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("does not throw when content is null", () => { + const row = {name: "x", format: "json", content: null} as unknown as NamedSecretRow + expect(() => redactVaultSecretRow(row)).not.toThrow() + expect((redactVaultSecretRow(row) as NamedSecretRow).content).toBeNull() + }) + + 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 empty (null/undefined) data through untouched", () => { + const persisted = wrap(undefined) + expect(redactPersistedVaultQuery(persisted)).toBe(persisted) + }) + + it("drops a non-array payload rather than persisting it unredacted (fail-safe)", () => { + const persisted = wrap({secrets: [{name: "x", key: "sk-live-leak"}]}) + const result = redactPersistedVaultQuery(persisted) + expect(result.state.data).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("sk-live-leak") + }) + + 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-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/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 96f2d502f0..f9cbad6b6f 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, @@ -703,6 +703,8 @@ export 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) => (
) -} +}) 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/AgentConfigSkeleton.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx index 8bef8cc179..e903ec05d8 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentConfigSkeleton.tsx @@ -23,13 +23,17 @@ export const SkeletonSectionRow = ({ }) => (
- + -
+
{withAdd ? : null} 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..911f1c61d1 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), @@ -1824,16 +1845,22 @@ function PlaygroundConfigSection({ return
{props.defaultRender()}
} - // The agent_config body is always expanded (its header is suppressed above), so it renders - // without the HeightCollapse toggle. + // The agent body is always expanded (its header is suppressed above), so it renders + // without the HeightCollapse toggle. Detected via the same marker as the header + // suppression — both the new `agent-template` and legacy `agent_config` name, so it + // holds across the rename (checking only `agent_config` here sent modern agent fields + // to the generic else branch, re-adding the collapse and the wider py-3 inset). const fieldSchema = schema?.properties ? (schema.properties as Record>)[fieldKey] : null + const isAgentMarker = (v: unknown) => v === "agent-template" || v === "agent_config" if ( - fieldSchema?.["x-ag-type-ref"] === "agent_config" || - fieldSchema?.["x-ag-type"] === "agent_config" + isAgentMarker(fieldSchema?.["x-ag-type-ref"]) || + isAgentMarker(fieldSchema?.["x-ag-type"]) ) { - return
{props.defaultRender()}
+ // pt-1 (not py-3) tightens the gap between the region header and the first section + // row; pb-3 keeps the bottom breathing room. The loading fallback below matches. + return
{props.defaultRender()}
} return ( @@ -1853,15 +1880,25 @@ 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) if (isConfigLoading) { if (loadingFallback) { - // px-4 py-3 mirrors the field-content wrapper the real sections (and the lazy - // control's Suspense fallback) render inside — without it the fallback skeleton - // sits 16px wider / 12px higher and visibly shifts when the schema lands. - return
{loadingFallback}
+ // px-4 pb-3 pt-1 mirrors the agent_config field-content wrapper the real sections (and + // the lazy control's Suspense fallback) render inside — without it the fallback skeleton + // shifts when the schema lands. + return
{loadingFallback}
} return (
@@ -1955,11 +1992,7 @@ function PlaygroundConfigSection({ rootTitle="Configuration" showBreadcrumb={false} collapsible={false} - slots={{ - fieldHeader: fieldHeaderSlot, - fieldActions: fieldActionsSlot, - fieldContent: fieldContentSlot, - }} + slots={drillInSlots} /> )} 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-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, 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/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..b35204e3be --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/debug.ts @@ -0,0 +1,74 @@ +import type {PersistedQuery} from "@tanstack/query-persist-client-core" + +const DEBUG_FLAG = "agenta:persist:debug" + +/** 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 + } +} + +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..00f90c4253 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/gc.ts @@ -0,0 +1,33 @@ +import {isPersistDebugEnabled, persistLog} from "./debug" +import {idbQueryStorage} from "./idbStorage" +import {catalogPersister, immutablePersister, recordsPersister} 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) + await recordsPersister.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..44106802b7 --- /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 {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) 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) 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..b7ee425ef6 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/index.ts @@ -0,0 +1,9 @@ +export {PERSIST_SCHEMA_VERSION} from "./version" +export {idbQueryStorage, clearPersistedQueryCache} from "./idbStorage" +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 new file mode 100644 index 0000000000..16c2c93f54 --- /dev/null +++ b/web/packages/agenta-shared/src/api/persist/persisters.ts @@ -0,0 +1,60 @@ +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", +}) + +const RECORDS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +/** + * Class D-with-restore — live, append-only logs (session records): paint-from-disk, but disk + * is NEVER authoritative. `refetchOnRestore: "always"` fires `query.fetch()` from the persister's + * post-restore task even with zero observers, so both the observer path and bare + * `fetchQuery` restores get exactly one revalidation (a stale-only policy would skip a + * sub-staleTime restore). Shorter maxAge than catalogs: entries are big (~200KB+/session) and a + * week-untouched session's log is cheap to refetch once. + */ +export const recordsPersister = 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/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/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" 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..f54ced3e6a --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/persist.test.ts @@ -0,0 +1,522 @@ +// 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, + recordsPersister, +} 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)}` +const recStorageKey = (key: QueryKey) => `agenta-rec-${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("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) + }) +}) + +// 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"] + 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..2756f66d57 --- /dev/null +++ b/web/packages/agenta-shared/tests/unit/persistNullGuard.test.ts @@ -0,0 +1,63 @@ +// 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 {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() + }) + }) +}) diff --git a/web/packages/agenta-ui/src/Editor/Editor.tsx b/web/packages/agenta-ui/src/Editor/Editor.tsx index 2b3378c302..8e5eb6057a 100644 --- a/web/packages/agenta-ui/src/Editor/Editor.tsx +++ b/web/packages/agenta-ui/src/Editor/Editor.tsx @@ -811,6 +811,7 @@ const EditorInner = 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 diff --git a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx index e70f9855c7..fba6392761 100644 --- a/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx +++ b/web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx @@ -290,7 +290,11 @@ export function ConfigAccordionSection({ ) : null} {extra ? ( + // `-my-2` keeps a full-height control (e.g. a 32px antd add-button) from + // stretching the header past its text-driven height, so every section row + // stays the same height whether or not it has an `extra` action. e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} onKeyUp={(e) => e.stopPropagation()} 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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0b7e7b6bec..b32be1bb0b 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 @@ -824,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) @@ -941,7 +945,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 +1011,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 +1238,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 +1250,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 +1281,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 +1348,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 +1385,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 +1620,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 +1653,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 +1730,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 +3370,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 +3946,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 +4186,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 +4898,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 +5218,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 +5494,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 +5543,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 +6257,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 +6620,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 +6945,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 +7556,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 +7591,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 +7674,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 +8019,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 +9218,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 +9828,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 +9840,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 +10060,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 +10118,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 +10146,6 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001792: {} - caniuse-lite@1.0.30001799: {} ccount@2.0.1: {} @@ -10637,7 +10602,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 +11057,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 +11095,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 +11395,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.3.0: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -11719,10 +11688,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 +11969,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 +12124,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 +12343,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 +12376,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 +12837,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.4: {} set-function-length@1.2.2: @@ -13146,7 +13097,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 +13188,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 +13215,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 +13249,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 +13551,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 +13630,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