Skip to content

State Management

The Vayu app uses a dual-state management approach: Zustand for UI state and TanStack Query for server state. Cross-cutting stores (tabs, layout, session, engine, save, response, dashboard, client settings, toasts, import modal) live in app/src/stores/ and are exported via the barrel app/src/stores/index.ts. Module-local UI stores co-locate in app/src/modules/<feature>/<feature>-store.ts (collections, history, variables, settings) to keep feature-specific UI state decoupled from global app state.

Architecture Overview

┌─────────────────────────────────────────────────┐
│           State Management Layers                │
├─────────────────────────────────────────────────┤
│  UI State (Zustand)                             │
│  - Cross-cutting: tabs, layout, session         │
│  - Domain: engine, save, response, dashboard    │
│  - Module-local: collections, history, vars     │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│  Server State (TanStack Query)                  │
│  - Collections, Requests, Environments           │
│  - Runs, Metrics, Global Variables              │
│  - Automatic caching & synchronization          │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│  Services Layer                                 │
│  - HTTP Client (api.ts)                        │
│  - SSE Client (sse-client.ts)                  │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│  Vayu Engine (C++ Daemon)                      │
│  - SQLite Database                              │
│  - HTTP API (localhost:9876)                    │
└─────────────────────────────────────────────────┘

Zustand Stores

Cross-Cutting Stores (app/src/stores/)

tabs-store.ts - Open Tabs & Navigation

Manages all open tabs (welcome, request, collection, dashboard, run, variables, settings) and active tab focus. Enforces a maximum of 12 open tabs with LRU eviction for non-exempt types.

State:

{
  openTabs: Tab[]                        // Each tab has unique id, type, and optional entityId
  activeTabId: string | null
  tabFocusedAt: Record<string, number>   // Tab id -> when it was last focused (epoch ms)
  specTabTarget: string | null           // Collection whose Spec tab something pointed at
  dataRowTarget: { requestId, rowIndex } | null  // Request a repro pointed at, and the row
  navHistory: TabLocation[]              // Visited locations this session, oldest first
  navIndex: number                       // Cursor into navHistory; -1 before anything is visited
}

Key Features: - Deduplication: Singleton types (welcome, variables, settings, inbox) only allow one tab at a time. Opening one that is already open with a different entityId retargets it - same tab id, new address - rather than focusing whatever it was showing. The inbox tab is the case that needs it: every Services drawer row opens that one tab pointed at its own inbox (issue #554). The other three are always opened with a null entityId, for which this is a no-op - LRU eviction: Oldest non-active, non-exempt, clean tabs are closed when over limit - Integration with save-store: dirty tabs are never selected for eviction. The guard resolves each tab's save-context key by tab type (isTabDirty), because the registry is keyed by editor and the two do not line up: settings and variables are singletons with no entityId, so a request-${entityId} lookup read them as clean and the 13th tab could close a dirty Settings tab. A variables tab counts any dirty variable-editor context as its own, since a Tab does not record which editor the sidebar has selected - over-matching keeps a tab that could have closed, under-matching loses work. Nothing is flushed during eviction; the predicate already refused the tab - Bulk close (issue #1360): closeOtherTabs, closeTabsToRight and closeSavedTabs - the tab strip's right-click menu - go through the same closeTabs core as closeTab and closeTabsForEntities, so "which tab takes the place of the one that was showing" stays one rule. A bulk close is one set and at most one recorded navigation visit, never a loop of single closes: closing tabs one at a time would focus and record a visit for every intermediate tab on the way to the last, leaving Back to walk the user through places they never went. - Response eviction: closeTabsForEntities clears each id's entry in response-store. Both callers reach it after a delete - the map's own LRU bound would get there eventually, but a response nothing can reach again should go at the delete, not twenty-four sends later. MAX_OPEN_TABS is exported for that bound: the cache retains twice it - Focus recency: tabFocusedAt stamps openTab and focusTab, and is read by the command palette, which lists open tabs most-recently-used first. It cannot come from openTabs, which is insertion order - the tab you were just in sits wherever it was opened. Session-scoped on purpose (absent from partialize): tabs are restored across launches, so a persisted copy would rank a restored list by yesterday's attention, and an empty map falls back to strip order. Entries for closed tabs are dropped on the next focus rather than in every close path - nothing reads a stamp for a tab that is not open - Pointing into a collection: openTab can only name a collection, and a collection tab picks its own sub-tab, so specTabTarget is where the one navigation that means a section says which. The import dialog sets it when a re-import is answered with Sync (issue #680); CollectionDetail opens on Spec when the named collection is on screen and clears it, so it survives the tab opening for the first time and never fires twice. Session-scoped like tabFocusedAt - a persisted copy would jump the user to Spec on the next launch for a choice they made yesterday - Pointing into a request the same way: dataRowTarget carries the data row a navigation meant, because openTab can only name a request and which row Send-with-row binds is state the UrlBar holds. A failed step of a collection run sets it through openRequestWithDataRow (issue #730) - "reproduce this step" means its request and the row that iteration bound - and the UrlBar selects the row, opens the picker on it and clears the target, whether or not the request can bind rows at all: a target left standing would fire on the next request that can. Session-scoped like specTabTarget, and doubly so - it points into a data file whose rows are deliberately never persisted, so a remembered index would name a different row in a file that has since changed. A non-integer or negative index throws rather than being stored - Navigation history (issue #1245): navHistory is a TabLocation per visit (a Tab without its instance id, so a closed tab's place survives it), with navIndex as the cursor - -1 before anything is visited. openTab and focusTab record each visit themselves, coalescing a repeat of the current location and truncating the forward half on a new one; capped at 50 entries (MAX_NAV_HISTORY), oldest dropped first. goBack() / goForward() move the cursor and show what it lands on - focusing the open tab if there is one, reopening it through openTab otherwise - without recording a step of their own. Closing the active tab now activates the most recent history entry whose tab is still open, falling back to the old left-neighbour rule, and leaves the closed location ahead of the cursor so Forward can reopen it; closeTabsForEntities prunes history entries for deleted entities, so Back never lands on one. Session-scoped like tabFocusedAt and specTabTarget (absent from partialize), and NOT persisted: tabs are restored across launches, and a persisted history would offer a Back that walks yesterday's route through today's window - Persistence: vayu.tabs (v1), with a pass-through migrate. zustand discards a payload whose stamped version differs from the store's when no migrate is supplied, so the stub is where the next bump goes; it also refuses a payload of the wrong shape rather than handing a non-array to every reader

Key Methods:

const { openTab, closeTab, focusTab, closeTabsForEntities } = useTabsStore();
openTab({ type: "request", entityId: "req-123" });
closeTabsForEntities(["req-123"]); // after a delete: closes tabs, drops responses

const { closeOtherTabs, closeTabsToRight, closeSavedTabs } = useTabsStore();
closeOtherTabs("tab-1"); // keeps tab-1, one set-and-visit for the rest
closeTabsToRight("tab-1");
closeSavedTabs(); // keeps every dirty tab
openCollectionSpecTab("col-123"); // opens the collection, on its Spec tab
openRequestWithDataRow("req-123", 500); // opens the request, on row 501 of its data file

const { goBack, goForward } = useTabsStore();
goBack(); // step back through navHistory; reopens the tab if it was closed
goForward(); // never records a visit - only openTab/focusTab do that

// canGoBack / canGoForward are exported selectors, not store methods - read
// by the title-bar buttons and the palette's go-back / go-forward commands
canGoBack(useTabsStore.getState());
canGoForward(useTabsStore.getState());

layout-store.ts - Drawer, Context Bar, & Response Position

Manages the left drawer (collections/history/variables/settings), the right context bar, and where the response pane sits - beside the request or below it - with one split ratio per arrangement.

State:

{
  drawerOpen: boolean                    // Is the left drawer visible?
  drawerView: DrawerView                 // "collections" | "history" | "variables" | "services" | "settings"
  drawerWidth: number                    // One width for every view
  contextBarOpen: boolean                // Is the right context bar visible?
  contextBarWidth: number
  contextBarCollapsedSections: string[]  // Section ids the user collapsed (`code` by default)
  responsePosition: ResponsePosition     // "beside" | "below" | "auto" (default beside, #1711)
  autoResponseArrangement: "beside" | "below"  // What auto resolves to right now - written by the builder's measurement, not persisted
  requestSplitRatioBeside: number        // 0-1; the request pane's share while the response is beside it
  requestSplitRatioBelow: number         // 0-1; the request pane's share while the response is below it
  scriptSnippetsCollapsed: boolean       // Default a fresh script row's snippets list starts from (collapsed); each row keeps its own state after that
  scriptEditorHeights: Record<string, number>  // Height (px) of a script.pre/script.post element's editor box, keyed by the element's own id - capped at 200 entries, oldest set evicted first
  scriptEditorHeightDefault: number      // Height a script row with no entry above starts at - the last height set on any row
  recentElementKinds: string[]           // Last 5 kinds added through the Add-element picker, most recent first
  paletteOpen: boolean                   // Is the ⌘K command palette showing?
}

Key Methods:

const {
  drawerOpen, setDrawerOpen, toggleDrawer,
  drawerView, setDrawerView, activateDrawerView,
  drawerWidth, setDrawerWidth,
  contextBarOpen, setContextBarOpen, toggleContextBar,
  contextBarWidth, setContextBarWidth,
  contextBarCollapsedSections, toggleContextBarSection,
  responsePosition, setResponsePosition, toggleResponsePosition,
  setRequestSplitRatio
} = useLayoutStore();
activateDrawerView("variables"); // Open drawer to variables, or toggle closed if already there
setDrawerWidth(300); // Clamped to [PANEL_MIN_WIDTH, PANEL_MAX_WIDTH] (constants/layout.ts)
toggleResponsePosition(); // beside <-> below; from auto, the opposite of what auto currently shows
setRequestSplitRatio("below", 0.6); // Into the ratio for the arrangement that was dragged, clamped 0.2..0.8
resolveResponseArrangement(useLayoutStore.getState()); // The arrangement on screen: the setting, or auto's pick

One drawer width, not one per view. v2 stored a width per view, so switching from Collections to History resized the main content under the user; the v3 migration collapses them onto a single drawerWidth, keeping whatever Collections (the default view) had. Re-introducing a per-view width re-introduces that bug. setRequestSplitRatio clamps to [0.2, 0.8]; both panel widths clamp to PANEL_MIN_WIDTH / PANEL_MAX_WIDTH.

setDrawerWidth / setContextBarWidth are not called per pointermove. PanelResizeHandle (app/src/components/layout/PanelResizeHandle.tsx) holds a drag's live width outside the store, painting it onto the panel's own inline style.width once per animation frame, and calls the setter - the one write partialize persists - exactly once, on pointerup. A store write on every pointermove was a synchronous JSON.stringify + localStorage.setItem of the whole persisted slice 120-240 times a second (#1715).

paletteOpen is here, and is not persisted. The palette lives in Shell while the things that open it - the welcome Launcher's Search tile, the title bar's search bar - are in other subtrees, so the flag has to be shared state rather than the dialog's own. It is deliberately absent from partialize: a dialog that reopened itself on every launch is not a layout preference.

Context-bar sections are collapsed by exception. The store holds the ids the user closed (contextBarCollapsedSections); anything not listed is expanded. Two consequences worth keeping: a section added in a later release ships expanded for existing users, because a blob written before it existed cannot name it; and it is an array rather than a Set because persist serializes with JSON, which writes a Set as {} - a collapse would survive exactly until the next launch.

code is the one section allowed to break the ships-expanded rule (CONTEXT_BAR_DEFAULT_COLLAPSED, constants/layout.ts, currently ["code"]): an expanded Code section issues a POST /compose on mount, so with the bar open the app composed a snippet on every request tab opened, whether or not anyone looked at it. That default needs both the initial state and the v3 -> v4 migration to actually reach an existing user, and neither alone is enough: persist merges a missing key onto the initial state, never a missing element into an array that is already there, so seeding contextBarCollapsedSections: ["code"] in the initial state only reaches a fresh install - a user who has ever collapsed anything already has the key, with an array persist treats as complete. The migration writes code into that array directly, and at the same time prunes any id in RETIRED_CONTEXT_BAR_SECTIONS (environment, retired the same release), so a persisted collapse list never keeps naming a section that no longer exists. It is a default, not a policy: once migrated, a user's own toggle on code overrides it exactly like any other section.

Persistence: vayu.layout (v6, with a real migration for every bump - the one store in the app doing persistence versioning end to end; v6 carries the old single requestSplitRatio forward as the Beside ratio, seeds Below even and keeps the position at Beside, so an upgrade re-arranges nothing)

session-store.ts - Active Environment

Tracks the active environment (for variable resolution) and the collection the user last worked in (a new-request target only), persisted across sessions.

State:

{
  activeEnvironmentId: string | null
  lastCollectionId: string | null
}

Key Methods:

const { activeEnvironmentId, setActiveEnvironmentId } = useSessionStore();
setLastCollectionId(collectionId);

Persistence: vayu.session (v2)

A persisted id must not outlive what it names. activeEnvironmentId rides on every composed payload, so a dangling one is not cosmetic - the switcher renders "No Environment" through a defensive find() while the wire still carries a deleted id. It is cleared at both ends: useDeleteEnvironmentMutation clears it when the active environment is the one deleted (in the mutation, so both delete flows are covered), and useActiveEnvironmentGuard() - mounted once in App.tsx - clears an id the engine's environment list does not contain. That guard keys on the query's isSuccess, never on the list being empty: an unreachable engine produces an empty list too, and clearing on that would discard a good selection whenever the app started before the engine did.

activeCollectionId was removed in v2. It had a reader (the resolver's fallback scope) and no writer, so it was permanently null on a fresh install and, on an older one, rehydrated a collection the user had long left and silently scoped {{var}} previews to it. The persist migrate drops the stored key. lastCollectionId is the field that looks similar and is not: it has a real writer and feeds only the welcome screen's new-request target - it must never feed the resolver.

The migration rebuilds the payload from the fields v2 knows rather than deleting that one key, which is also the shape check: a hand-edited or half-written entry degrades to defaults instead of handing a non-string id to the switcher. That whitelist is where the next bump goes - zustand discards a payload whose stamped version does not match when no migrate is supplied, which is the same reason tabs-store carries one.

activeEnvironmentId is a cache of engine state, not the source of truth. The engine owns which environment is active (environments.is_active, at most one row, enforced in its DB layer). The store holds the same id so the switcher, the resolver and every composed payload can read it synchronously, and two pieces of wiring keep the two in step:

  • useSetActiveEnvironmentMutation (queries/environments.ts) is the only writer. It PUTs isActive and updates the store optimistically, rolling the store back if the engine refuses - a selection the engine did not accept must not survive in the UI, or the next launch silently disagrees with this one. It invalidates the environments list rather than patching the response into it, because the deactivated row changed server-side without appearing in any response body.
  • useActiveEnvironmentRestore (hooks/, mounted in App.tsx) reconciles on launch: adopt the engine's active environment if it has one, otherwise push a persisted id that names an environment that still exists - once per session, so a write the engine keeps rejecting is a failed write and not a request loop. Both directions wait on isSuccess; an unreachable engine returns an empty list that is indistinguishable from "no environments exist", and adopting from that would clear a good selection on every cold start. A third direction was added with the MCP state tools (#758): once this session has seen the engine hold a selection, an engine that reports none is adopted as a clear rather than pushed back at. Otherwise an activate_environment with "none" - or any other client's deactivate - would be undone on the very next refetch by this window's memory of the id it used to hold. The upgrade push stays for the case it was written for: an engine that has never held a selection at all.

It composes with useActiveEnvironmentGuard above rather than replacing it, and is mounted after it: the guard answers "does this id still exist", this hook answers "which id does the engine hold". Order matters only in the one case where both would act - a dangling id should be dropped, not pushed back at the engine as a selection.

Editing an environment's variables deliberately sends no isActive (absent means "keep" on a PUT): echoing a cached value back would let a variable edit re-activate an environment from a stale read, deactivating whichever one the engine actually holds.

data-file-store.ts - Where Each Collection's Data File Lives

Remembers where a collection's data file is on this machine, so the Run dialog can pre-fill it instead of asking for it every run (issue #599).

State:

{
  locations: Record<string, { path: string; fileName: string }>  // keyed by collection id
}

Key Methods:

const { setDataFile, clearDataFile } = useDataFileStore();
setDataFile(collectionId, { path, fileName });

Persistence: vayu.data-files (v1)

Two things this store deliberately is not. It is not the contract - the declared columns are the same on every machine, so they live on the engine's collection row as dataSchema and travel through import; a path is true of one filesystem only and stays here, never reaching the engine, an export or MCP. And it never holds rows: a data file's contents are user data of unknown sensitivity and are persisted nowhere in Vayu, which data-file-store.test.ts asserts against the persisted payload rather than against the store's surface.

The path is written when the user declares a contract in the Data tab (CollectionDetail/DataTab.tsx), obtained through the preload's existing getFilePath bridge, and dropped when the collection is deleted or its contract cleared. Reading it back needs the gated dataFile:read IPC (electron/data-file.ts), because the renderer otherwise cannot name a path.

The persisted payload is normalized through both migrate and merge: migrate runs only on a version mismatch, and the common case is a same-version payload, so an entry that is not a {path, fileName} pair of strings would otherwise reach the read IPC as a path. A bad entry is dropped rather than repaired - there is nothing to repair a path to, and the picker is one click away.

spec-file-store.ts - Where Each Collection's Spec File Lives

Remembers where a collection's bound OpenAPI document is on this machine, for the specs that were picked as files rather than fetched from a URL (issue #638).

State:

{
  locations: Record<string, { path: string; fileName: string }>  // keyed by collection id
}

Key Methods:

const { setSpecFile, clearSpecFile } = useSpecFileStore();
setSpecFile(collectionId, { path, fileName });

Persistence: vayu.spec-files (v1)

The same two-halves law as data-file-store above, one contract over: the document is engine state (spec_documents.content, hashed there, bound by collection.openapi.specId) because it is the same on every machine and travels through import; a path is true of one filesystem only and stays here. A URL-sourced spec has no entry at all - its origin is spec_documents.source_url, which is portable and is what a re-fetch will use. And it never holds spec content: a second copy in localStorage could not be hashed and could not be told apart from the bound one, which spec-file-store.test.ts asserts against the persisted payload rather than against the store's surface.

The path is written when an OpenAPI file is imported (queries/import.ts, after the apply, because the collection has no id until then) or when the Spec tab binds a picked file, obtained through the preload's existing getFilePath bridge, and dropped on unbind. Reading the files a picked document references needs the gated specFile:read IPC (electron/spec-file.ts), which takes the document's path plus the reference and resolves one against the other in the main process - the renderer names no directory of its own, the same posture dataFile:read holds. It is normalized through both migrate and merge, for the reason spelled out for data-file-store above.

bound-row-store.ts - The Row The Open Builder Is Bound To

One slot: the data row a Send-with-row has picked, and the id of the request it was picked for (issue #1074).

State:

{
  bound: { requestId: string; row: Record<string, unknown> } | null
}

Key Methods:

const setBoundRow = useBoundRowStore((s) => s.setBoundRow);
setBoundRow({ requestId, row }); // or null for "bound to none"

Persistence: none, deliberately - see below.

Why a store at all. RequestBuilderProvider already gives the picked row to its own resolver, which covers every preview inside the builder (issue #1062). The tab strip is the one that is not below it: useTabDescriptors labels every open tab from a single list-wide resolver, so it cannot take the row off the builder's context and, left alone, labelled a tab from the environment while the bar one row beneath it showed the file's value.

One slot, not a map, and it names its request. The builder binds a row for the request it is showing, so that is the only request an on-screen preview can be bound for; a row per remembered index would be a row out of a file that is no longer the one loaded. Carrying the request id is what lets a reader check rather than assume, so a slot left standing cannot relabel the next tab - boundRowFor(bound, requestId) is that check, named once rather than repeated at each call site.

Never persisted, and cleared with the builder. Rows are user data of unknown sensitivity and are persisted nowhere in Vayu, the same law data-file-store states above; the builder also clears the slot when it unmounts, so a row cannot outlive the send that justified it.

recovery-notice-store.ts - Which Data-Loss Notice Was Already Seen

One timestamp: the at of the startup-recovery record the user has already been told about (issue #922).

State:

{
  acknowledgedAt: number | null  // epoch ms of the dismissed record, or null
}

Key Methods:

const { acknowledgedAt, acknowledge } = useRecoveryNoticeStore();

Persistence: vayu.recovery-notice (v1)

The record is the engine's - a marker file beside the database, reported on GET /health for as long as it stands, because a record that cleared itself as soon as something read it would be lost whenever the engine restarted before the app polled. Showing a notice about it exactly once is this side's job, and one timestamp is the whole of it: a later recovery carries a later at and is a different event, so it surfaces again with no per-event bookkeeping. Persisted rather than session state because "does not repeat on the next launch" is the point - a window reopened against an already-running engine would otherwise re-announce a wipe the user had dismissed. A stored value that is not a finite number reads as "nothing acknowledged", the safe direction: a notice shown twice is an annoyance, one suppressed by a garbage value is silent data loss again. It is normalized through both migrate and merge, for the reason spelled out for data-file-store above.

engine-store.ts - Engine Connection & Restart State

Merged store managing engine connection status and restart-required notifications (for config changes that need an engine restart).

State:

{
  engineStatus: EngineStatus  // "starting" | "connected" | "unreachable"
  engineError: string | null
  recovery: EngineRecovery | null  // What the engine's startup did to the database
  workers: number | null  // The `workers` setting's effective value, or null before the first poll
  engineStartWindow: number | null  // When the engine now coming up began, or null
  pendingRestart: boolean
  restartRequiredKeys: string[]  // Config keys requiring restart
}

Key Methods:

const {
  engineStatus, setEngineStatus,
  engineError, setEngineError,
  recovery, setEngineRecovery,
  workers, setWorkers,
  engineStartWindow, openEngineStartWindow, closeEngineStartWindow,
  pendingRestart, addRestartRequiredKey, clearRestartRequired,
} = useEngineStore();

recovery is the optional recovery node from GET /health (issue #922), written by the same poll that writes engineError and read by RecoveryBanner. null is a clean start; a value means the engine restored the database from its backup or deleted it as unrecoverable.

workers is the workers field from the same poll (issue #1508) - the workers setting's effective value, not the machine's raw core count - read by the Dock's version-string tooltip. null before the first poll answers; it is not cleared on a later disconnect, so a hover during a brief drop still shows the last known count.

engineStatus is starting while no poll has succeeded since the engine now coming up began coming up and it is still inside its grace window, connected once a poll has answered ok, and unreachable once a poll fails past that window or an engine that had answered stops answering with nothing starting. engineError is what the failed health poll recorded (queries/health.ts), and it is null in every state but unreachable: an engine still inside its grace window has no failure to report. The Dock's connection indicator renders it in a tooltip, for unreachable alone - "Disconnected" on its own read the same for a refused connection, a timeout and a TLS failure.

engineStartWindow is the evidence that decision is made against: the moment the engine now coming up began coming up, or null when none is. It is a window rather than a "has ever connected" flag because a cold start is not only the app's first (#1227) - useEngineRestart kills the running engine and spawns a fresh one that repeats the whole startup housekeeping, with the port down for all of it, and a flag called that silence a failure on the evidence of an engine that no longer existed. Two paths open it - useHealthQuery on mount and the restart path before it invokes the IPC - and it is deliberately evidence rather than a status, so useHealthQuery stays the only writer of engineStatus. It is closed by the poll an engine answers, and by a restart the main process reports as failed, after which the next failed poll owes the user its reason again. A window nobody closes is spent rather than cleared - an engine that never arrives leaves its opening time in place, expired - so this is not an "is something starting" flag and must not be read as one: only engineStatusAfterFailedPoll interprets it, and to that an expired timestamp and a null mean the same thing.

Non-persisted (cleared on app restart).

save-store.ts - Centralized Auto-Save

Orchestrates auto-save across the app with a registry of saveable contexts (e.g., request tabs, environment editors). Provides Ctrl/Cmd+S integration and unified save status.

State:

{
  status: "idle" | "pending" | "saving" | "saved" | "error"
  lastErrorMessage: string | null
  activeContextId: string | null
  contexts: Map<string, SaveContext>  // Saveable entities
}

failSave is the app's single failure seam. It sets status: "error" and raises an error toast carrying the reason. Its call sites are the collection tree's create / delete / duplicate / rename, useSaveManager, SettingsMain, VariableTableEditor, useDraftSaveContext, the context bar's VariablesSection and the inbox's CannedResponseControls - and doing the reporting here rather than at each of them means a new caller cannot forget to report. The last two were added because both could fail in complete silence: the variables section fired three mutations with no onError at all, and the manual-draft editors rendered an inline callout that a quit flush has no screen to show. CannedResponseControls (#1450) is a direct writer like SettingsMain: pressing Apply calls startSaving/completeSaveThenIdle/ failSave itself around the PUT, rather than only through the useDraftSaveContext-registered save those two functions also serve to Ctrl/Cmd+S and the quit flush - the registration alone would report nothing for a direct click, since only triggerSave/flushAll route a registered context's save through the store's own status wrapper.

VariablesSection also registers a context (context-bar-variables), because failSave alone only covers the failure. A variable commit is a plain mutation rather than a draft, so it registers one entry for the whole section whose hasPendingChanges tracks whether any commit is outstanding and whose save() resolves when the last one settles - without it flushAll had nothing to wait for, and the renderer could be torn down mid-PUT with the input already showing the value as committed.

Only the context that set a status clears it. SettingsMain used to run an unconditional setStatus("idle") whenever it had nothing pending - which included mount - so merely opening Settings wiped an error another context had just published to the Dock. It now tracks whether the pending on screen is its own and leaves anything else alone.

The same rule is why completeSaveThenIdle is the only way to report a success: it sets saved and arms the return to idle behind two checks - that the status is still the saved it set, and that no later save has re-armed the reset since. A bare completeSave() followed by setTimeout(() => setStatus("idle"), …) fires regardless of what happened in the meantime, so a rename that succeeded two seconds ago cleared the failure a delete had just published; and two saves a second apart had the first one's timer end the second one's indicator early. triggerSave has always guarded the first way, useSaveManager hand-rolled the second as a clearTimeout of its own timer. Both live in the store now, and the six call sites (useSaveManager, SettingsMain, VariableTableEditor, VariablesSection, both collection-tree renames) share that one implementation rather than five copies of the timer.

completeSave is gone with them. It set saved and armed nothing, so every caller either paired it with a hand-rolled timer or - VariablesSection, the one non-draft commit path - left "Saved" in the Dock until something else happened to change it. The indicator's lifetime is TIMING.SAVED_STATUS_DURATION_MS, in one place, for every surface that saves.

lastErrorMessage is the reason behind an "error" status, for the Dock's persistent line - see below. It went through the same fate as errorMessage once: the field existed for the Dock's error line, that line was removed in favour of the toast, and the field went with it as one nothing read any more. It is back because the toast turned out not to be the whole answer (next paragraph), and this time it has a second reader.

There is no lastSavedAt or pendingSaveId, for the same reason that field almost went a second time - every match on either name was a write inside save-store.ts. status is still the store's main public surface, and all five of its values have a reader: the Dock renders pending as "Unsaved changes", which is the only place in the app that says so. That matters because auto-save is a setting the user can turn off, and with it off nothing was written back and nothing said as much (the tab strip carries no unsaved-dot on purpose).

"error" has two readers now, not one. The toast failSave raises expires after TIMING.TOAST_DURATION_MS.error (10s), and a failed save can leave a draft unsaved for as long as the engine stays down - far longer than that. The Dock's ambient status line (Dock.tsx) renders an "error" status as "Not saved", coloured with --destructive-text, with lastErrorMessage in a tooltip on hover - the same shape EngineStatus uses for its own error, rather than a second way of saying "hover for the reason." It stays until the next save transition, so it outlives the toast for exactly as long as the draft stays unsaved.

A failed auto-save now retries itself. useSaveManager's debounced timer is the only caller that does: on a failure it re-arms with the auto-save delay, doubling on each further failure and capped at TIMING.SAVE_RETRY_MAX_DELAY_MS (one minute), cancelled on unmount, on an entity switch, and by the next ordinary edit. Cmd/Ctrl+S and the entity-switch/quit goodbye flush still call performSave directly and report their own outcome once - retrying either would save into a pane the user has already left, or double a save they just asked for by hand.

A reconnect flushes every dirty context, not just the one that failed. useHealthQuery's automatic-reconnect branch and useEngineRestart's manual one both call flushAll() right after invalidateQueries() - the same move, for the same reason: a dirty editor's own retry can be backed off up to a minute behind an engine that just came back, and nothing else pokes it sooner. Every registered context is safe to flush here, unlike the quit flush's urgency: a context with nothing pending is a no-op, and one that has never failed loses nothing by saving now instead of on its own schedule.

triggerSave will not report a success the context did not have. Registered contexts report their own failures through failSave and then resolve rather than rejecting - useSaveManager, SettingsMain and VariableTableEditor all do - so runSave checks for status === "error" after awaiting instead of setting "saved" unconditionally. Without that check a failed Cmd/Ctrl+S showed "Saved" beside its own failure toast.

It refuses "pending" on the same grounds (#1381). A context that saw an edit land while its write was in flight publishes pending, because the payload that went out does not hold that edit - and runSave is the path Cmd/Ctrl+S and the quit flush take, so overwriting it put "Saved" on the Dock over an edit nobody had persisted. One rule covers both: a status the context published for itself is the truthful one, and runSave only fills in the silence.

A writer that registers no context inherits that rule from completeSaveThenIdle. The collection tree's two renames call startSaving and completeSaveThenIdle by hand, so runSave never saw them: a rename published "Saved" onto the one status the Dock renders while the open request held an unsaved script - true of the rename, false of everything else on screen (#1385). The success reporter now asks the registry, and publishes pending instead while any other registered context still has hasPendingChanges; the context holding that edit clears it when it writes. A registered context names itself as it reports - completeSaveThenIdle(contextId) - because its own entry is refreshed by an effect and so still reads dirty at the moment its write lands. A direct writer names nobody and is measured against all of them, which is the point of guarding in the store rather than at the call sites: the writer added next inherits it without knowing the rule exists.

That same effect lag is why the pending this guard publishes is re-derived once, after TIMING.SAVED_STATUS_DURATION_MS. Two surfaces finishing within a render of each other each read the other's not-yet-refreshed entry as dirty, so both report pending over an editor holding nothing - and nothing would revisit it until the next save. The re-check clears it only while no context is dirty and no later save has re-armed the reset, so a pending a context published for its own unsaved edit is untouched.

SaveContext:

{
  id: string
  name: string
  save: () => Promise<void>
  hasPendingChanges: boolean
}

Key Methods:

const {
  registerContext, unregisterContext, updateContext,
  setActiveContext, getActiveContext,
  triggerSave,       // Ctrl/Cmd+S - saves active or first dirty context
  flushAll           // Save all dirty contexts (used before app quit); resolves
                      // to { saved, failed, pending } - see below
} = useSaveStore();

Non-persisted. See useSaveManager (debounced-autosave editors) and useDraftSaveContext (draft editors, whether they commit on blur or on a button) for registration details. Between them every dirty editor in the app is in this registry, which is what flushAll walks on quit - a surface that is not registered is not merely unsaved on quit, it is invisible.

The registry has a second reader: the collection tree refuses to drag a request whose open tab still has unsaved edits (contexts.get("request-<id>") ?.hasPendingChanges, keyed the way useSaveManager registers it). Two writers on one row - a save carrying the row's contents while a reorder rewrites its owner and order - is the clobber family #237 belongs to, and the drag is the half that can simply wait.

collection-auth-draft-store.ts - A Collection's Live Auth-Tab Draft

A Map<collectionId, CollectionAuth> mirroring AuthTab's in-progress draft outside the component (#1483).

Key Methods:

const draftAuth = useCollectionAuthDraft(collectionId); // undefined = no tab open, or clean

Non-persisted. AuthTab's draft is component-local useEntityDraft state, invisible to anything that is not AuthTab - including the Inheritance Chain card it renders as a child, and the context bar's CollectionAuthSection, mounted in a different part of the tree and backed by useCollectionsQuery directly. Both used to describe the last-saved mode while the picker sat on an unsaved one. AuthTab writes its current draft here on every render and clears its entry on unmount; withDraftAuth (modules/request-builder/utils/auth-resolution.ts) substitutes it into an ancestor chain before either reader calls resolveAuthSource, so a dirty pick and its own inheritance chain agree.

response-store.ts - Response Cache

In-memory storage of responses per request ID, persisted across view/tab switches but not to disk.

State:

{
  responses: Map<string, StoredResponse>,
  lru: string[]  // request ids, least-recently-written first
}

StoredResponse: Includes status, headers, body, execution time, script results, and console logs.

Key Methods:

const { setResponse, getResponse, clearResponse, clearAll } = useResponseStore();

Eviction by identity: clearResponse runs from useDeleteRequestMutation (the delete is what makes the response unreachable) and from tabs-store's closeTabsForEntities (the collection cascade, which knows every descendant id).

Eviction by count: RESPONSE_CACHE_MAX_ENTRIES (24) bounds the map (#1156). Those two delete seams were once the only ones, so the map grew with every distinct request a session sent - each entry a body plus its raw copy - until the app quit. What the map holds is the 24 most recently sent requests, not the open tabs: this store cannot ask tabs-store what is open, since that store imports this one. The cap is twice MAX_OPEN_TABS so that recency covers tab reach in practice - a full set of tabs can each be re-sent and every one of them still re-opens on its full-fidelity body. The gap is a tab held open without being re-sent while 24 other requests are, which tab eviction allows only for a dirty tab; it falls back like any older request. Order comes from lru, not from executedAt, which a restored response carries from the run that produced it and which therefore says nothing about when this session last touched the entry; setResponse is the only writer, and it moves a re-sent request back to the recent end, so the entry just stored is never the one evicted. Past the cap nothing is lost, only unabridged: the request re-opens against the backend's stored run, whose body the engine truncated at maxTraceBodyBytes.

Non-persisted (responses are reloadable from backend).

tab-selection-store.ts - Active Sub-Tab Per Entity

Three independent maps remembering which sub-tab is showing, per entity id: the request builder's tab, the response pane's tab, and the collection screen's tab.

State:

{
  requestTab: Map<string, RequestTab>       // keyed by request id
  responseTab: Map<string, ResponseTab>     // keyed by request id
  collectionTab: Map<string, CollectionTab> // keyed by collection id
}

Key Methods:

const { getRequestTab, setRequestTab } = useTabSelectionStore();
const { getResponseTab, setResponseTab } = useTabSelectionStore();
const { getCollectionTab, setCollectionTab } = useTabSelectionStore();
useTabSelectionStore.getState().clearEntity(id); // drops id from all three maps
useTabSelectionStore.getState().clearAll();

Why a store at all. Shell.tsx mounts one workspace tab's surface at a time, so RequestBuilderProvider, ResponseViewer and CollectionDetail all unmount whenever the user looks at a different tab and remount when they come back - and each held its active sub-tab in a bare useState, which is gone the moment the component is. The same three components also serve every open tab of their own type without remounting (RequestBuilder is not recreated per request tab, nor CollectionDetail per collection tab), so a tab-id change reaching the same component instance needed the same lookup a mount-time initializer already had to do - both call sites read and write through this store rather than through two different mechanisms.

In memory only, like response-store.ts above, and for the same reason: a sub-tab pick is not worth persisting across a relaunch. Unlike that store, this one carries no LRU cap - an entry is one enum value rather than a response body, so bounding it buys nothing.

Eviction by identity: clearEntity runs from tabs-store's closeTabsForEntities, unconditionally rather than gated on type the way the response clear is - a collection id keys collectionTab exactly as a request id keys requestTab / responseTab, so a collection cascade has to evict here too, and clearEntity is a harmless no-op for whichever of the three maps never held a given id.

appearance-store.ts - Pre-Paint Interface Preferences

The UI font, the interface scale, the corner roundedness and the interface density - the four preferences index.html's pre-paint script applies before React mounts. Seeded from localStorage at module load and written back one key per preference (vayu-ui-font, vayu-ui-font-custom, vayu-ui-scale, vayu-ui-radius, vayu-ui-density), not through zustand's persist: the pre-paint script reads those exact keys, and SETTINGS_STORAGE_KEYS clears them on "Reset app settings".

Density (issue #1670) differs from the other three in mechanism: font, scale and radius each resolve a stored value to something applyX computes and writes with style.setProperty, while applyDensity only toggles a data-density attribute on documentElement - the two densities are whole --spacing values declared directly in index.css (:root / [data-density="comfortable"]), so there is nothing to compute.

State:

{
  font: UiFontChoice   // a preset or "custom"
  fontCustom: string   // used when font === "custom"
  scale: number        // page-zoom factor, 0.8-2.0 in 0.1 steps
  radius: UiRadius
}

Each action both persists and applies (--font-sans, --radius, webFrame.setZoomFactor), so there is one write path per preference rather than a state update and a separate effect that has to agree with it.

Why a store and not useState in useAppearance: scale has two inputs. The Appearance panel's slider and the View menu's Ctrl/Cmd + - 0 move the same value, and useAppearance is mounted twice (the app shell and the panel). Per-instance state let the panel keep reading "Default" while the window rendered 133%. The menu bridge (onZoomCommandnudgeScale/resetScale) lives in useMenuActions, which mounts exactly once - subscribing from useAppearance would move the zoom two steps per keypress.

The scale range, the legacy-preset migration and the clamp/snap live in constants/appearance.ts (clampScale, parseScale, nudgeScale). The pre-paint script duplicates them by necessity - it runs before any module - and appearance.prepaint.test.ts executes the real script to keep the duplicate honest.

client-settings-store.ts - Renderer Preferences

Central home for renderer-only preferences that aren't part of the pre-paint appearance set (theme/color/UI-font/scale/radius live in their own localStorage keys so index.html can apply them before React mounts). Holds editor behavior, the monospace/code font, chart granularity, the capacity SLO threshold, the live refresh rate, auto-save preferences, reduced motion, the notification preferences and the load-test dialog's ceilings. Backs the Settings panels (modules/settings/main/panels/). Non-React consumers (services, the dashboard store) read via getState().

State:

{
  editor: EditorPrefs            // fontSize, wordWrap, minimap, lineNumbers, tabSize
  monoFont: MonoFontChoice       // a preset or "custom"
  monoFontCustom: string         // used when monoFont === "custom"
  chartBucketSeconds: number     // time-bucket width for the dashboard charts
  sloThresholdMs: number         // p99 latency the capacity breakpoint triggers at
  liveRefreshMs: number          // how often live metrics commit into the store
  autoSave: AutoSavePrefs        // { enabled, delayMs }
  reducedMotion: boolean         // mirrored onto <html data-reduced-motion>
  keepAwakeDuringRuns: boolean   // standing answer on the run wake lock; default off
  systemNotifications: boolean   // opt-in for OS notifications while Vayu is in the background; default off
  notifications: NotificationPrefs  // position, durationScale, maxVisible, minSeverity
  loadTestCeilings: LoadTestCeilings
}

import { SETTINGS_STORAGE_KEYS } from "@/stores";  // localStorage keys reset by "Reset app settings"

monoFont and reducedMotion also touch the DOM (--font-mono, data-reduced-motion), so their setters and onRehydrateStorage both apply them - a preference that only lands on the next reload reads as a broken setting.

keepAwakeDuringRuns is the user's standing answer to whether a run may ask the OS to stay awake (issue #1357). It is read when a run starts rather than watched, by LoadTestService and ScenarioRunService, so the answer that governs a run is the one that was true when it began - flipping the row mid-run does not reach a stream already going. Off is the shipped default: with it off a load run of ten minutes or more asks once (KeepAwakePrompt), and that grant takes the same wake-lock key the service releases, so it ends with the run.

systemNotifications is the opt-in for the OS notifications services/notify.ts posts (issue #1358). It is read at the moment an event fires, not watched, by the same reasoning as keepAwakeDuringRuns: every call site is a service or an event handler with no hook to hang a subscription on. Off is the shipped default, and it is persisted through partialize like the rest of this store's top-level preferences.

loadTestCeilings is the one slice with a bound outside the app: each value is clamped to LOAD_TEST_CEILING_BOUNDS (constants/load-test.ts) on write and on rehydrate, because the bounds are the engine's crash guards and a build that tightens one must not keep offering a stored ceiling above it. The load dialog turns them into its field ranges via resolveLoadTestLimits; nothing else reads them.

Persisted to localStorage (via zustand/persist), version: 1 with a pass-through migrate; workspace/session state (open tabs, layout, active environment) is deliberately excluded from the reset, as is the live chart window - that one is engine config, so it resets with the engine's settings.

Nested preferences are completed against their defaults on rehydrate (mergeWithNestedDefaults). zustand's merge is a shallow top-level spread, so a stored editor / autoSave / notifications / loadTestCeilings object replaces its defaults whole and a key added after that payload was written arrives undefined - notifications.maxVisible feeds slice(-maxVisible) in toast-store, and slice(-undefined) caps nothing. Adding an object-valued preference means adding a line to that merge; the store's test enumerates them rather than trusting the list.

dashboard-store.ts - Load Test Metrics & State

Manages live load test run state: streaming metrics, final reports, and running aggregates (peak concurrency, SLO breakpoint). Retention is time-based, not a fixed point count: addMetricsBatch trims ticks older than the user-configurable live window (liveWindowSeconds, default 5m, null = full run), backstopped by a maxRetainedTicks ceiling (default 50,000). Both are kept in sync by the useLiveChartSettings hook and drive what the live charts plot. (app/src/config/metrics.ts now only holds the SSE commit throttle, METRICS_UI_THROTTLE_MS.)

The window is engine config, not a renderer preference. It is stored as the engine's liveReplayWindowMs entry (milliseconds; 0 = full run), so useLiveChartSettings reads it from useConfigQuery and writes it with useUpdateConfigMutation - there is no localStorage key. The engine needs the same number: it sizes the in-memory SSE tick ring that GET /runs/:runId/live replays from offset 0, and that replay is what rebuilds these charts when the dashboard attaches or re-attaches mid-run. Two settings would let the retained span and the displayed span disagree, with the engine replaying less than the chart is configured to show. constants/live-window.ts owns the option list and the liveWindowToMs / liveWindowFromMs mapping. The Dashboard picker is its only editor: the engine settings list rendered a second one until #586, under a different label and a different save model, and ENGINE_SETTINGS_EDITED_IN_APP (modules/settings/engine-settings-edited-in-app.ts) is what keeps that row out of it now.

The tick ceiling is shared the same way, as liveMaxRetainedTicks (default 50,000). It is a memory bound, not a rendering one - bucketColumns collapses ticks into chartBucketSeconds buckets (0.5s by default) before uPlot sees them, and uPlot draws to canvas, so a full window reaches the screen as a few thousand points however many ticks back it. It also costs nothing at stock settings, since the window is what sizes the retained history; it only binds when window / tick-interval exceeds it. DEFAULT_MAX_RETAINED_TICKS here and DEFAULT_MAX_LIVE_TICKS in the engine are the pre-config defaults and must move together.

Because the value arrives asynchronously, the store seeds liveWindowSeconds with the module default rather than reading it synchronously at creation; the hook corrects it once the config query resolves.

State:

{
  currentRunId: string | null
  mode: "running" | "completed" | "stopped"
  isStreaming: boolean
  currentMetrics: LoadTestMetrics | null
  historicalMetrics: LoadTestMetrics[]  // Trimmed to liveWindowSeconds (cap: maxRetainedTicks)
  monitorSamples: MonitorSample[]       // Server vitals scraped this run (cap: maxRetainedTicks)
  liveWindowSeconds: number | null             // Live retention window; null = full run
  finalReport: RunReport | null
  error: string | null
  activeView: "metrics" | "request-response"
  isStopping: boolean
  loadTestConfig: LoadTestRunConfig | null     // Config snapshot during run
  requestInfo: LoadTestRequestInfo | null      // Request snapshot during run
  peakConcurrency: number                      // Running max (monotonic)
  breakpoint: Breakpoint                       // SLO crossing (latched on first breach)
}

Key Methods:

const {
  startRun, stopRun, setStreaming,
  addMetricsBatch,  // Efficiently fold batch into history and update aggregates
  addMonitorSamples, // Append scraped server vitals, bounded by maxRetainedTicks
  setFinalReport, setError, setActiveView, setStopping,
  setLiveWindowSeconds,  // Update the live retention window (from useLiveChartSettings)
  setMaxRetainedTicks
} = useDashboardStore();

The load-test dialog seeds its scrape cadence and bounds its metric list from the engine's monitorIntervalMs / monitorMaxSeries settings, through useMonitorSettings - the same read-the-engine's-copy arrangement useLiveChartSettings uses above, and for a sharper reason: this dialog always sends an explicit intervalMs, so a renderer-local default would mean the setting never applied to a run started here at all.

monitorSamples is kept beside the ticks rather than merged into them: the two are sampled by different clocks (the engine's tick cadence and the user's scrape interval), so they are joined onto one x axis at render time by joinMonitorToTimeline - which is also where a failed scrape becomes a gap in the line rather than a plateau. The array is empty for a run that configured no monitor, and that emptiness is what keeps the vitals chart row off the dashboard entirely. It carries its own copy of the tick ceiling because a scrape can be configured faster than the tick cadence.

There is deliberately no store-wide reset: startRun already wipes the series, the report and the aggregates, and a reset on top of it nulls currentRunId - the dashboard then shows no active test while one streams.

Non-persisted (fresh per session).

host-sleep-store.ts - When The Machine Slept Under A Run

The intervals the host spent asleep while a run was streaming (issue #1357), keyed by run id. The app holds a system wake lock for the length of a run, but that lock is a request to the OS: a closed lid or a critical battery overrides it, and the run's series is then missing a stretch that nothing in it explains.

The record lives here rather than in the run report because the engine was suspended too and knows nothing about it. useHostSleepRecorder writes it - the main process's power:suspended carries the anchor (where the run had got to, read off dashboard-store on the way down, since the renderer is frozen in between) and power:resumed carries the duration.

State:

{
  byRun: Record<string, HostSleep[]>  // { at, durationMs, startSeconds }, oldest first
  runOrder: string[]                  // first-annotated order, for eviction
}

Two readers, both by run id through the useHostSleeps(runId) selector rather than one prop drilled to both: MetricsView and PerformanceTab mark the sleeps on the charts, OverviewTab states them in RunEvents. The selector returns one frozen empty array for a run with no sleeps, so a dashboard rendering at 10 Hz is not re-rendered by a stable absence.

Persisted, bounded to the newest MAX_RUNS (20) runs and MAX_SLEEPS_PER_RUN (20) intervals each: the gap outlives the session that produced it, and a user who finds the hole tomorrow needs the same answer, but localStorage is a fixed budget shared with the workspace. migrate and merge both normalize, as recovery-notice-store does and for the same reason.

inbox-notify-store.ts - Which Inboxes May Notify On A Capture

Whether a capture landing on one inbox may raise an OS notification while Vayu is in the background (issue #1388), keyed by inbox id. Off for an inbox nobody has decided about, and off even when systemNotifications is on: a capture is the one relevant event with no natural rate, and a webhook source that retries would otherwise teach the user to turn the whole feature off, taking the run-finished notification with it.

It lives here rather than on the engine's inbox record, the way host-sleep-store keeps a run's annotation beside a report with no field for it: the engine's inbox is in-memory state that never outlives the process that opened it, and it does not act on this flag - only the desktop app does.

State:

{
  enabled: Record<string, true>  // only the inboxes that are on; off is absence
}

Four readers. Two at the moment a capture arrives: modules/inbox/capture-notifier.ts gates on it (the second gate; services/notify.ts reads the global opt-in), and the header toggle in modules/inbox/index.tsx writes it. The third is hooks/useInboxWatchers.ts, which reads the whole map continuously to decide which inboxes services/inbox-watch-service.ts should hold a live stream for: the toggle promises a notification while Vayu is in the background, and the inbox tab is mounted only while it is the active tab (issue #1400). The fourth is the Services drawer's inbox row (modules/services/ServicesPanel.tsx), which reads one inbox's entry to decide whether to say the promise is not currently being kept - a stream that gave up, or one the stream cap left out (issue #1412). It reads rather than writes, and an inbox whose toggle is off gets no note, because there is nothing to say about a promise that was never made.

Persisted, and pruned: an inbox id belongs to the engine process that minted it, so retainInboxes drops every id a successful GET /inbox no longer names, called from useInboxWatchers on a list read the engine actually answered - a failed read leaves the map alone, because "no inboxes" and "could not ask" are not the same list. It was called from InboxView until #1400, which pruned the map only while that tab happened to be open. migrate and merge both normalize, as host-sleep-store does.

scenario-run-store.ts - Live Collection-Run Steps

The live half of a scenario (collection) run's tab. ScenarioRunService pushes the step SSE events in here and ScenarioRunView reads them, so the stream survives navigating away from the tab and back - the same split, for the same reason, as LoadTestService and dashboard-store.

It holds one run. A scenario is sequential and the app starts one at a time, so startRun replaces the previous run's steps rather than accumulating; a tab for an older run reads an empty list rather than whatever is streaming now. addSteps drops a batch that arrives with no run registered, because the stream is replayable and a step from a replaced run can still land on a socket that has not finished closing.

State:

{
  runId: string | null       // The run being streamed, or null
  steps: ScenarioStepRow[]   // Reported so far, in plan order
  summary: StepListSummary   // The four outcome counts, plus how many steps are
                             // past the first pass and how many bound a data row
  appendEpoch: number        // Moves whenever the list changed by anything other
                             // than growing at its end
  isStreaming: boolean
  error: string | null       // A transport failure on the stream
}

A batch per flush, not a commit per event (issue #1153). ScenarioRunService buffers arriving step events and commits what it collected on the user's liveRefreshMs cadence, the same throttle LoadTestService puts on its ticks. The premise for having none - that a scenario's step rate is bounded by request latency - does not hold: the engine's scenario loop is sequential with no pacing, so a local target returns hundreds of steps per second, and each one used to cost a full copy of the step list, a store notify and a re-render of the run tab. The buffer is committed on the leading edge of a window and again on a trailing timer, and drained when the stream closes or fails; a run replaced mid-flight discards whatever it left buffered, since those rows belong to a list the store has already cleared. That mechanism - buffer, leading edge, trailing timer, and the liveRefreshMs read behind it - is one implementation, services/throttled-batcher.ts (issue #1206), and now has a third caller: useExecutionEvents buffers a design-mode request's streamed events through it the same way (issue #1158). When to flush and when to discard stay each caller's own, because the lifecycles genuinely differ - the design-mode hook flushes on teardown rather than discarding, because a teardown there is as often the builder unmounting mid-stream as it is a replacement, and the store's run guard is what drops the leftovers in the second case.

Steps are folded in by foldStepEvents (modules/history/main/scenario-steps.ts), which keys on (iteration, stepIndex) rather than arrival order: the engine's SSE ring replays from Last-Event-ID, so a reconnect re-delivers events already rendered and an append-only list would double every row it re-saw. A whole batch costs one array copy, and the fold returns the state it was given when every event in the batch was an idempotent replay, so a reconnect's replay does not re-render the list.

summary is maintained by that same fold rather than recounted by the view - the dashboard-store aggregate stance, for the same reason: a scan per commit is a scan over a list that grows for the length of the run. ScenarioRunView is its reader, for the four count chips and for the two whole-list questions its rows answer. It does not displace the report: once report.scenario can give the run's own totals those win, because thinning drops passes and the stored rows undercount them (see docs/app/COMPONENTS.md). summarizeSteps answers for the stored rows, which arrive complete, and is the oracle the incremental summary is tested against.

appendEpoch is the second thing that fold publishes, and the reason it has to (issue #1205). Every commit hands the view a new array - immutability, which zustand's change detection depends on - so from the rows alone an appended row and a replaced one look alike, and a reader that wants to keep anything derived from the list has to compare the two lists to find out. The fold already knows, so it says: foldStepEvents returns appendedOnly beside the fold, and the store turns it into a monotone counter that moves for a replay that replaced a row, a gap-resume that spliced one in, and a startRun that emptied the list. useFilteredSteps (modules/history/main/) is its reader: it counts what the batch that arrived added to a chip's or the search box's match, and produces rows only as far as the growing window it owns has to show them, so a narrowed live list costs one predicate pass over the batch rather than over the whole run per flush - and, once that window is full, no array copy at all (issue #1297). Monotone matters - React may render once for two commits, and a value that could return to one a reader had already seen would let a stale prefix through.

Once a run reaches a terminal status its stored results rows are the complete record and the view reads those instead. ScenarioRunService.handleClose invalidates runs.detail(runId) and refetches runs.report(runId) through the query cache - the same keys the tab reads, so a bare fetch would leave the pane on the stale copy. It also invalidates runs.lists() (the History row still says "running") and runs.lastCollectionRuns() (the context bar's Last run section says it too, from its own family, and is not polled at all).

That report fetch carries staleTime: 0, and the feature rides on it. The tab mounts the moment the run starts and asks for the report immediately, when the engine has written no step rows yet - they go to SQLite in one batch at the end - so the cache holds a report with an empty results[], seconds old. Under the hook's own five-minute RUNS_STALE_TIME_MS that entry reads as fresh and fetchQuery resolves from it without a request, which left the step list on the live rows for the life of the tab and made every step expand into an empty panel, since only a stored row carries the exchange. This is the one fetch in the app that knows the data just changed, so it is the one that must not honour the cache. The engine writes the rows, then the summary, then the terminal status, and publishes complete after all three, so the refetch cannot race the write it exists to pick up.

There is deliberately no stopMonitoring on the service and no clear on the store: the stream ends on its own when the engine sends complete, and nothing in the app stops a collection run mid-flight yet. Both belong with the stop control that would need them.

Non-persisted (fresh per session).

execution-events-store.ts - Live Streaming-Request Events

The live half of a streaming design request (issue #574): POST /execute with stream: true answers 202 {runId, eventsUrl} and the upstream's own events arrive over GET /runs/:id/events. modules/request-builder/hooks/useExecutionEvents buffers the relayed frames through services/throttled-batcher.ts and commits a batch per flush window on the user's liveRefreshMs cadence, so the store pays one copy and one notify per window rather than per frame (issue #1158) - the same batcher LoadTestService and ScenarioRunService commit through, now with three callers. The response pane's Events tab reads the store, so the rows survive switching to another request tab and back - the same split, and the same reason, as scenario-run-store above.

It holds one stream: a second Send is what ends the first, so two are not a state the builder can reach. It also holds the request the stream belongs to, not only the run - one RequestBuilderProvider serves every request tab, so "are these rows mine?" is a question about the request on screen and a run id alone cannot answer it. Both the provider and the viewer select against requestId, which is what stops a stream started elsewhere appearing under a request that never streamed.

State:

{
  requestId: string | null      // Whose Send started this stream
  runId: string | null          // The run the engine created; what a Stop names
  eventsUrl: string | null      // Engine-relative, exactly as the answer gave it
  open: StreamOpen | null       // What the stream connected to (the `open` frame)
  events: StreamEvent[]         // Received so far, oldest first
  totalEvents: number | null    // The engine's own count, from the `complete` frame
  isStreaming: boolean
  endReason: StreamEndReason | null
  error: string | null          // A transport failure on the relay
}

Every write is addressed to a run, and a write for a run the store is not holding is dropped. Not defensive tidiness: the relay replays its retained ring on connect, so a frame from a stream that has already been replaced can still arrive on a socket that has not finished closing - and those rows landing under the send that replaced it is the worst failure here, because such a timeline looks real.

totalEvents is set only from the complete frame (falling back to what arrived, so it is never left null once a stream has ended). While the stream runs, the arrived-so-far count is events.length; reporting anything else would be a total nothing had counted. It matters because it is not the row count once a list has been capped, and the Events tab's truncation disclosure compares the two.

Once the run reaches a terminal status the stored trace is the complete record: the provider fetches the report, restore-response.ts maps its events node onto ResponseState, and the tab reads that instead - the two-sources-one-list handoff ScenarioRunView makes. What lives here is only ever what arrived on the socket, and it is not bounded by the engine's retained ring: that ring (sseMaxRetainedEvents, 2,000 by default) is only what a reconnect replays, and a client that stays connected is replayed the ring once and then tails the rest of the stream. What actually bounds the list is the per-stream cap instead - sseMaxStreamEvents (100,000 by default, 10,000,000 at the ceiling) or sseMaxStreamDurationMs (600,000 ms / 10 minutes by default), whichever the stream reaches first - which is orders of magnitude bigger than the ring and why the Events tab cannot assume the list stays short. That is also why the tab (ResponseEvents.tsx) renders through useGrowingWindow with memoized rows rather than the whole list at once, and why a live stream passes its runId as the window's listKey - the default reset key is the row count, which would snap the window back to its first slice on every batch a growing-in-place list receives.

Non-persisted (fresh per session).

import-modal-store.ts - Import Modal UI

Simple modal state for the collection import dialog.

State:

{
  isOpen: boolean
}

Key Methods:

const { isOpen, open, close } = useImportModalStore();

toast-store.ts - Transient Notifications

The queue behind the toasts. It holds what to show; the Radix primitive in components/ui/toast.tsx owns when - the dismiss timer, pausing on hover, focus and window blur, swipe, and the open/closed state the exit animation keys off. There is no setTimeout in the store.

State:

{
  toasts: Toast[]   // { id, title?, message, variant, action?, duration }
}

Key Methods:

const { showToast, dismissToast } = useToastStore();

showToast("Run history cleared", "success");   // string form, still supported
showToast({                                     // returns the toast id
  message: "Couldn't stop the run",
  variant: "error",
  action: { label: "Try again", onClick: retry },
});

Variants are info | success | warning | error, with base durations of 4s / 4s / 6s / 10s from TIMING.TOAST_DURATION_MS. warning is for a refusal ("A load test is already running") as distinct from a failure.

Three of the policies are user preferences (notifications on client-settings-store, shapes in constants/toast.ts), applied here on the way in:

  • minSeverity - a floor, compared by passesSeverityFloor. Below it showToast returns an id and shows nothing.
  • durationScale - a multiplier over the per-variant duration (scaledDuration), not a replacement for it, so halving keeps the ratio between a confirmation and a failure. never becomes NEVER_DISMISS_MS rather than Infinity, which the primitive's setTimeout would coerce to 1.
  • maxVisible - the stack cap, defaulting to MAX_TOASTS (4); past it the oldest is dropped.

Two more policies live here because the primitive has no opinion on them: an identical message and variant already on screen is collapsed rather than stacked, and an explicit duration from the caller wins over the scale. Removal is driven by the primitive's onOpenChange(false), which covers timeout, close button and swipe alike, so there is one removal path rather than three - but it closes the toast rather than dropping it, and the entry leaves the queue TIMING.TOAST_EXIT_MS later so the exit animation has frames to run in.

The values are not in the store. Delays live in config/timing.ts (TOAST_DURATION_MS, TOAST_EXIT_MS) with every other UI-facing delay in the app, and the stack cap in constants/toast.ts (MAX_TOASTS). The store holds behaviour, not configuration.

See docs/design-system.md -> Toasts for the visual tokens.

Module-Local Stores

Module-local UI stores co-locate in app/src/modules/<feature>/<feature>-store.ts and manage feature-specific UI state that should not leak into the global store tree.

modules/collections/collections-store.ts - Collections Tree Expansion

UI-only: Which collections are expanded/collapsed in the tree.

State:

{
  expandedCollectionIds: Set<string>
}

Key Methods:

const {
  expandedCollectionIds,
  toggleCollectionExpanded, expandCollection, expandCollections
} = useCollectionsStore();

Every action here has a caller, and expanding is idempotent. expandCollection (one id) and expandCollections (an ancestor chain) both return the same Set when nothing changes, because it is passed down the whole tree and listed in effect dependencies - a fresh Set for a no-op re-renders every row and re-runs the reveal effect. That is why the tree calls them instead of hand-rolling if (!expanded.has(id)) toggle(id), which reads the set it writes and so drags expandedCollectionIds into the dependencies of everything that expands. collapseCollection and reset used to live here with no callers at all; collapsing goes through toggleCollectionExpanded.

modules/history/history-store.ts - History Filter & Sort

UI-only: Search, filter (type/status/pinned), and sort (newest/oldest) for the history tab.

State:

{
  searchQuery: string
  filterType: "all" | "load" | "design"
  filterStatus: "all" | "pending" | "running" | "completed" | "stopped" | "failed"
  pinnedOnly: boolean
  sortBy: "newest" | "oldest"
}

Helper: filterRuns(runs, filters) applies type/status filtering and sorting to the loaded pages. Search is not handled here: searchQuery is debounced into the server-side q param (see useRunsQuery) so it covers all runs, not just the pages loaded into the sidebar.

pinnedOnly is server-side for the same reason - it drives GET /runs?baseline=true, so a pin older than the loaded pages is still findable - and applied again in filterRuns. That second pass is not redundant: unpinning patches the loaded pages in place rather than refetching them (see useSetRunBaselineMutation below), so the row just unpinned would otherwise sit in the pinned-only list until the next poll. The param decides what is fetched; the pass decides what is shown. false is never sent: the engine reads baseline=false as "only unpinned runs", so the off state omits the param entirely.

Key Methods:

const {
  searchQuery, setSearchQuery,
  filterType, setFilterType,
  filterStatus, setFilterStatus,
  pinnedOnly, setPinnedOnly,
  sortBy, setSortBy,
  resetFilters
} = useHistoryStore();

modules/variables/variables-store.ts - Variables Category Selection

UI-only: Which category (globals/collection/environment) is selected in the variables tree.

State:

{
  selectedCategory: VariableCategory | null
  // VariableCategory = { type: "globals" } | { type: "collection"; collectionId }
  //                  | { type: "environment"; environmentId }
}

Key Methods:

const { selectedCategory, setSelectedCategory, reset } = useVariablesStore();

A variable-map write merges onto the freshest map, never onto its own copy

PUT /environments/:id, PUT /globals and PUT /collections/:id replace the whole variables map, so any writer holding less than the freshest map risks carrying a key it never touched back to its pre-write value. Two surfaces write these maps and both go through lib/variable-merge.ts's mergeVariableChanges rather than each re-deriving the read-fresh-merge-write shape (#1439):

  • The context bar's useVariableCommit (components/layout/context-bar/ variable-commit.ts) commits one key. CommitScope.read() re-reads the query cache at commit time, not the value the input was rendered with, and mergeVariableChanges applies the one changed key onto it before write/ mutate.
  • VariableTableEditor (modules/variables/main/) commits every row the user has actually edited. performSave diffs the current rows against baselineRef - the map the table last agreed with the server on - to get a VariableChanges of only the touched keys, then merges that onto dataVariables, the query-cache-backed prop, which is why it stays current even while the row-init effect below is refusing to reseed. A key nobody touched - an MCP agent's update_environment landing while the table has an unrelated unsaved edit - therefore survives the next save regardless of what the editor's own copy of it says.

A key both sides touched is a conflict, not a coin flip. While the table is dirty, the row-init effect recomputes the same diff against baselineRef on every dataVariables change purely to call findVariableConflicts, which reports a key only when the fresh map and the user's edit both moved it away from the baseline to different values. The table renders one Callout per conflicting key naming it, with a "Take theirs" action; the user's value is what saves by default (it is already the value performSave sends for that key) until they explicitly take the other side's. baselineRef itself only advances on a full reseed, on this editor's own successful save (to the map that save just wrote, so a later write is never compared against this save's own history), and when a conflict is resolved - never merely because the table was dirty when a fresh write arrived, since a key nobody has touched needs no conflict bookkeeping at all.

modules/settings/settings-store.ts - Settings Category Selection

UI-only: Which settings category (e.g., "ui") is selected in the sidebar.

State:

{
  selectedCategory: SettingsCategory | null
}

Key Methods:

const { selectedCategory, setSelectedCategory } = useSettingsStore();

lib/graphql/explorer-store.ts - GraphQL Schema Explorer View

UI-only, in memory: whether the schema explorer pane is open, and per schema identity the search text, the expanded row ids, the scroll position and whether rows show their full description or one clipped line.

State:

{
  open: boolean,
  byKey: Record<
    string,
    { search: string; expanded: string[]; scrollTop: number; showDescriptions: boolean }
  >,
  lru: string[]
}

Key Methods:

const { open, setOpen, view, setSearch, toggleExpanded, setScrollTop, toggleDescriptions } =
  useExplorerStore();

showDescriptions is per schema like everything else here: how much documentation a user wants on screen is a property of the schema being read, and an endpoint that documents nothing has nothing to answer for.

A store rather than component state, because the pane is unmounted for no reason the user did. Radix tears the whole Body tab down on every glance at Headers or Auth - the same unmount the body drafts exist for - and a component-state explorer comes back collapsed to its roots with an empty search box.

Keyed by schema identity, using the schema cache's own schemaCacheKey, so two requests against one endpoint share the tree they have opened and the same URL reached with different credentials does not. Deliberately not persisted and capped at EXPLORER_VIEW_MAX_ENTRIES (8, matching the schema cache): an expansion set is a description of a schema that may not exist next launch.

lib/graphql/reveal-store.ts - GraphQL Outline Click-to-Scroll

UI-only, in memory: one slot holding the operation the context bar's GraphQL outline asked the query editor to scroll to, until something serves it.

State:

{
  pending: { requestId: string | null; name: string | null; index: number } | null
}

Key Methods:

const { pending, revealOperation, clearReveal } = useRevealStore();

A store because the two ends cannot see each other. The outline lives in the context bar, outside RequestBuilderProvider; the Monaco instance lives inside GraphQLBody and stays there, the way the insert machinery applies its edits in-component rather than handing the editor out. What crosses the boundary is a request to reveal, not an editor.

Consume-and-clear, for the reason the insertion effect records: a command left in the slot is replayed on the next render and the next remount, and Radix remounts the Body tab on every glance at Headers. GraphQLBody clears it once served and when it cannot serve it (the operation was renamed away, which it says out loud); the provider clears one naming another request or a request whose body is no longer GraphQL, since nothing under it can ever serve those. The provider's other half is bringing the Body tab forward - the editor does not exist while another tab is on screen.

A command names its request for the reason the body drafts do: only one request builder is mounted at a time, so a mismatch takes a click and a tab switch in the same tick, and the cost of not carrying the id is another request's editor jumping to a line number that means nothing there.

TanStack Query (Server State)

TanStack Query manages server state with automatic caching, refetching, and synchronization. It is the source of truth for collections, requests, environments, globals, and runs.

Query Hooks

Located in app/src/queries/, one file per resource family, all re-exported from app/src/queries/index.ts, which is what callers import from. The key factory lives beside them in app/src/queries/keys.ts. A couple of hooks with a single consumer are imported from their file directly (useRunTimeSeriesQuery from @/queries/runs); everything with more than one is in the barrel.

Collections & Requests

  • useCollectionsQuery() - Fetch all collections (there is no single-collection query: a collection is read out of this list)
  • useRequestsQuery(collectionId) - Fetch requests in a collection
  • useMultipleCollectionRequests(collectionIds) - The same list for several collections at once, as parallel queries. Its requestsByCollection map is referentially stable while the underlying results are unchanged (built in useQueries' combine, which TanStack memoises only for a combine of stable identity - hence the useCallback). Callers list it in effect dependencies; when it was rebuilt on every call, the collection tree's reveal effect (useRevealActiveSelection) re-ran on every render and re-expanded a collection the user had just collapsed
  • useRequestQuery(requestId) / requestDetailOptions(requestId) - One request by id (GET /requests/:id), used by a restored request tab and by a design-run copy on cold start. Only an ApiError with statusCode === 404 becomes RequestNotFoundError (test it with isRequestNotFound); a 5xx or an unreachable engine is rethrown untouched, because a transport failure must not read as "this request was deleted"
  • useCollectionAncestors(collectionId) - The collection's ancestor chain including itself, root first, derived from the collections list rather than fetched
  • usePrefetchCollectionsAndRequests() - Warm-cache pass over every collection's requests at startup

Sorting: every tree list - roots, a collection's children, a collection's requests - sorts with the single exported compareTreeOrder (types/domain.ts): order, then createdAt, then id byte-wise. There were two comparators with two different tie rules and neither matched the engine's, so "run this folder" could execute in an order the sidebar had never shown. The rule is pinned to the engine's SQL by engine/tests/fixtures/tree-order-conformance.json, read by types/tree-order.conformance.test.ts and by the engine's tree_order_test.cpp - change one side and the other suite fails.

That rule orders one block. A folder's sub-collections and its requests are two separately ordered blocks, so their order values can collide, and where the blocks sit relative to each other is the render's rule: CollectionItem puts every subfolder above every request, at every depth (CollectionTree.folders-first.test.tsx). A recursive collection run has to execute in that same sequence - each subfolder's whole subtree, then the folder's own requests - which is pinned across the render and the engine's plan by engine/tests/fixtures/recursive-run-order-conformance.json, read by modules/collections/CollectionTree.run-order.conformance.test.tsx and by the engine's scenario_plan_test.cpp (issue #431).

Mutations: - useCreateCollectionMutation() - Create collection - useUpdateCollectionMutation() - Update collection (with cache update) - useDeleteCollectionMutation() - Soft-delete a collection into the trash rather than removing it (issue #988); invalidates coarsely, see below, and also invalidates the trash list so the Trash view picks it up - useCreateRequestMutation() - Create request - useUpdateRequestMutation() - Update request (invalidates only the lists that can have changed, see below) - useDeleteRequestMutation() - Soft-delete a request into the trash (also clears the response, and invalidates the trash list) - useReorderMutation() - Reposition collections and requests through POST /reorder in one transaction, optimistically (see below) - useImportMutation() (queries/import.ts) - Apply a parsed import through POST /import/apply in one transaction

Reordering (useReorderMutation) is the one mutation that writes its caches three times, because a drag is only believable if the row is where the pointer left it before the write returns:

  • onMutate snapshots every key the plan can touch and draws the plan into the caches - the same two steps the engine performs, normalize each named scope to 0..n-1 in display order, then position each move. A cross-collection move crosses list caches and updates requests.detail (which carries staleTime: Infinity, so a stale collectionId there would outlive every refetch).
  • onSuccess re-applies the rows the engine actually wrote, which the response carries. They are normally what was drawn, but a normalization is authoritative from the engine, so the tree settles on real positions rather than a guess.
  • onError restores the snapshots wholesale and reports through useSaveStore.failSave - the one channel every save failure reaches the Dock and the toast through. The mutation owns this rather than its caller, so the gesture layer cannot forget it.
  • onSettled invalidates the affected keys once - the collections list only if a collection moved, and only the request lists the plan named.

Every write goes through setQueryData with a fresh array, never a mutation in place: the map useMultipleCollectionRequests builds is compared by reference by the reveal effect, so an in-place edit would move a row on screen that the tree never notices. The plan itself comes from modules/collections/reorder-math.ts - a pure module, node-tested, that turns sibling lists and a drop index into the minimal set of rows to rewrite.

Trash

The read side of the same soft delete (issue #989): useDeleteCollectionMutation and useDeleteRequestMutation stamp a row rather than removing it, and these are how the app sees what got stamped (queries/trash.ts).

  • useTrashQuery() - Every deleted root, newest first (GET /trash). No staleTime: the list changes only through the two mutations below, the two delete mutations above, and the startup retention purge, so the default is already the cheapest correct answer.
  • useRestoreTrashMutation() - Put one deleted root back (POST /trash/:id/restore). Invalidates trash.all, collections.all, requests.all and prefetch.allRequests() - the same coarse invalidation useDeleteCollectionMutation does, and for the same reason (see the cascade delete note below).
  • usePurgeTrashMutation() - Destroy one deleted root for good (DELETE /trash/:id). Invalidates only trash.all: every other cache already stopped serving these rows when they were stamped, so purging them changes nothing a live read can see.

Both mutations also invalidate trash.all on failure (#1438). The row either acted on is one this list said existed, so the likeliest failure is that it no longer does - an agent's purge_trash_entry or restore_trash_entry landed between the drawer's last read and the click. Leaving the list alone there keeps a phantom row on screen that fails the same way on every further click; refetching either removes it or proves it real.

Environments & Variables

  • useEnvironmentsQuery() - Fetch all environments
  • useGlobalsQuery() - Fetch global variables
  • useCookiesQuery() / useClearCookiesMutation() (queries/cookies.ts) - The engine's cookie jars, one per environment, read and cleared by CookiesCard in Settings. The mutation invalidates rather than patching the cache: a clear that raced a request in flight would otherwise leave the panel claiming an empty jar the engine has already refilled
  • useClientCertificatesQuery() and the create / update / delete mutations (queries/client-certificates.ts) - the engine's host-to-certificate registry for mTLS endpoints (issue #707), read and edited by ClientCertificatesCard in Settings > Network & connectivity. The mutations invalidate rather than patching: the engine refuses a second entry for a host+port pair already taken, so the list is the authority on what a new entry may claim

Mutations: - useCreateEnvironmentMutation() - useUpdateEnvironmentMutation() - useSetActiveEnvironmentMutation() - The only writer of the active environment; PUTs isActive and mirrors it into session-store, rolling the store back if the engine refuses - useDeleteEnvironmentMutation() - useUpdateGlobalsMutation()

Runs & History

  • useRunsQuery(q?) - Run history as an infinite query over the paginated GET /runs {data, pagination} envelope, newest first. Mirrors useRunTimeSeriesQuery's getNextPageParam on the same envelope shape; fetchNextPage pages older runs in on demand. q is the optional server-side search.
  • Polling is gated to the unpaged state (runsPollInterval): 5s while only page 1 is loaded, off once the user has paged older runs in. Refetching an infinite query re-fetches every loaded page in sequence, so a user ten pages deep drove ~10 engine requests per tick. Only page 1 can gain rows (start_time DESC) anyway; a paged list refreshes on the next mutation or invalidation instead of on a timer.
  • Only a surface that renders runs mounts it. The interval belongs to the hook, so it runs for as long as any observer is mounted - and the App root used to be one, which kept the poll alive with History closed, nothing running and nothing at the root reading the result (#1150). The root now calls usePrefetchRuns() instead: one prefetchInfiniteQuery over runsListInfiniteOptions(), which warms the same cache entry without observing it. HistoryList, WelcomeScreen and the palette's useEntityItems observe the key while they are visible and drive the cadence themselves.
  • runsListInfiniteOptions(q?, pinnedOnly?) - the key, fetcher and page params, shared by the hook and the warm-up so the entry one writes is the entry the other reads. prefetchInfiniteQuery, not prefetchQuery: the readers are infinite queries and the cache entry has to be in InfiniteData shape.
  • flattenRunPages(data) - flatten the pages into a de-duped Run[] (dedupe by id guards the momentary double-row a head insertion can cause across two refetched pages). runsTotal(data) - the server's total from the first page.
  • useAllRunsQuery() - Every run (all pages) as a flat list, for callers that need the whole set rather than a polled page (Settings' count + clear). Not polled.
  • useLastDesignRunQuery(requestId) - The most recent completed design run for a request, in one filtered call (?requestId=&type=design&status=completed&limit=1) - the server sorts start_time DESC, so its single row is the answer. No client-side download-and-filter. It caches a plain RunListResponse, so it has its own key family (queryKeys.runs.lastDesign(requestId)) and deliberately does not sit under runs.lists(): RequestBuilderProvider mounts it for every open request tab, and the delete-run patch walks that prefix as InfiniteData. Keeping the two shapes apart at the root is the rule - a prefix patch must never meet a cache shape it did not write. Under the lastDesigns() prefix, like the three families below it, so a delete, a cleared history or an MCP run event reaches it without knowing which request it belonged to (#776): a run id gives no way back to one, and until the prefix existed a deleted design run went on being restored into the tab that had it open.
  • useRecentDesignRunsQuery(requestId) - The last RECENT_DESIGN_RUN_LIMIT (5) design runs of a request, newest first, behind the context bar's Recent sends section. One filtered call (?requestId=&type=design&limit=5) and no report fetch: each row carries its own resultSummary (statusCode + latencyMs) from the engine, and the report path would load and JSON-parse every result's trace_data, per row. Deliberately unfiltered by status, unlike useLastDesignRunQuery - status takes one value, so filtering to completed would hide every failed send, which is most of what a trend is read for. Own key family (queryKeys.runs.recentDesign(requestId)) under the recentDesigns() prefix, outside runs.lists(), for the same shape reason as lastDesign. Not polled: the builder's send path, DesignRunView's replay, the MCP run event, useDeleteRunMutation and useInvalidateRuns (Settings' Clear run history) each invalidate it, and a new run-writing path has to as well or the section keeps showing the sends from before it.
  • useLastCollectionRunQuery(collectionId) - The most recent run of one collection, behind the context bar's Last run section. One filtered call (?collectionId=&limit=1) over the engine's collectionId filter, which matches a scenario snapshot's own scenario.collectionId as JSON rather than its text - the lookup that did not exist, which is why the section was deferred twice (#377 → PR #394 → PR #400 → #422). Unfiltered by status for the same reason useRecentDesignRunsQuery is: a failed run is the one worth surfacing. Own key family (queryKeys.runs.lastCollectionRun(collectionId)) under the lastCollectionRuns() prefix, outside runs.lists(), for the third time and the same shape reason. Invalidated by useStartScenarioRunMutation (the new run is the last run), useDeleteRunMutation and useInvalidateRuns.
  • useRunQuery(runId) / runDetailOptions(runId) - Fetch a single run (full configSnapshot). Same 404 contract as requestDetailOptions: only an ApiError with statusCode === 404 becomes RunNotFoundError (test it with isRunNotFound, never by message), and that error is never retried - a run tab is persisted and outlives its run, so a deleted run used to retry forever behind a "Try again" that could not work. Everything else - a 5xx, an unreachable engine - is rethrown untouched and keeps the default retry budget. HistoryDetail renders the two cases differently: "this run no longer exists" with Close tab, versus "couldn't load this run" with Try again.
  • useRunReportQuery(runId) - Fetch final report for a run. Also written by LoadTestService at stream end via queryClient.fetchQuery, so opening the run in History reads the cached copy rather than re-fetching a report that cannot change.

  • useRunTimeSeriesQuery(runId) (@/queries/runs) - The stored per-tick series behind the History charts, as an infinite query over the same {data, pagination} envelope. Historical data cannot change, so staleTime: Infinity.

Mutations: - Stopping a run is not a mutation hook. It is useEngine().stopLoadTest(runId), which calls apiService.stopRun and returns a boolean rather than throwing - the dashboard's Stop button reports the refusal itself. - useDeleteRunMutation() - Delete a run. Patches every infinite-list cache variant in place (drops the row, decrements the mirrored total) plus the all-runs cache, and evicts the run's report. The list updater shape-guards (!Array.isArray(old.pages)) as a belt to lastDesign's braces. The recentDesigns() and lastCollectionRuns() families are invalidated rather than patched: a deleted run gives no way back to the request or collection their sections are keyed by, and refetching a few rows beats carrying a run-to-owner map to patch them.

Deleting a run closes its tabs. Both delete flows - HistoryList's row delete and Settings' Clear run history - call closeTabsForEntities(ids, "run") for the runs the engine actually accepted (a 409'd run keeps its tab, because the run is still there). Tabs are persisted, so a run tab left open after its run is gone is not merely stale: it rehydrates into a pane that can never load on every restart.

Engine Health, Config & OAuth

  • useHealthQuery() - Health check, polled every TIMING.HEALTH_CHECK_INTERVAL_MS; it is what sets engineStatus / engineError on engine-store, and is where the starting-vs-unreachable decision gets made, so the connection indicator follows it. It reads that decision off engineStartWindow, which useEngineRestart also opens - the restart supplies the evidence, the poll still does the classifying
  • useConfigQuery() / useUpdateConfigMutation() - Engine configuration (QUERY_CACHE.CONFIG_STALE_TIME_MS); also the home of the live chart window, which is engine config rather than a renderer preference
  • useScriptCompletionsQuery() / useScriptTypeDefinitionsQuery() - the pm.* completion list and the engine-generated .d.ts behind Monaco's hover text and diagnostics. Both derive from one engine-side table that changes only with the engine binary, so both take QUERY_CACHE.SCRIPT_COMPLETIONS_STALE_TIME_MS and the same gc time; the type definitions also cap retries at SCRIPT_COMPLETIONS_RETRY, since losing them costs hover text rather than the editor
  • useOAuth2TokenStatusQuery(cacheKey), useFetchOAuth2TokenMutation(), useClearOAuth2TokenMutation() - the engine-side OAuth 2.0 token cache
  • queryKeys.compose.forRequest(requestId, environmentId) - POST /compose for a stored request, behind an inline useQuery in the context bar's Code section. Keyed by environment as well as request, because the same request composes differently per environment and one key for both would serve the wrong snippet after a switch. staleTime: Infinity with an explicit recompose: the section is only mounted while expanded, so this is the "compose on expand, not per keystroke" rule. The section also ships collapsed by default (CONTEXT_BAR_DEFAULT_COLLAPSED), so that round trip now happens on first expansion rather than on every request tab opened (#1310). queryKeys.compose.allForRequest(requestId) is the per-request invalidation root, and forRequest is built from it rather than beside it so the prefix relationship holds by construction - that is the key an MCP write to one request drops (#1438), and staleTime: Infinity does not stand in its way: it gates the cost of composing, not the correctness, because invalidateQueries refetches a mounted observer whatever its staleTime

Query Keys & Cache Invalidation

Centralized in app/src/queries/keys.ts, using TanStack Query's hierarchical key factory pattern. Copy the shapes from that file rather than hand-writing a key - a key that differs by one segment is not an error, it is a silent cache miss that presents as a data bug:

// app/src/queries/keys.ts (abridged)
collections: {
  all: ["collections"],                                   // a constant, not a call
  lists: () => ["collections", "list"],
  list: () => ["collections", "list"],
  details: () => ["collections", "detail"],
  detail: (id) => ["collections", "detail", id],
},
requests: {
  all: ["requests"],
  lists: () => ["requests", "list"],
  listByCollection: (collectionId) => ["requests", "list", { collectionId }],
  details: () => ["requests", "detail"],
  detail: (id) => ["requests", "detail", id],
  examples: (id) => ["requests", "detail", id, "examples"],  // saved example responses (#481)
},
trash: {
  all: ["trash"],
  list: () => ["trash", "list"],   // no per-entry detail cache - the list is the only read
},
runs: {
  all: ["runs"],
  lists: () => ["runs", "list"],
  list: (filters = {}) => ["runs", "list", filters],      // keyed by its server-side filters (q, baseline)
  lastDesigns: () => ["runs", "lastDesign"],               // prefix: invalidate every request's last run
  lastDesign: (requestId) => ["runs", "lastDesign", requestId],
  recentDesigns: () => ["runs", "recentDesign"],           // prefix: invalidate every request's list
  recentDesign: (requestId) => ["runs", "recentDesign", requestId],
  lastCollectionRuns: () => ["runs", "lastCollectionRun"], // prefix: invalidate every collection's row
  lastCollectionRun: (collectionId) => ["runs", "lastCollectionRun", collectionId],
  baselines: () => ["runs", "baseline"],                  // prefix: invalidate every request's pin
  baseline: (requestId) => ["runs", "baseline", requestId],
  allRuns: () => ["runs", "allRuns"],
  detail: (id) => ["runs", "detail", id],
  report: (id) => ["runs", "report", id],
  timeSeries: (id) => ["runs", "timeSeries", id],
  monitorSeries: (id) => ["runs", "monitorSeries", id],   // scraped server vitals, when the run had a monitor
  samples: (id) => ["runs", "samples", id],               // captured response bodies, fetched lazily
},
// environments mirrors collections; globals / cookies / health / config /
// scriptCompletions / scriptTypes / oauth / prefetch each own a root.

The lists() / details() levels exist to be invalidated as prefixes; what a query is keyed on is list() / detail(id). requests.examples(id) sits under detail(id) rather than beside it, because examples are owned by the request: invalidating one request's subtree drops its examples with it, which is what a request delete needs. runs.lastDesign, runs.recentDesign, runs.lastCollectionRun and runs.baseline sit under runs.all but deliberately not under runs.lists() - see below.

runs.baseline(requestId) caches the run pinned as that request's baseline (GET /runs?baseline=true&requestId=&limit=1), which the history report's vs-baseline strip reads. Pinning, unpinning and deleting a run all invalidate the baselines() prefix rather than patching it: the pin moves, and a run id gives no way back to the request whose baseline it was - a run of an unsaved request has no requestId at all. The pin flag on the loaded list rows is patched in place from the mutation's response, the same way a delete patches them, because the sidebar polls only its first page and a refetch would leave a pin invisible on any page the user had scrolled to.

specs.detail(id) and specs.meta(id) are the same document under two keys (issue #712): the full read carries content and both extracted indexes, the meta read describes the row and nothing else. They are kept apart rather than merged so a cached description can never satisfy a reader that needs the text - and holding one row twice is safe here precisely because a document is immutable, which is also why both carry staleTime: Infinity. The Spec tab's card reads meta; the import dialog's bound-spec match and the Sync section's Check read the full document, each on the action that needs it. Export reads neither since it moved engine-side (issue #855) - specs.export(collectionId, format, opened) holds the finished document instead, keyed by format because the dialog toggles between two serializations of one answer, and by the moment the dialog mounted because a collection changes under its id in a way a document never does: that is what makes it fresh on every open and cached across a toggle, which neither staleTime alone can say. A key per format also means a switch is a cache miss, so this is the one read that carries placeholderData: keepPreviousData (issue #1311): the counts the dialog prints belong to the collection rather than to the serialization, and are the same either way, so the previous answer is the honest thing to hold while the next one assembles. The dialog gates Copy and Download on isFetching for that window - the text under them is still the previous format's - and shows a placeholder shaped like its summary card on the first read, when there is nothing to keep.

specs.match(collectionId, fingerprint) is the third of that family and the one that names no document (issue #761): the pairing of a collection's requests against a picked document's operations, answered by POST /specs/match since the matcher moved engine-side. There is no id to key it by - the document has not been stored, because the tab asks before the user commits to the bind - so the key carries a fingerprint of both inputs, exactly the fields the matcher reads off them (a request's id, method and URL; an operation's identity). That is what makes its staleTime: Infinity honest: an answer cannot go stale while both inputs are pinned in its key, and either of them changing is a different key rather than a stale entry under this one.

Automatic Invalidation: - Mutations automatically invalidate related queries (e.g., creating a request invalidates the collection's request list) - Some mutations use optimistic updates and cache updates for instant UI feedback - A cascade delete invalidates coarsely, on purpose. Deleting a collection deletes its descendant collections and all their requests engine-side, and which rows those are is engine-side knowledge - a client that re-derives the subtree to patch caches surgically will drift from the engine's definition of "descendant". So useDeleteCollectionMutation invalidates collections.all + requests.all wholesale. requests.detail entries carry staleTime: Infinity, so without this a deleted request stays fresh forever and keeps feeding restored tabs. useRestoreTrashMutation invalidates the same two families for the same reason, in the other direction: which rows a restore brought back is just as much engine-side knowledge - the cohort is defined by a timestamp the client never sees - so it takes the same wholesale invalidation rather than a client guess at which requests came back with a restored collection. - A single-request update invalidates narrowly, for the same reason. Which lists a request write can affect is client-side knowledge: the request's own collection, plus the one it left if the write was a move. So useUpdateRequestMutation invalidates requests.listByCollection(id) per affected collection rather than requests.lists(). It used to refetch every collection's list on any rename, which a reorder (a run of sibling PUTs) turns into one full-tree refetch per row. The source collection of a move is read from the requests.detail cache, which still holds the pre-update row at that point - the response carries only the new owner. - The warm-cache prefetch is a query too. usePrefetchCollectionsAndRequests is keyed as queryKeys.prefetch.allRequests() rather than an inline key, so creating a collection can invalidate it - it succeeds once at startup and would otherwise never re-run for a collection created mid-session. - ...but warming a polled list must not be a query. usePrefetchRuns is a one-shot prefetchInfiniteQuery on mount, not an observer, because useRunsQuery carries a refetchInterval and observing it from the root bought the warm cache at the price of a poll that never stopped (#1150). An invalidation of runs.lists() then marks the warm entry stale without refetching it, which is the wanted behaviour: nothing is displaying it.

Writes from outside the renderer: mcp:data-changed. An MCP tool call mutates the engine from the Electron main process, so no mutation runs here and no query notices - and with refetchOnWindowFocus: false (below) nothing ever catches up on its own. A request an agent created stayed invisible in the collection tree until some unrelated renderer mutation happened to invalidate the lists. The main process now sends one event per data family a successful call touched; useMcpDataInvalidation() (registered once, in App.tsx) maps it to keys through lib/mcp-invalidation.ts:

Entity Invalidates Why that key
collection collections.all, requests.all, trash.all, compose.all, prefetch.allRequests() A delete_collection cascades through descendants and their requests, and which rows those were is engine-side knowledge - the same reason useDeleteCollectionMutation invalidates coarsely. trash.all because a delete is what adds a row the drawer lists and a restore or purge is what takes one away; compose.all because a collection write moves the chain every descendant inherits auth, headers and scripts from, and this event carries no way to name them; prefetch.allRequests() because the warm-cache pass succeeds once and would stay fresh forever, so a collection created mid-session never gets one - the same invalidation useCreateCollectionMutation and useRestoreTrashMutation do (#1438)
request requests.listByCollection(collectionId), or requests.lists() when the call named no collection, trash.all, plus requests.detail(requestId) and compose.allForRequest(requestId) when the call named one row The same narrowing useUpdateRequestMutation does; without a named owner the owner is unknowable here. The detail key is for update_request / delete_request: it is staleTime: Infinity, so a restored tab would otherwise keep serving the copy it read on open. trash.all because delete_request declares this family alone and stamps a row the drawer lists. The compose key is the per-request prefix, not compose.all: every tool that can change a composition either names the row it wrote or declares collection too, create_request composes nothing yet, and invalidateQueries refetches a mounted observer whatever its staleTime - so the wide key would re-compose an open snippet once per write in an agent's loop over rows it never touched. prefetch.allRequests() is deliberately not here for the same reason: it fans out one list fetch per collection, and this is the family an agent loops over row by row (#1438)
environment environments.all, globals.all, compose.all Variables are read through the detail cache as well as the list; POST /compose substitutes those same variables, and nothing refetches a composition on its own. globals.all rides along because update_globals (#758) declares this family - same resolution order, same blob shape, and an entity of its own would have had exactly one reader
run runs.lists(), runs.allRuns(), runs.baselines(), runs.recentDesigns(), runs.lastCollectionRuns(), runs.lastDesigns() - and a removal of runs.detail/report/samples/timeSeries/monitorSeries for a named runId The history list polls, but Settings' count, the vs-baseline strip, Recent sends, Last run and every open tab's last design run do not. The four prefixes rather than per-row keys because a run id gives no way back to the request or collection it belonged to - the same trade useDeleteRunMutation makes
cookie cookies.all One key for every jar - the engine reports them together
config config.all, requestDefaults.all requestDefaults is a second endpoint over the same entries rather than a slice of the first: four of them (negotiateCompression, loadNegotiateCompression, correlationIdEnabled, correlationIdHeader) decide what a send adds on its own and the engine resolves the answer, so the declared set is re-read rather than derived here - which is why useUpdateConfigMutation drops both keys. At the root, because design and load are separate cache entries and one write can move both (#1438)
service inbox.list(), mockServer.list(), mockIssuer.list(), plus a removal of inbox.captures(inboxId) for a named inbox and of mockServer.routes(mockId) for a named mock The drawer and the Dock's count poll, so the lists are about immediacy; the captures cannot be invalidated, because useInboxCapturesQuery merges its fetched page into the cache and would union back the rows a clear_inbox_captures just destroyed, and a stopped mock's route table has no id left to refetch from
oauth oauth.all The whole prefix, not the one key: useOAuth2TokenStatusQuery is keyed per cache key, and the key a fetch_oauth2_token writes under is derived engine-side and appears only in the answer, so the event carries no hint to narrow by. The query polls at 30s on its own, so this is immediacy - an agent that clears a token must not leave the row saying it is valid

The event carries no engine data, only which family went stale, so a row still reaches the UI by exactly one path: the query layer. Per-run reports and time series are still never invalidated wholesale - a new run cannot have changed an existing run's report, and those are the expensive fetches in the family. They are dropped only for the one run a call named: stop_run, set_run_baseline and delete_run each take a runId, and the event carries it as a third scope hint. Removal rather than invalidation, because samples and both series are staleTime: Infinity - a deleted run would otherwise go on rendering under an open History tab until its entry was garbage collected. runs.detail goes with them so the pane refetches, takes its 404 and shows HistoryDetail's "This run no longer exists" state instead of a run the sidebar no longer lists. The hint says which run changed, not how, so a set_run_baseline costs an open detail pane one refetch of data it already had; a stale answer is a lie and a refetch is a wait. runs.lastDesigns() is taken at its prefix and never per request (#776): delete_run and set_run_baseline name a runId and no request, so the narrow key could not reach the tab whose run went away, and the cost of the prefix is that a run_request refetches one filtered row per mounted tab instead of one. The service family (issues #756, #757) takes the same shape one level down, with two scope hints instead of one. inboxId: a named inbox has its capture list removed rather than invalidated for a reason the run family does not have - three writers share that one cache entry (the fetch, the load-more pages and the live SSE stream), so every write to it is a union by capture id, and a refetch into a cache still holding cleared rows puts them straight back. The app's own clear mutation writes an empty page first for exactly this; from the main process the equivalent is to drop the entry. mockId: a named mock has its route table removed for the mirror reason - the table is a start-time snapshot held at staleTime: Infinity, so an invalidation would not refetch it, and after a stop_mock_server there is nothing to refetch from, since a mock's record dies with its listener. Both are what useDeleteInboxMutation and useStopMockServerMutation already do app-side. All three lists are invalidated on every service event rather than one per kind, because the entity is deliberately one family: the drawer and the Dock's count ask "what is listening", not "which kind". One field on that event is not about the cache at all. startedRun rides the run event of start_load_run and run_collection and names the run those tools just created, plus which of the two run services owns its stream ({runId, kind}, issue #1419). useRunWatchers() - registered once in App.tsx, beside useMcpDataInvalidation() - reads it and calls loadTestService.startMonitoring or scenarioRunService.startMonitoring, which is the same path the dashboard and the Run Collection dialog take, so the wake lock, the OS progress bar and the finished notification all begin when an agent's run does rather than when someone opens its tab. For a load run it points dashboard-store at the run first, exactly as those surfaces do: startMonitoring states that its caller has registered the run, and a store left unpointed would collect the ticks of a run it does not name. It carries no config - the agent's arguments are the engine's business, and a run with no declared duration is one whose bar has no denominator yet. Every other run event names a run that already exists (a stop, a baseline change, a delete) and starts no watcher; run_collection_smoke sends its requests one at a time and has no run to watch at all.

The entity list is duplicated across the process boundary (MCP_DATA_ENTITIES in electron/mcp/tools.ts, McpDataEntity in types/domain.ts) because production code under electron/ cannot import from app/src; data-changed.conformance.test.ts is what keeps the copies equal, and the map above is a Record over the union so a new family fails to compile until it names a reader. The emitting side is documented in docs/engine/mcp.md.

Retry policy: the shared default is shouldRetryQuery (lib/query-client.ts), not a bare count. A 4xx from the engine is a verdict, not a hiccup - a 404 for a deleted row answers identically every time, so retrying it only delays the error the caller is waiting on. 4xx is never retried; everything else (5xx, timeout, unreachable engine - which http-client.ts throws as a plain Error, not an ApiError) keeps the DEFAULT_QUERY_RETRY budget.

Cache policy lives in config/cache.ts (QUERY_CACHE), not in the call sites. Name the constant when you need a duration; restating the number here is how this section drifted before. The current shape:

Query Policy
Collections, requests lists, environments, globals, runs list DEFAULT_STALE_TIME_MS (30s) via the shared client
Request detail (requestDetailOptions) staleTime: Infinity - a restored tab reads it once and mutations invalidate it
Run detail, run report RUNS_STALE_TIME_MS (5m); completed runs are immutable
Run time series staleTime: Infinity, RUNS_GC_TIME_MS (30m)
Engine config CONFIG_STALE_TIME_MS (1m)
Script completions SCRIPT_COMPLETIONS_STALE_TIME_MS (1h), same gc time
Health staleTime: 0, refetched every TIMING.HEALTH_CHECK_INTERVAL_MS (30s)

Polling intervals are separate from staleness: the runs list polls every 5s only while unpaged (runsPollInterval), health polls on TIMING.HEALTH_CHECK_INTERVAL_MS, and the shared client sets refetchOnWindowFocus: false - a desktop app that refetched everything on focus would fight the editor the user just came back to.

Custom Hooks

useEngine() - Compose, Execute, Stop

Wraps the three engine calls that are not queries, with isExecuting / error for the caller to render.

API:

const {
  composeRequest: (params: ComposeRequestRequest) => Promise<ComposedRequest>
  executeRequest: (params: ExecuteRequestRequest, environmentId?: string) => Promise<SanityResult | null>
  stopLoadTest: (runId: string) => Promise<boolean>
  isExecuting: boolean
  error: string | null
} = useEngine();

  • composeRequest is POST /compose - the engine resolves {{variables}} and inherit auth and hands back the execute-ready payload (issue #226). It throws on failure; the caller surfaces it like an execute failure.
  • executeRequest takes an already composed payload. It never throws: a failure comes back as a SanityResult with status: 0 and an errorCode, because the response pane renders the failure the same way it renders a response.
  • stopLoadTest returns false rather than throwing when the engine refuses.
  • Starting a load test is not here. The request builder composes, then calls apiService.startLoadTest() (POST /runs), useDashboardStore().startRun() and loadTestService.startMonitoring() in that order, at one call site - registering the run before attaching the stream is what keeps the dashboard from showing a stream with no run behind it.

Live Metrics Streaming - loadTestService + sseClient

There is no React hook for the metrics stream, on purpose. The stream has to outlive whichever view is mounted (navigate away from the dashboard mid-run and the run keeps streaming), so it is a module singleton: services/load-test-service.ts holds the state machine and services/sse-client.ts the EventSource.

import { loadTestService } from "@/services/load-test-service";

loadTestService.startMonitoring(runId);  // after useDashboardStore().startRun(...)
loadTestService.stopMonitoring();
  • Connects to GET /runs/:runId/live, a replayable tick topic read from offset 0 - so attaching, or re-attaching from History mid-run, rebuilds the charts rather than starting blank.
  • Every tick is buffered; commits into useDashboardStore().addMetricsBatch() are throttled to METRICS_UI_THROTTLE_MS so historicalMetrics keeps the full 10 Hz signal while renders stay bounded. The buffer, the leading edge and the trailing timer are services/throttled-batcher.ts, shared with ScenarioRunService (issue #1206) and with useExecutionEvents (issue #1158) - a monitor scrape has no timer of its own and rides the next tick's flush, which is why that second buffer stays in the service rather than moving into the batcher.
  • The engine sends an explicit complete event, so a CLOSED readyState is a genuine failure. There is no custom reconnect: EventSource cannot set Last-Event-ID on a fresh connection, so a manual reconnect would replay the whole topic and duplicate every tick already plotted. The browser's own intra-connection retry does carry it and is left alone; once the browser gives up, recovery is converging on GET /runs/:id/report, which the service does in its close handler.

useVariableResolver() - Variable Resolution

Resolves {{variableName}} patterns in strings and objects using environment, collection, and global variables.

API:

const {
  resolveString: (input: string) => string
  resolveObject: <T>(obj: T) => T
  getVariable: (name: string) => ResolvedVariable | null
  getAllVariables: () => Record<string, ResolvedVariable>
  getVariableOrigins: (name: string) => VariableOrigin[]
} = useVariableResolver({ collectionId?: string; boundRow?: DataFileRow });

// resolveString takes an optional per-call row (issue #1074), for the one
// caller that resolves for several requests at once - the tab strip.
resolveString(input: string, row?: DataFileRow): string

Resolution Priority (highest to lowest): 1. Bound data row (bare column names) - only while boundRow is passed and a row is actually picked (issue #1062) 2. Environment variables 3. Collection variables 4. Global variables

Collection scope comes from the collectionId option and nowhere else - a caller that passes none resolves against globals + environment. See docs/app/variable-resolution.md for why the session-store fallback was removed.

boundRow is optional and additive: without it resolveString / resolveObject resolve exactly as before (composition only, both a bound column and {{data.column}} deferred). With it, they resolve through resolveTemplateWithRow instead, so a bare bound column and {{data.column}} both read the row - the request builder's provider is the one caller that passes it, deriving it from the picked-row index. See docs/app/variable-resolution.md for the tier and its rules.

resolveString's second argument is the same row named per call instead of per hook. It exists for useTabDescriptors, which labels every open tab from a single resolver and therefore cannot name one row for the whole of it; it reads the row out of useBoundRowStore for the tab it is labelling. Both spellings render a row's cells through the same helper, so the two cannot disagree.

The environment is the one scope no option names. It is not passed in: the hook reads useSessionStore().activeEnvironmentId itself, so every caller resolves against the environment the user has actually selected and no caller can scope a preview to a different one. This page documented an environmentId option for a while; nothing has ever accepted one. The two scopes are asymmetric on purpose - the active environment is app-wide state, while the collection is a property of whatever the caller is rendering.

ResolvedVariable carries sourceId / sourceName - the specific environment or collection the winning value came from (absent for global).

getVariableOrigins returns every definition of a name, lowest precedence first, including disabled ones that never resolve. Display-only; the variable popover renders it as "also defined". Since issue #1064 it also carries the bound row on top when one answers the name, taking winner from whichever scope it beat. See docs/app/variable-resolution.md for why the losers are kept and why the MCP copy is not given the same accessor.

RequestBuilderContext - variable members

The request builder re-exposes the resolver plus two things only it can derive:

getVariableOrigins: (name: string) => VariableOrigin[]
updateVariable: (name: string, value: string, scope: VariableScope) => void
writableScopes: VariableScope[]

writableScopes lists the scopes updateVariable would actually write to. Each of its branches opens with a guard (if (!activeEnvironmentId) return), so a write to a scope with no active target is a silent no-op - the variable popover uses this list so its "create in" picker cannot offer one.

A write through updateVariable always sets enabled: true. Setting a value means "make this value apply"; enabling and disabling belongs to the variables editor. Without it, creating a value for a name that was disabled everywhere preserved enabled: false and the token stayed unresolved.

Usage:

const { resolveString } = useVariableResolver({ collectionId });
const resolvedUrl = resolveString("https://{{baseUrl}}/api/users");

RequestBuilderContext - body drafts

bodyDrafts: React.MutableRefObject<BodyDrafts>

What the body modes you are not looking at were holding. A request stores one body - the shape is a discriminated union, {"mode":"json","content": "..."} - so JSON, text and GraphQL share request.body, and switching mode handed the same string to a different reader. Switching from JSON to GraphQL therefore read the payload as a raw query string and destroyed it.

Two buckets, not eight: json, text, jsonrpc and xml are one raw string differing only in highlighting, so text carries between them deliberately; graphql is an envelope this side parses into two panes and keeps its own; the two form modes use formData / urlEncoded and never touch body. jsonrpc and xml sit in the raw bucket because nothing here reads their text as a structure - JSON-RPC's frame is completed engine-side at wire time and an XML document is sent byte for byte, so each pane holds one plain document. The rule lives in modules/request-builder/utils/body-drafts.ts.

Two things about it are deliberate and easy to undo by accident:

  • It lives in the provider, not in BodyPanel. Radix unmounts an inactive TabsContent, so a panel-local ref is discarded the moment you glance at the Headers tab, taking the stashed body with it.
  • The provider does not reset it on a request change. The drafts carry their own requestId and switchBody drops any belonging to another request, so a second reset would duplicate that rule - and would fire on the request-change effect, which re-runs on more than the id.

Deliberately not persisted: a request has one body, and storing payloads it will never send would put them in exports and in the engine's schema.

RequestBuilderContext - the GraphQL Variables draft

getVariablesDraft: () => VariablesDraft | null
setVariablesDraft: (draft: VariablesDraft) => void

The Variables pane's raw text, for the same reason and with the same lifetime. The pane is a JSON editor over one key of the GraphQL envelope, and the envelope cannot always hold what it shows: text that is neither JSON nor a resolvable {{template}} is dropped by serializeGraphQLBody, deliberately, so that the query pane keeps saving while the variables pane has an unclosed brace. That makes the pane's own text the only copy - and GraphQLBody's component state the wrong place for it, since the Radix unmount discards a half-typed variables object exactly as it once discarded a stashed JSON body.

It is beside the mode drafts rather than inside them because it is not a mode's body (GraphQL's body is already in the graphql bucket). It carries its own requestId for the drafts' reason, and ownVariablesDraft - not a second reset in the provider - is what drops one belonging to another request.

RequestBuilderContext - the headers a setting added

Two settings each own a header row: the body mode's Content-Type (written by BodyPanel) and the Event stream toggle's Accept: text/event-stream (written by SettingsPanel, issue #574). GraphQL is sent as a JSON envelope and genuinely needs Content-Type: application/json, so BodyPanel appends one - but nothing removed it, so a single visit to GraphQL left the header on the request for good, including after switching back to none, which sends no body at all.

Ownership of the row used to be a context-level record, { requestId, rowId, value }, held in a ref the provider owned. That could not survive a reload: a fresh mount started with an empty ref, so a stale auto-written row was then indistinguishable from one the user typed, and neither withoutContentType (nothing to remove) nor a later mode switch (an "existing" row, so nothing to add) could tell the difference (issue #1481). Ownership now lives on the row itself - KeyValueEntry.source?: HeaderRowSource, whose "body-mode" | "stream" half (AutoHeaderSource) is what an app setting writes; the third value, "legacy-default", is the engine's own header-strip repair pass marking a row it disabled rather than deleted (issue #1491, docs/engine/db-schema.md's "header-strip pass" section) and is never written from this side. There is no context accessor for either app setting any more; switchAutoHeader in modules/request-builder/utils/auto-header.ts reads and writes the marker directly on the headers array it is handed, once per change: it removes the marked row when the new setting does not need that same header, then adds whatever the new setting does need - both halves in one pass over one array, because two updateField("headers", …) calls would compute the second against the array they had before the first. panels/body/content-type.ts is now just the body-mode half: which Content-Type a mode requires, plus the delegation.

Two things it is deliberate about:

  • By marker, not by value. A Content-Type the user typed and the row a setting wrote are identical apart from the marker, and only a marked row may be removed. KeyValueEditor's handleUpdate clears the marker the moment a user retypes a row's key or value - once retyped, it is theirs, whatever the new value happens to be. Merely disabling a marked row does not clear it: the row is not sent while disabled, so there is nothing to have adopted.
  • A row already declaring the header - even a different value - is never overridden. Silently replacing it would be a worse version of the bug this exists to fix. A disabled declaration does not count: it is not sent, so the header the mode needs is still missing.

The setting that used to be the one slot left, AutoMethod, is not a header row at all (issue #1228). A new request is a GET, and GraphQL over GET is a different transport - the document travels as query parameters and a mutation cannot be sent that way - so picking the GraphQL body mode on a request still holding that default used to build one the server answered with a bare 400. The mode now sets POST the same reversible way it sets the Content-Type header above, and leaving the mode puts the method back. It has the same reload gap issue #1481 fixed for the two header rows, closed the same way by issue #1505: ownership lives on RequestState.methodSource?: MethodSource ("graphql", the domain type mirroring HeaderRowSource), a value on the request itself rather than a ref-held record, so it is exactly as durable as request.method beside it. switchGraphQLMethod in panels/body/graphql-method.ts is the rule that reads and writes it, called from BodyPanel.handleModeChange alongside switchAutoHeader, and it keeps the same two guarantees stated as bullets above, read against a scalar field instead of a row: a method the user has since chosen - methodSource no longer "graphql" - is no longer this side effect's to revert, and MethodSelector's onValueChange clears the marker in the same call that sets the hand-picked method, the exact moment KeyValueEditor's handleUpdate clears a header row's source on a retype. A GET still reaches GraphQL through the door this side effect does not touch - the user picks GET back, or an import wrote one - which is when the Query pane header's BadgeText names the transport that will be used.

Which identity a builder files under is one value, resolved once (issue

1272): memoryKey ?? request.id ?? UNSAVED_AUTO_KEY, read by every

per-builder map the provider still holds - now only the Send-with-row picker's row memory below, since the three reversible-setting records above all moved onto the request's own state. A request tab is its request and passes nothing. The editable copy History renders for a stored run passes the run id, because design-run-seed.ts gives that copy id: null on purpose - a null id is one of the two gates that stop an edited copy from rewriting the saved request - so every open run copy used to share the id-less bucket before issue #1272. That was not a near miss: the rules read a requestId of null against another null as a match, so the second run tab's picked row was handed the first one's record and read as its own. A prop rather than a field on RequestState that only History would set: request.id stays honestly null, and no request grows a "which run am I" field for one caller. A run id names a tab, so that memory is bounded by the same sweep rather than exempt from it.

UNSAVED_AUTO_KEY is what remains for a builder declaring neither - nothing in the app today - and it is the one key the sweep cannot drop, since a key naming no tab cannot be bounded by the tabs. It is kept rather than removed because the alternative for such a builder is sharing whichever key was written last, which the null === null ownership check hands out as owned rather than refusing.

The open tabs bound the Send-with-row picker's memory (issue #1271), the provider's one remaining per-request map: which row index each request was last sent with (rowIndexByRequest, issues #659 and #1062). It survives a tab switch and a return, because neither of those is a send - and it goes when the tab does, since the request it was picked for can no longer be switched to. tabs-store is subscribed to rather than selected from, because a provider that re-rendered on every tab focus would charge the request the user is working in for it. MAX_OPEN_TABS caps the tab strip, so it caps the map, and both request tabs and run tabs are swept, by their entityId - a request id and a run id respectively, from different tables and so unable to collide. A run tab holding no builder at all (a load test, a scenario) only ever keeps a key nothing wrote.

The row memory is useState rather than a ref - lastRowIndex is read while rendering - so its half of the sweep is a setState, and retainKeys (context/retain-keys.ts) returns the map it was handed when every key is still live. Without that identity guard the provider would re-render on every tab focus, which is exactly the cost the subscription was chosen to avoid.

The two header markers and methodSource need none of this: what is persisted is the row itself, in request.headers, or the method's own marker, in request.methodSource - no provider-side map, no sweep, nothing a reload can lose that a save does not already carry.

useSaveManager() - Auto-Save Manager

Orchestrates auto-save for a saveable entity (request, environment, etc.) with debouncing, context registration, and centralized save state tracking. Located in app/src/hooks/useSaveManager.ts.

API:

const {
  forceSave: () => Promise<void>
  status: "idle" | "pending" | "saving" | "saved" | "error"
  isSaving: boolean
} = useSaveManager({
  entityId: string | null           // Unique ID for this entity
  contextName?: string              // Display name (e.g., "Request: GET /api")
  onSave: () => Promise<void>       // Function to persist changes
  hasChanges: boolean               // Whether unsaved changes exist
  changeToken: number               // Bumped by the caller on every edit
  enabled?: boolean                 // Disable auto-save (default: true)
});

Features: - Debounced auto-save: Triggers after the delay the user chose in Settings → General, defaulting to 5000ms (autoSave.delayMs in client-settings-store, options in constants/client-settings.ts) - Context registration: Automatically registers with useSaveStore() for app-wide Ctrl/Cmd+S integration and tab LRU coordination - Save status: Updates centralized save store so UI can show "Saving..." or "Saved" indicators - Entity switching: Flushes pending saves when entity ID changes (in cleanup, before unmounting) - Saves are queued, never skipped: a caller arriving while another save is in flight waits behind it and gets its own write, so its promise resolves only once its edits are persisted. Skipping (the old behaviour) meant Cmd/Ctrl+S, the quit flush and the entity-switch flush all reported "saved" for edits that were never in the flying snapshot - and the switch then reset the provider, making them unrecoverable. performSave binds onSaveRef.current at call time rather than when the queue reaches it, so a save queued by the entity-switch cleanup still writes the entity being left - No debounceMs parameter: the delay is a user preference, not a per-caller one, so the hook reads it from the store rather than taking it as an option. Turning auto-save off in Settings leaves the entity marked dirty - Ctrl/Cmd+S still saves it - but schedules nothing. - The delay runs from the last edit, and changeToken is what makes it a debounce (#1381). hasChanges cannot carry that on its own: it is already true by the second keystroke, so an effect keyed on it does not re-run and the timer armed by the first keystroke runs to term. A save then went out five seconds into every burst carrying a half-typed script. The token changes on every edit, so each one tears the pending timer down and arms a fresh one - A returning save reports "Saved" only for the edits it carried (#1381). The saver and the token are bound together when performSave is called; if the token has moved by the time the write lands, an edit arrived that the payload does not hold, and the status goes back to pending rather than flashing "Saved" over it. The caller is what re-arms the save - this only refuses to mislabel it

app/src/config/timing.ts used to carry an AUTO_SAVE_DELAY_MS: 3000 that nothing read and that this section documented as the source of truth. It is deleted; timing-keys-have-readers.test.ts now fails on any TIMING key without a reader, and useSaveManager.autosave-setting.test.tsx pins the Settings value to the timer that actually runs.

Usage:

const { forceSave, status, isSaving } = useSaveManager({
  entityId: requestId,
  contextName: `Request: ${request.method} ${request.url}`,
  onSave: () => apiService.updateRequest(requestId, changes),
  hasChanges: JSON.stringify(draft) !== JSON.stringify(saved),
  enabled: true
});

useEntityDraft() - Manual Draft/Save Model

The other save model, and the counterpart to useSaveManager(): an editable draft, a Save button gated on isDirty, and a Reset that discards it. Located in app/src/hooks/useEntityDraft.ts. CollectionDetail's AuthTab and ElementsTab (issue #1512) are its button users.

InfoTab uses the hook without the button: it wants the draft, the resync and the mutation reset, but commits when focus leaves the field, like the request builder. reset therefore has fewer callers than draft has.

Why auth - and now Elements - keep the button (#446, #1512). Not because a credential or an element list outranks a plain field - the request builder autosaves its own auth through useSaveManager, and used to autosave its scripts the same way InfoTab still does. Because a blur is not a completion signal once a tab is more than one focus stop: an OAuth 2.0 config with Advanced open renders 20 focus stops, 9 of them non-value controls, and clicking reveal to check a half-typed password fires focusout while the draft is dirty - so a blur-commit would write half a credential to the record every descendant request inherits from. The old ScriptTab had exactly one focus stop (its Monaco editor), so leaving it meant what leaving a description means - but ElementsTab's ElementList is adding a kind, naming a row, toggling it, reordering it and editing its form, each a separate stop, and a script.* element's own editor inside the list would fire the identical half-typed-value blur. The fields only make sense saved together, which is what the button means on both tabs now. Should collection auth or elements ever persist by themselves, the mechanism is the debounce, not a blur - a different change from this one. Both tabs state their save model above the fields, since they are the ones on that screen that differ.

API:

const {
  draft: T                                 // The editable copy
  setDraft: Dispatch<SetStateAction<T>>    // Standard setState signature
  isDirty: boolean                         // Draft differs from the persisted value
  reset: () => void                        // Discard the draft, adopting the persisted value
  baseline: T                              // The value the draft last agreed with the server on
  externalValue: T | null                  // A persisted change pending while the draft is dirty
} = useEntityDraft<T>({
  entityKey: string                        // Identity of the thing being edited
  value: T                                 // The persisted value
  mutation: { reset: () => void }          // The save mutation this editor reports through
});

Behaviour: - Seeds and resyncs while clean: a clean draft follows value when it changes - a save landing, a background refetch. In InfoTab this is what clears the post-trim divergence, since the tab persists name.trim(). The request builder needs the same property for the same reason and gets it a different way, since its state is not a draft: see the draft adopts an external write per field. - A dirty draft is never silently overwritten (#1437). While the draft disagrees with baseline, an incoming change to value is held in externalValue instead of reseeding draft - an MCP update_collection landing mid-edit used to replace the user's unsaved text with whatever the agent wrote. ElementsTab and AuthTab treat externalValue as one value and show a "Changed elsewhere" Callout (CollectionDetail/shared.tsx's ExternalChangeCallout) whose action calls reset() to take it. InfoTab's draft has two independent fields, so it diffs draft and externalValue against baseline per key instead: a key the user has not touched adopts the external value immediately, the same as a clean tab; a key both sides touched surfaces its own conflict, named, and is left at the user's edit until they choose. - Tracks by JSON value, not identity: value may be a fresh object literal every render (InfoTab builds { name, description } inline); callers do not have to memoize it. - entityKey is a switch, not an edit: a change reseeds the draft and calls mutation.reset(), discarding any pending externalValue too. These editors render without a React key, so a different entity arrives via props on the same instance, and a TanStack mutation holds isError until the next mutate - without the reset, a failed save is reported against an entity the user never tried to save. ElementsTab passes ${collection.id}:elements, one entity key for the whole element list under one collection id (the retired ScriptTab passed ${collection.id}:${fieldKey}, since pre- and post-request scripts were two separate things to edit). - Requiring the mutation is the point: the three hand-rolled copies this replaced had drifted, and the one that omitted the reset had exactly that bug.

Usage:

const { draft, setDraft, isDirty, reset } = useEntityDraft({
  entityKey: collection.id,
  value: collection.auth,
  mutation: updateCollection,
});

The draft lives in component state, so its panel must not unmount. Radix unmounts an inactive TabsContent; CollectionDetail therefore force-mounts the four draft-holding tabs (Info, Auth, Pre-request, Post-request) from their first visit onwards, so an intra-collection tab switch stops discarding the draft. This is the same call, for the same reason, as the request builder's body drafts living in RequestBuilderProvider rather than in BodyPanel (see request-builder/utils/body-drafts.ts). The Variables tab is deliberately not force-mounted: it autosaves and claims the store's active context on mount, so keeping it alive behind another tab would point Ctrl/Cmd+S at the wrong editor.

A collection switch still reseeds and discards, deliberately - it is the hook's documented behaviour above, and pinned by useEntityDraft.test.ts.

useDraftSaveContext() - Registering a Manual Draft

The counterpart to useSaveManager's registration half, for editors using the useEntityDraft model. Located in app/src/hooks/useDraftSaveContext.ts. Used by InfoTab, AuthTab and ElementsTab.

useDraftSaveContext({
  id: `collection-${collection.id}-auth`,  // Unique per editor
  name: `Collection auth: ${collection.name}`,
  isDirty,                                 // From useEntityDraft
  isActive: active,                        // Is this the tab on screen?
  save: persist,                           // Rejecting is how failure is reported
});

Behaviour: - Registration only. It schedules nothing; each editor decides when to call its own save - AuthTab and ElementsTab on their Save button, InfoTab when focus leaves the field. The defect it fixes is orthogonal to that choice: the other ways to save - Ctrl/Cmd+S, the quit flush, tab eviction - could not reach these editors at all, because none of the three tabs ever called registerContext. - isActive decides who owns Ctrl/Cmd+S. triggerSave prefers the active context, and these editors stay mounted while hidden, so without it the last sibling to mount would answer for the panel on screen. - A failure toasts rather than resolving quietly. The editors render an inline SaveFailed callout for a button press, but a quit flush has no callout on screen, and runSave reads a resolved promise as success - swallowing here would report "Saved" for a write that failed. - The save must carry its own validity guard. A disabled button does not stop the store-driven paths, which is why InfoTab refuses a blank collection name inside persist. With its buttons gone that guard is now the only one, and it is silent by itself - the tab pairs it with reportBlankNameRefused() (lib/blank-name.ts), which restores the stored name and reports through failSave. - isDirty also drives the Dock's "Unsaved changes" line (#1483). The registry's own hasPendingChanges is not what the Dock reads - only the store-wide status is - so the hook calls markPendingSave() the moment isDirty turns true and completeSaveThenIdle(id) the moment it turns false again, mirroring useSaveManager's autosave editors. Edge-triggered, and reset rather than fired across an id change, so switching the entity a mounted editor describes never reports the previous entity's save as this one's.

The request builder's draft adopts an external write per field

RequestBuilderProvider resets its whole draft when the request id changes, and neither a rename, an MCP agent's update_request, nor a move to another collection changes the id. Before issue #1436, only name adopted a change that arrived this way - every other field held a snapshot taken when the tab opened, so a debounced auto-save could write that snapshot back over whatever an agent had just changed, a delete under a dirty tab flushed a doomed PUT into a 404, and a move discarded the whole draft along with the id-based reset it was routed through.

The fix generalizes the name's adoption into a per-field three-way merge, the same shape useEntityDraft (below) already uses for the collection tabs:

  • baseline is the value the draft last agreed with the server on - not necessarily request, which is whatever the user has typed, and not necessarily what a stray refetch just reported either, if that report conflicts with an edit (see below).
  • touchedFields is which top-level RequestState keys setRequest / updateField have written since that baseline. A successful save clears exactly the fields it sent; takeExternalField (below) clears one.
  • On every initialRequest change (the request query's copy, gated on its object identity so the diff runs once per refetch and not once per keystroke), mergeExternalWrite (app/src/lib/field-merge.ts) diffs initialRequest against baseline over every mergeable field (MergeableRequestField - every RequestState key except id, collectionId and disabledDefaultHeaders, which have their own rules below):
  • a field the fetch did not change is left alone;
  • a field the fetch changed and the user has not touched adopts the new value into the draft, via setRequestState (never setRequest - adopting someone else's write is not an edit of ours, and marking it dirty would schedule a save that writes back what was just read) and baseline moves to match;
  • a field the fetch changed and the user has touched, to a different value than the fetch reports, is a conflict: the draft keeps the user's value, baseline is deliberately left where it was (so the conflict keeps being detected until it is resolved), and the incoming value is exposed on fieldConflicts[field];
  • a touched field the fetch now agrees with (the save's own echo, or a coincidence) resolves quietly - baseline moves, nothing is shown. mergeExternalWrite compares by JSON content, not by reference: headers, params and auth are rebuilt into fresh arrays/objects on every fetch regardless of content, and === there would read every refetch as an external change - the same reason useEntityDraft compares by JSON.stringify.
  • fieldConflicts and takeExternalField(field) are on RequestBuilderContextValue. ExternalChangeNotice (request-builder/components/) renders one ExternalChangeCallout per logical group - the four fields the editor splits a request's body across (bodyMode/body/formData/urlEncoded) count as one "body" group, so a body conflict does not paint four callouts - and its "Take theirs" resolves every field in the group.
  • collectionId is not a mergeable field: a collectionId-only change with the same id (the move_item case) updates just that field in place, leaving the rest of a dirty draft untouched, in a dedicated branch beside the id-keyed full reset.
  • A request deleted elsewhere stops index.tsx handing the provider an initialRequest at all, while keeping RequestBuilderProvider mounted (see below) so the draft is not lost. The provider notices initialRequest went missing while request.id is still set and flips requestGone, which feeds useSaveManager's enabled - so a dirty draft's autosave timer, already armed or about to be, never fires a PUT against an id that now 404s.
  • The generation handleSave checks (changeTokenRef, see the save-manager walkthrough below) bumps on a foreign write too, not only on the user's own edits - an effect keyed on baseline's identity, not a direct write inside the merge itself: a ref write during the render phase is unsafe under React's rules (a render can be discarded or replayed) and is what this repo's react-hooks/refs lint rejects. Without the bump, a save already in flight when the merge adopts or conflicts a field could still clear hasUnsavedChanges over a value the merge just changed underneath its response.
  • restoreStoredName() is unchanged in spirit but now reads baseline.name rather than a name-only ref: the Info tab's blank-name refusal needs the stored name back, and the baseline already holds it for every field, not just this one.

The save payload only ever carries what touchedFields names (buildUpdatePayload in request-builder/index.tsx): onSave receives (request, changedFields), and an untouched field is omitted from the PUT /requests/:id body entirely - which the engine's merge-patch already reads as "leave the stored value alone" - rather than being resent unchanged. The four body fields collapse into the wire's body/bodyType pair if any one of them was touched. name keeps its own rule inside that: touched-but-blank still omits the key (a blank name is refused, not saved), for the reason the old comment gave - the debounced auto-save can fire while the field is briefly empty.

useUpdateRequestMutation's cache write also learned to lose a race (queries/collections.ts): a foreign write's own refetch can land in the detail cache while this client's save is still in flight, and writing the save's response into the cache unconditionally could then overwrite the newer row with a stale one purely because the older write's round trip finished second. The onSuccess handler now compares updatedAt (a toISOString() string end to end, so lexical and chronological order agree) and keeps whichever row is actually newer.

Guarded by RequestBuilderProvider.name-sync.test.tsx (the name-only case that started this), RequestBuilderProvider.external-write-merge.test.tsx (the general per-field merge, the move, the delete, and the generation bump), field-merge.test.ts (mergeExternalWrite itself), save-request-name.test.ts and request-builder.script-clearing.test.tsx (the partial payload), and collections.update-request-race.test.ts (the cache race).

State Flow Examples

Executing a Single Request

  1. User clicks "Send" button in request builder
  2. useEngine().executeRequest() is called with request and (optionally) environment ID
  3. useVariableResolver() resolves any {{variables}} in the request URL, headers, body
  4. Request is transformed (frontend → backend format) and sent via HTTP
  5. Response is stored in useResponseStore() keyed by request ID
  6. Response viewer component reads the response and displays it
  7. On request tab switch, the response persists in response-store and is displayed if the user returns - for the 24 most recently sent requests, past which the store's LRU bound has dropped the entry and the backend's stored run answers instead
  8. If any script ran, the environment / globals / collection query families are invalidated so values the script wrote are visible in the variables editor and the resolver. The gate is scriptsMayWriteVariables(pre, post) (request-builder/utils/execute-mapping.ts), shared by the builder's send path and the History run view's resend. Both script kinds count: pm.environment.set and friends persist engine-side from a Tests-tab script exactly as from a pre-request one, and with refetchOnWindowFocus: false nothing else is coming to correct a stale value

Starting a Load Test Run

  1. User configures load test in the dashboard modal (duration, concurrency, etc.)
  2. The request half is composed engine-side (useEngine().composeRequest()), then apiService.startLoadTest() sends the composed payload plus the load config to POST /runs
  3. Engine responds with runId
  4. useDashboardStore().startRun(runId, config, requestInfo, requestId) initializes dashboard state
  5. loadTestService.startMonitoring(runId) connects to /runs/:runId/live
  6. As metrics stream in, addMetricsBatch() folds them into historical metrics (trimmed to liveWindowSeconds, backstopped by maxRetainedTicks) and updates running aggregates (peak concurrency, SLO breakpoint)
  7. Dashboard view shows live metrics, request/response (from the SSE stream's final response), and aggregates
  8. When the run completes, the engine sends a complete event
  9. LoadTestService.handleClose() fetches the final report through the query cache (under queryKeys.runs.report(runId), so History reuses it) and stores it in dashboard-store.finalReport - only if the dashboard is still showing that run. The store is re-read after the await: finishing run A and immediately starting run B otherwise landed A's report on B's dashboard, flipping a running test to "completed" with A's percentiles.
  10. Dashboard switches to "completed" mode showing the final report; the runs lists are invalidated so the terminal status lands without waiting for a poll

Saving a Request with Auto-Save

  1. User opens or creates a request tab via useTabsStore().openTab()
  2. Component mounts useSaveManager() with the request ID and save callback
  3. Hook registers the context with useSaveStore() for Ctrl/Cmd+S integration
  4. User edits the request (URL, headers, body, etc.)
  5. hasChanges is marked true and changeToken is bumped, arming the debounce timer - autoSave.delayMs from client-settings-store, 5s by default (Settings → General offers 5s / 30s / 1m)
  6. A further change within that window bumps the token again, which tears the pending timer down and arms a new one, so the delay measures from the last edit
  7. After the delay elapses with no edit, performSave() is called, which calls the onSave callback
  8. Save status updates in useSaveStore(), and the Dock shows "Saving..." then "Saved" for TIMING.SAVED_STATUS_DURATION_MS
  9. A save is only allowed to call the entity clean for the generation it sent (#1381). onSave(request, changedFields) serialises the state - and the set of touched fields - as they were when the save started, so a keystroke landing during the round trip is not in that payload. The generation also moves on a foreign write the per-field merge adopts or conflicts, not only on the user's own edits (#1436): a save in flight when that happens must not clear hasUnsavedChanges over a value the merge just changed underneath its response. The provider records the generation beside the snapshot and clears hasUnsavedChanges - and the fields the snapshot named - only if the token has not moved when the write lands; the hook applies the same check to the "Saved" indicator. Clearing unconditionally marked that keystroke saved, left nothing dirty for the next timer to fire on, and lost the edit
  10. The request builder's onSave sends only changedFields (#1436, see the draft adopts an external write per field above) - an untouched field is omitted from the PUT rather than resent unchanged
  11. On tab switch or unmount, any pending save is flushed before the context is unregistered - unless the entity is confirmed gone (useSaveManager's enabled: false), which the request builder sets once its request is deleted elsewhere, so the flush never fires a doomed PUT. If that flush fails, the context is re-registered under the same id rather than left unreachable - flushAll's reconnect trigger (below) and a later quit both walk the registry, not the pane that just unmounted (#1489)
  12. On app quit (Electron before-quit) and on window close (the X button), useSaveStore().flushAll() saves all dirty contexts and resolves to { saved, failed, pending } - what actually landed, not just that the round trip finished

Both window-destroying paths flush, through one coordinator. before-quit always did; close did not, and close is what the X button fires - it destroys the WebContents and nulls the window handle, so the before-quit that followed found no renderer to ask and skipped the flush entirely (on macOS, close does not quit at all, so the edits were simply gone with the app still running). electron/save-flush.ts owns the once-only flush and the 2s ACK ceiling for both, which is also why "already flushed" is shared state: a quit that flushed and then closes the window must not ask a dying renderer twice. It lives outside main.ts so it can be tested - main.ts creates windows and starts the engine at import time.

A running service is asked about before either path flushes (#1363). A close or quit that would stop an inbox, mock server or mock issuer names it and waits for an answer first, and Cancel leaves the window, the renderer and every pending save exactly as they were - a flush run ahead of the question would have told the renderer its work was ending. Confirm, and the flush runs as it always did on the way out.

The flush settling is not the same as the flush landing (#1489). Before, both paths closed the window the moment the round trip finished - the ACK, or the 2s ceiling if the renderer never answered - whether or not anything had actually been saved: a refused write, a hung engine, or a save still running when the ceiling gave up all looked identical to a clean quit. The renderer's flushAll now carries its outcome back over the before-quit-flushed ACK (FlushResult, electron/save-flush.ts), and a settle with no ACK at all (the ceiling won) is treated the same as a failure - nothing is known to have landed. Either case holds the close behind one native dialog, confirmDiscardOnFailedFlush: "N edits could not be saved - the engine is not responding", Quit/Close anyway or Keep working. A clean flush still closes with no dialog, exactly as before. useTabsStore.closeTab asks the same question one tab early: closing a single dirty tab while the engine is not connected keeps the tab and toasts instead of letting the unmount flush fail into a pane that no longer exists.

Variable Resolution Priority

  1. User activates a request in a tab. The environment comes from useSessionStore().activeEnvironmentId; the collection comes from the request's own collectionId - not from the session store, which has held no collection scope since the vayu.session v2 migration
  2. Component calls useVariableResolver({ collectionId }) - the environment is not passed, the hook reads it from the session store
  3. Hook fetches globals, collection variables, and environment variables via TanStack Query
  4. When resolveString("https://{{baseUrl}}/{{path}}") is called:
  5. First, check environment variables for baseUrl and path
  6. If not found, check collection variables
  7. If still not found, check global variables
  8. Replace with the first match found, or leave {{variableName}} unreplaced if no match

Best Practices

  1. Cross-cutting vs. module-local: Store cross-cutting UI state (tabs, layout, engine, save) in app/src/stores/; store feature-specific UI state (collections tree, history filters, variables category, settings category) in app/src/modules/<feature>/<feature>-store.ts.

  2. Zustand for transient UI state: Use Zustand for UI state that doesn't persist to disk (or is ephemeral per session). Decorate with persist middleware to survive page reloads if needed (e.g., open tabs, drawer state).

  3. TanStack Query for server state: Use TanStack Query for collections, requests, environments, globals, runs, and reports. It is the single source of truth and ensures consistency across the app.

  4. Save manager integration: Use useSaveManager() in any component that edits a persistable entity (request, environment, etc.) that autosaves. It handles debouncing, context registration, and centralized save state. Do not manually call useSaveStore() for auto-save. For an editor that holds a draft instead - committing it on blur or on an explicit button - use useEntityDraft() plus useDraftSaveContext() - the first owns the draft, the second puts it in the registry - and do not hand-roll the draft/resync/isDirty/mutation-reset parts again.

  5. Centralized save on app quit: On Electron's before-quit event, call useSaveStore().flushAll() to persist any pending changes before the app closes. An editor that is not registered is not merely unsaved here, it is invisible - which is how the collection tabs lost drafts silently for as long as they existed. flushAll reports what happened ({ saved, failed, pending }), and main.ts asks before discarding anything it did not save (#1489) rather than closing on faith.

    Corollary: an editing surface must never fail without saying so. There is no global MutationCache.onError in lib/query-client.ts, so a bare mutation.mutate(...) reports nothing at all. Route the failure through failSave (toast + status) or render the mutation's isError, and roll an uncontrolled input back to the stored value while you are at it - the context bar's variables section did neither, so a rejected edit sat on screen looking committed.

  6. Leaving an editor saves or asks; it does not drop. A settings category switch, an unmount, a tab switch - each used to discard dirty state silently in at least one place. Settings flushes its valid edits on the way out (engine config writes are cheap merge-patches); the collection tabs keep their panels mounted so there is nothing to discard.

  7. Tab LRU and dirty state: The tab store reads the save registry and refuses to evict a dirty tab (isTabDirty, matched by tab type - the registry is keyed by editor and the two do not line up). Nothing is flushed during eviction, because the predicate has already declined to take unsaved work; over the cap with every candidate dirty, no tab closes at all. An explicit close (closeTab) is not eviction and used to have no such guard; it now applies the same isTabDirty check but only refuses when the engine is not connected (#1489) - a dirty tab closes and flushes as usual once the engine can actually take the write.

  8. Response persistence: Responses are stored in memory (not localStorage) so they survive tab switches but are cleared on page reload. This balances UX (quick switch back) with memory (responses can be large).

  9. Live metric retention is a time window, not a point count. addMetricsBatch trims ticks older than liveWindowSeconds (the engine's liveReplayWindowMs, 5m by default, null = full run), with maxRetainedTicks (DEFAULT_MAX_RETAINED_TICKS, 50,000) as a memory backstop. Both are engine config so the replayed span and the displayed span cannot disagree. The only knob in config/metrics.ts is METRICS_UI_THROTTLE_MS, the SSE commit throttle; chart cost is bounded by bucketing (chartBucketSeconds), not by dropping ticks.

  10. Variable resolution priority: Always resolve variables in priority order: environment > collection > global. Use useVariableResolver({ collectionId }) to scope a preview to a collection; the active environment comes from the session store and is not a parameter.

  11. Lazy loading and prefetch: Use usePrefetchCollectionsAndRequests() and usePrefetchRuns() on app init to warm up caches. Lazily fetch environments, globals, and run reports only when needed to reduce initial bundle size and API load. Warm a polled list with a prefetch, never by mounting its hook at the root - an observer that lives for the session polls for the session (#1150).