Skip to content

refactor(web): replace prop-drilled daemon config with TanStack Query cache architecture; fix concurrent optimistic rollback#33094

Merged
vex-assistant-bot[bot] merged 7 commits into
mainfrom
devin/1780430583-fix-config-patch-spec
Jun 2, 2026
Merged

refactor(web): replace prop-drilled daemon config with TanStack Query cache architecture; fix concurrent optimistic rollback#33094
vex-assistant-bot[bot] merged 7 commits into
mainfrom
devin/1780430583-fix-config-patch-spec

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Prompt / plan

Redesign the daemon config mutation pattern in Settings/AI to follow STATE_MANAGEMENT.md principles. The TanStack Query cache becomes the single source of truth for profiles, profileOrder, activeProfile, and callSites — eliminating prop-drilling, imperative callbacks, and scattered direct API calls.

Also includes the prerequisite spec fix: PATCH /v1/config lacked requestBody/responseBody definitions, causing the generated SDK to emit body?: never.

Why needed

The previous architecture copied server state into useState, then
prop-drilled it into modals with imperative onProfilesChanged callbacks
to sync mutations back to the parent. This created a parallel source of
truth that could drift from the query cache, and made concurrent mutations
unsafe (a failed toggle rollback would restore a full snapshot, silently
reverting unrelated edits).

What was wrong

LanguageModelCard
  ├── 4x useState copies of server state (profiles, profileOrder, activeProfile, savedActiveProfile)
  ├── initialized ref guard + one-shot useEffect to hydrate from config
  ├── reconcileFromDaemonConfig() to destructure config → useState
  └── passes profiles/profileOrder/activeProfile as props to modals
        ├── ManageProfilesModal
        │     ├── 8x direct configPatch() calls with own try/catch
        │     ├── onProfilesChanged callback to imperatively update parent
        │     └── 6+ manual useState for error tracking
        └── CallSiteOverridesModal
              ├── 2x direct configPatch() calls
              ├── onSaved callback to tell parent "I changed something"
              └── persistedOverrides/orderedProfiles prop drilling

What the correct architecture is

useDaemonConfig()          → typed memoized slices from query cache
useDaemonConfigMutation()  → shared useMutation with onSettled invalidation

LanguageModelCard          → reads cache directly, no useState copies
ManageProfilesModal        → reads cache directly, optimistic toggle/reorder
CallSiteOverridesModal     → reads cache directly, mutations via shared hook

All consumers read from the same query cache. Mutations invalidate on settle → all consumers re-render. No callbacks, no prop drilling of server state.

Changes

use-daemon-config.ts — expose profiles, profileOrder, activeProfile, callSites as memoized selectors; add useDaemonConfigMutation() wrapping configPatch in useMutation with onSettled invalidation

language-model-card.tsx — remove 4x useState, initialized ref, reconcileFromDaemonConfig for profiles; read from cache; keep draftActiveProfile as ephemeral UI state (unsaved dropdown ≠ server state)

manage-profiles-modal.tsx — call useDaemonConfig() directly; remove onProfilesChanged callback; implement optimistic toggle/reorder via cancelQueries + setQueryData + targeted field rollback on error

call-site-overrides-modal.tsx — call useDaemonConfig() directly; remove onSaved/persistedOverrides/orderedProfiles props; save/reset via useDaemonConfigMutation()

ai-types.ts / ai-utils.ts — remove profile fields from DaemonConfigReconciliation (no longer needed; web-search/image-gen service modes remain)

conversation-query-routes.ts — add requestBody (z.record) and responseBody ({ok: boolean}) to config_patch route

STATE_MANAGEMENT.md — update "Via the Cache" example: full-snapshot rollback → targeted field rollback with updater functions, document why cancelQueries is required before every optimistic setQueryData

Concurrent-safe optimistic updates

Toggle and reorder use cancelQueries before optimistic writes to prevent
stale refetches from overwriting the optimistic cache. Rollback patches
only the specific field that failed (e.g. a profile's status), not the
full config snapshot — this preserves concurrent mutations' successful
updates. This follows TkDodo's concurrent optimistic updates guidance.

Alternatives NOT taken

Separate useMutation per operation (toggle, reorder, delete) — would
give cleaner onMutate/onError lifecycle, but onMutate can't be
passed per-call to mutateAsync (TanStack Query v5), and the per-item
tracking (togglingNames, deleting) doesn't map to a single mutation's
isPending. The shared useDaemonConfigMutation + manual try/catch is
the pragmatic choice given the shared mutation serves 4+ distinct
operation types.

"Via the UI" optimistic pattern — considered for toggle since
mutation/query live in the same component, but rejected because
LanguageModelCard (a sibling component, mounted behind the modal) also
reads profiles from the cache and needs to see status changes immediately.

What stays as-is (by design)

  • Modal open/close state → ephemeral UI → useState is correct
  • Draft active profile dropdown → unsaved UI selection → useState is correct
  • Draft call-site overrides → "edit many, save all" pattern → local draft state is correct
  • reconcileFromDaemonConfig for web-search/image-gen modes → still used by those cards

Root cause analysis

How did the code get into this state? The original ai-page.tsx god
component managed all daemon config in useState at the top level.
When modals were added, the natural path was prop-drilling the state
down and using callbacks to propagate changes back up. This worked
for a single component tree but created a parallel source of truth
alongside the TanStack Query cache.

What led to the concurrent mutation bug? The optimistic toggle
stored a full config snapshot in onMutate context and restored it
in onError. This pattern comes from the TanStack Query docs' basic
example, which doesn't account for concurrent mutations. Our
STATE_MANAGEMENT.md example had the same gap (now fixed in this PR).

Prevention: Updated STATE_MANAGEMENT.md "Via the Cache" example to
document targeted field rollback + cancelQueries as the standard
pattern, with references to TkDodo's concurrent optimistic updates guide.

References

Test plan

  • bunx tsc --noEmit — passes (0 errors)
  • bun run lint — passes (0 errors in touched files)
  • bun test src/domains/settings/ai/ — 8/8 pass
  • Pre-commit + pre-push hooks pass

Link to Devin session: https://app.devin.ai/sessions/b87fe17fe84348b89321863e56a947e4
Requested by: @ashleeradka

…eplace raw client.patch calls with generated configPatch

The PATCH /v1/config endpoint lacked requestBody and responseBody
definitions in the route metadata, causing the generated SDK to emit
body?: never for configPatch. This forced 11 call sites to use the raw
HeyAPI client.

Changes:
- Add requestBody (z.record) and responseBody ({ok: boolean}) to the
  config_patch route in conversation-query-routes.ts
- Regenerate openapi.yaml via generate:openapi
- Replace all 11 raw client.patch calls across use-daemon-config.ts,
  manage-profiles-modal.tsx, and call-site-overrides-modal.tsx with the
  generated configPatch function
- Remove unused client import from manage-profiles-modal.tsx

Part of LUM-2072

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@linear

linear Bot commented Jun 2, 2026

Copy link
Copy Markdown

LUM-2072

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

ashleeradka and others added 2 commits June 2, 2026 20:21
… cache architecture

Redesign the daemon config mutation pattern in Settings/AI to follow
STATE_MANAGEMENT.md. The TanStack Query cache is now the single source
of truth for profiles, profileOrder, activeProfile, and callSites.

Changes:
- useDaemonConfig() exposes typed memoized slices derived from the
  query cache instead of components copying config into useState
- useDaemonConfigMutation() centralizes all config patches in a shared
  useMutation with onSettled cache invalidation
- LanguageModelCard reads directly from cache; removes 4 useState copies,
  initialized ref guard, and reconcileFromDaemonConfig usage for profiles
- ManageProfilesModal calls useDaemonConfig() directly; removes
  onProfilesChanged callback, implements optimistic toggle/reorder via
  queryClient.setQueryData with rollback on error
- CallSiteOverridesModal calls useDaemonConfig() directly; removes
  onSaved callback and persistedOverrides/orderedProfiles prop drilling
- DaemonConfigReconciliation no longer carries profile fields (only
  used by web-search and image-gen cards for their service modes)
- Dead code removed: unused captureError import, stale assistantId deps

Ephemeral UI state (modal open/close, draft dropdown selections, draft
call-site overrides) correctly remains in useState per STATE_MANAGEMENT.md.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration devin-ai-integration Bot changed the title fix(daemon,web): add requestBody/responseBody to config_patch, replace raw client.patch with generated SDK refactor(web): replace prop-drilled daemon config with TanStack Query cache architecture Jun 2, 2026
@ashleeradka

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8de1dfc790

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/domains/settings/ai/manage-profiles-modal.tsx
The Zustand store hook was called inside a useMemo callback, violating
the Rules of Hooks. Hoist the hook call to the component top level and
add it to the dependency array so the profile list recomputes when the
flag hydrates.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@ashleeradka

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b5206968df

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/domains/settings/ai/manage-profiles-modal.tsx Outdated
ashleeradka and others added 3 commits June 2, 2026 20:52
Restoring the full previousConfig snapshot on toggle failure could
overwrite concurrent cache updates from other mutations. Instead,
capture only the previous status value and use a setQueryData updater
to patch just that field on top of the latest cache state.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Per TanStack Query docs and TkDodo's concurrent optimistic updates
guide, call cancelQueries before setQueryData to prevent a stale
refetch (triggered by another mutation's onSettled invalidation)
from overwriting the optimistic update. Applied to both toggle and
reorder handlers.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ttern

The previous example restored the full query snapshot in onError,
which silently overwrites concurrent mutations' optimistic updates.
Updated to: (1) capture only the changed field, (2) use an updater
function in onError to patch just that field back, (3) document
why cancelQueries is required before every optimistic setQueryData.

References:
- https://tanstack.com/query/v5/docs/framework/react/guides/optimistic-updates
- https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration devin-ai-integration Bot changed the title refactor(web): replace prop-drilled daemon config with TanStack Query cache architecture refactor(web): replace prop-drilled daemon config with TanStack Query cache architecture; fix concurrent optimistic rollback Jun 2, 2026
@ashleeradka

Copy link
Copy Markdown
Contributor

@codex review

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Value: Closes the "useState-mirrors-server-state" pattern in the AI/Settings domain end-to-end. Query cache becomes the single source of truth for profiles / profileOrder / activeProfile / callSites; modals read from the same cache, mutations invalidate on settle, no prop drilling, no imperative onProfilesChanged callbacks, no parallel sources of truth that can drift. Also lands a real concurrent-safety fix to the optimistic rollback path — full-snapshot rollback would overwrite unrelated concurrent mutations; the new rollback patches only the failed field on top of the latest cache.

Why this matters now: #33056 decomposed ai-page.tsx into seven cards over a shared useDaemonConfig() hook with key-based dedup. This PR finishes the architecture below the card boundary — language-model-card.tsx drops 4 useState copies + initialized ref + reconcileFromDaemonConfig, modals read the same cache, and the useDaemonConfigMutation() shared hook gives every consumer the same onSettled-invalidating mutation surface. The two PRs together complete the TanStack-as-source-of-truth pattern for daemon config.

What this does

  • use-daemon-config.ts — exposes profiles / profileOrder / activeProfile / callSites as memoized selectors over the same query data; adds useDaemonConfigMutation() wrapping configPatch in useMutation with onSettled invalidation. All consumers share ONE cache entry, one mutation surface.
  • language-model-card.tsx -82/+40 — 4 useState copies of server state deleted, initialized ref guard gone, reconcileFromDaemonConfig for profiles removed. Cache reads directly. draftActiveProfile correctly kept as useState — that's unsaved dropdown selection, an ephemeral UI state, not server state. The diff comment "ephemeral UI state, correct as useState" makes that boundary explicit.
  • manage-profiles-modal.tsx -303/+161 — biggest single delta. Modal calls useDaemonConfig() directly; onProfilesChanged callback deleted; optimistic toggle/reorder via cancelQueries + setQueryData updater + targeted field rollback on error. 8x direct configPatch() calls collapsed to useDaemonConfigMutation().
  • call-site-overrides-modal.tsx -78/+23 — same treatment: cache read direct, onSaved / persistedOverrides / orderedProfiles props deleted, mutations via shared hook.
  • ai-types.ts / ai-utils.tsDaemonConfigReconciliation profile fields removed (no longer needed; web-search / image-gen service modes remain on the reconciliation path).
  • Spec fix (commit 1, b83f45d9): PATCH /v1/config had no requestBody / responseBody definitions — generated SDK was emitting body?: never. New requestBody: z.record(z.unknown()) + responseBody: { ok: boolean }. This is the prerequisite that lets the rest of the refactor use the generated client.
  • STATE_MANAGEMENT.md — "Via the Cache" example updated: full-snapshot rollback → targeted field rollback with updater functions; documents why cancelQueries is required before every optimistic setQueryData. Future-proofs the next reviewer who hits this pattern.

Anti-pattern check

  • No render-phase ref mutations in any modified file.
  • No runtime-boundary as casts added. Grepped every ADDED line in the 9-file diff: zero non-comment as hits.
  • Rules of Hooks — caught and fixed mid-PR (Codex P2 #1, see below). useAssistantFeatureFlagStore.use.queryComplexityRouting() correctly hoisted to component top level and added to the useMemo deps. Verified at HEAD line 277, deps line 292.
  • Concurrent-safe optimistic update lifecycle — every optimistic write is preceded by cancelQueries (toggle line 315, reorder line 462). Rollback patches only the failed field (previousStatus line 317 → updater at line 347 sets just .status). This is structurally the same pattern #33046 settled on for contacts — refetch-failure resilience without overwriting concurrent successful writes.
  • TanStack canonical mutation shapeuseMutation with onSettled invalidation as the baseline; per-call setQueryData updaters only where optimistic UX is worth the complexity. Matches STATE_MANAGEMENT.md as updated in this PR.

Per-bot findings — verification at HEAD 66eead5a

  • Codex P2 #1 (manage-profiles-modal.tsx:291) — "Subscribe to the routing flag before memoizing profiles" — FIXED in b5206968df. Devin reviewer's reply correctly identified this as actually worse than a stale-dep — calling a Zustand store hook inside useMemo violates Rules of Hooks. Hook now hoisted to top of component, included in deps. Verified at HEAD.
  • Codex P2 #2 (manage-profiles-modal.tsx:337) — "Roll back only the toggled profile status" — FIXED in 3d6fb67c91. Toggle rollback now captures previousStatus only and uses a setQueryData updater that patches just .status on top of the latest cache, matching the reorder rollback's profileOrder narrowing. Verified at HEAD — previousStatus capture at line 317, narrow rollback at line 347.
  • Devin Review at sha 1 b83f45d9 "No Issues Found" with 3 dashboard findings. The 3 dashboard findings drove the audit commits (b5206968, 3d6fb67c). All landed.
  • Final docs commit 66eead5a updates STATE_MANAGEMENT.md to document the concurrent-safe rollback pattern — Codex hasn't returned verdict at this SHA yet, but the doc-only delta over 3d6fb67c is mechanical (the code Codex green-lit in the prior round is untouched).

Merge gate

Gate Status
Vex APPROVE ✅ (this review)
Second approval ⏳ Codex still processing at HEAD (👀 reaction; Boss triggered at 21:04 vs HEAD at 20:59). Will merge when Codex returns 👍 — won't re-trigger.
CI checks ✅ all green (Lint / Type Check / Test / Build / FlexFrame / OpenAPI / 2× Socket)
Outstanding REQUEST_CHANGES ✅ none
Open inline comments ✅ both Codex P2s marked resolved by Devin commits

Vellum Constitution — Distinct: the value here is treating "concurrent optimistic rollback" as a first-class invariant, not an edge case. Most TanStack guides stop at "snapshot → optimistic → restore-on-error" — that's safe in isolation but unsafe under real concurrent mutation. Patching only the failed field on top of the latest cache is the right shape, and codifying it in STATE_MANAGEMENT.md means the next reviewer doesn't have to re-derive it.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant