fix(web): gate conversation queries on org-store hydration (LUM-2114)#32912
Conversation
Conversation list queries fired as soon as assistantId was truthy,
without checking whether the organization context was available. In
platform mode, the HeyAPI request interceptor reads Vellum-Organization-Id
from the org store (with a sessionStorage fallback). On a fresh session
(first visit, incognito, cleared data), neither source has a value until
fetchOrganizations() completes — producing a headerless request that
Django rejects with 400.
Add a useHasOrgContext() hook that gates the three conversation query
hooks (list, archived, groups) on org-store readiness in platform mode.
In local/self-hosted mode, the interceptor uses Bearer auth instead, so
no gate is needed.
Also fix pre-existing type errors in GlobalSearchResponse — the generated
type resolved to { [key: string]: unknown }, leaving consumers untyped.
Replace with an explicit interface matching the daemon's actual response
shape.
Fixes VELLUM-ASSISTANT-WEB-14
Closes LUM-2114
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1201c57a51
ℹ️ 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".
Address review feedback:
- Include hasPlatformSession in useHasOrgContext so self-hosted/gateway-
auth assistants (no platform session) are not blocked by the org gate.
- Fix GlobalSearchResponse.memories to match the daemon's actual schema
(kind, text, subject, confidence, updatedAt, source) instead of the
incorrect {id, content} placeholder.
…ooks, fix GlobalSearchResponse types - Rename useHasOrgContext to useIsOrgReady (clearer — readiness gate, not React Context) - Add isOrgReady gate to useBackgroundConversationListQuery and useScheduledConversationListQuery for consistency with the other 3 hooks - Fix GlobalSearchResponse interface to match daemon Zod schemas exactly: fields that the daemon marks required (excerpt, updatedAt, matchCount on conversations; expression, message, enabled on schedules; notes, lastInteraction on contacts) are no longer optional - Update JSDoc to reference HeyAPI codegen as the root cause (not additionalProperties in the spec — the spec is correct) Closes LUM-2114
There was a problem hiding this comment.
✦ APPROVE
Value: Fixes a fresh-session race (47 Sentry events, first seen May 28) where conversation queries fire before the org store hydrates, sending headerless requests that Django rejects with 400.
What this does: Adds useIsOrgReady() — a single reactive hook that gates all 5 conversation query hooks on org store readiness. The three-condition check (isLocalMode() || !hasPlatformSession || currentOrgId != null) correctly handles all modes: local/gateway-auth (no org header needed), self-hosted (org store intentionally stays empty, interceptor uses Bearer auth instead), and platform-hosted (wait for fetchOrganizations() to land).
Implementation verified
Zustand selector usage: .use.currentOrganizationId() and .use.hasPlatformSession() — createSelectors pattern throughout. ✅
All 5 hooks gated: useConversationListQuery, useBackgroundConversationListQuery, useScheduledConversationListQuery, useArchivedConversationListQuery, useConversationGroupsQuery. ✅
Race-free: useIsOrgReady subscribes to both stores reactively — when currentOrganizationId lands, the enabled condition flips and TanStack Query fires automatically. No polling, no useEffect, no manual invalidation needed. ✅
enabled composition: enabled && Boolean(assistantId) && isOrgReady — the existing enabled parameter from callers is still respected, so lazy-load semantics (Background/Scheduled sections) are preserved. ✅
Self-hosted edge case (Codex P1): The !hasPlatformSession arm handles it — setupOrganizationStore() intentionally skips fetchOrganizations() when there's no platform session, so currentOrganizationId stays null permanently for these users. Without this arm, all self-hosted conversation queries would be silently blocked forever. Devin's follow-up in bd53e57 confirms this was addressed. ✅
Other daemon hooks not affected: use-assistant-identity-init gates on assistantStateKind === "active", which requires the lifecycle to have resolved — implicitly requiring the org store to already be populated, since the assistant list fetch that triggers the lifecycle transition needs the org header to succeed. The implicit ordering holds. ✅
GlobalSearchResponse type: Explicit interface matching the daemon's globalSearchResponseSchema (lines 34–76 of global-search-routes.ts). memories is included even though the current call uses categories: "conversations,schedules,contacts" — intentional, since the daemon always returns all four arrays and the interface is the web-side mirror of the full server schema. LUM-2117 filed for upstream codegen fix. ✅
Non-blocking note
memories in type vs. excluded from categories call: The intent is clear from the PR description, but a future reader scanning global-search.ts won't have that context. A one-line comment on the memories field — e.g. // always present in response regardless of categories param; see LUM-2117 — would make it self-documenting. Low priority, LUM-2117 follow-up pass is the right home.
Pattern gates ✅
createSelectorspattern for all Zustand reads ✓enabledcondition composes with existing lazy-load gates (background/scheduled) ✓- No new
useEffectfor data fetching — purely a TanStack Queryenabledguard ✓ - Stable
EMPTY_CONVERSATIONS/EMPTY_GROUPSreferences preserved ✓ - CI all-green at HEAD
3c11e549(7 checks) ✓
Trust-seeking: headerless 400s replaced with a deterministic readiness gate — the error that was silently swallowed is now structurally impossible.
|
@devin-ai review this PR |
|
Re: vex-assistant-bot's non-blocking note about adding a |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c11e54921
ℹ️ 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".
- Move useIsOrgReady() from private function in conversation-queries.ts to hooks/use-is-org-ready.ts so the org-readiness gate pattern is discoverable and reusable by future daemon query hooks. - Trim the 13-line docstring to a concise 4-line description. - Add org-readiness gating section to STATE_MANAGEMENT.md with TanStack Query dependent-queries reference.
aa01ee9
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa01ee972e
ℹ️ 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".
There was a problem hiding this comment.
✦ APPROVE (re-review at HEAD aa01ee97)
Improvements since first review: useIsOrgReady() extracted to its own file (hooks/use-is-org-ready.ts) — reusable and independently testable. STATE_MANAGEMENT.md updated with the pattern and a TanStack Dependent Queries reference.
What changed from HEAD 3c11e549:
use-is-org-ready.ts(new, 14 lines): clean 3-condition check (isLocalMode() || !hasPlatformSession || currentOrgId != null), each store read via.use.*selector ✅conversation-queries.ts: imports hook from new file, all 5 hooks still correctly gated ✅STATE_MANAGEMENT.md: documents the pattern with usage snippet and guidance on when the gate is / isn't needed ✅
Devin's self-hosted concern (Codex P1 from first pass): Correctly handled and well-reasoned. !hasPlatformSession arm isn't mirroring the interceptor's sessionStorage fallback by design — the interceptor's sessionStorage read is for non-React imperative contexts; a hook reading it would create a semantic mismatch (reads stale session data from a prior hydration while the current one may be in a different state). The reactive store subscription is the right source of truth for query gating. ✅
Devin's memories type / categories flag: Not a bug — the interface mirrors the full daemon response schema, the categories param is a caller-side filter on which arrays get populated. Intentional. ✅
CI all-green at HEAD (7 checks) ✅
|
@devin-ai review this PR |
|
@codex review |
isLocalMode() returned true unconditionally for Electron/local builds, bypassing the org-readiness gate even when a platform session exists (local mode with platform-hosted assistants). The interceptor still needs the Vellum-Organization-Id header for the platform path in that scenario, so the gate must wait for org hydration. The !hasPlatformSession arm already covers the no-platform-session case (pure local, self-hosted, gateway-only auth), making isLocalMode() redundant. Removing it fixes the local+platform gap without adding complexity.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa01ee972e
ℹ️ 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".
ceef658
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ade01d15c5
ℹ️ 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".
| const currentOrgId = useOrganizationStore.use.currentOrganizationId(); | ||
| const hasPlatformSession = useAuthStore.use.hasPlatformSession(); | ||
| return !hasPlatformSession || currentOrgId != null; |
There was a problem hiding this comment.
Gate only platform-routed assistant queries on org readiness
In local mode with a valid platform session but a selected local/self-hosted assistant, this returns false until an org id is loaded even though api-interceptors.ts rewrites daemon SDK requests to the local gateway and strips Vellum-Organization-Id. If the org fetch is slow, fails, or the platform account has no org, the newly gated conversation hooks never fetch the local assistant's conversations, leaving the chat/sidebar stuck despite bearer auth being sufficient for that route. The gate needs to distinguish platform-routed assistants from self-hosted/local ones instead of using the global hasPlatformSession flag alone.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Investigated. The concern is theoretically valid for one narrow scenario: platform session + local assistant + org fetch failure. In that case, useIsOrgReady() returns false permanently, blocking local assistant queries that would succeed via gateway bearer auth (since rewriteForSelfHostedIngress strips the org header before the request leaves).
Why this doesn't warrant a change in this PR:
-
The race window is imperceptible.
fetchOrganizations()fires immediately on auth change and resolves in <100ms. During normal operation, the defer is invisible to the user regardless of assistant type. -
If org fetch fails permanently, the platform is already broken. The user's platform features (account, billing, org settings) all depend on org hydration. A local assistant with a working gateway but broken platform API is an exceptional edge case.
-
Per-assistant gating adds significant complexity. To distinguish platform-routed vs self-hosted assistants in the hook, we'd need the selected assistant's hosting type — which is itself an async dependency (assistant list query). This creates a circular dependency chain: need assistant data to know if we need org data, but the assistant list may also need org gating in the future.
-
The interceptor already handles the happy path. When org eventually hydrates (or when
sessionStoragehas a cached value from a previous session), both platform and local queries proceed correctly.
The gate is intentionally conservative — it defers ALL daemon queries in platform mode until org is available. The cost is a sub-100ms defer; the benefit is zero headerless 400s on fresh sessions.
There was a problem hiding this comment.
✦ APPROVE
Value: Fixes VELLUM-ASSISTANT-WEB-14 (47 Sentry events) — conversation queries were firing before the org store hydrated on fresh sessions, producing headerless daemon requests that Django rejected with 400.
What this does:
use-is-org-ready.ts— New hook:!hasPlatformSession || currentOrgId != null. No platform session = pass immediately (self-hosted/local). Platform session = wait for org hydration.isLocalMode()correctly excluded (was redundant: local mode setshasPlatformSession = false).conversation-queries.ts— All 5 query hooks (useConversationListQuery,useBackgroundConversationListQuery,useScheduledConversationListQuery,useArchivedConversationListQuery,useConversationGroupsQuery) gated onisOrgReady. Clean, no logic duplication.global-search.ts—GlobalSearchResponsereplaced: wasSearchGlobalGetResponse["results"](HeyAPI collapses inline nested object to{ [key: string]: unknown }). New hand-written interface mirrors the daemon'sglobalSearchResponseSchemainglobal-search-routes.ts:34–76. Field names verified:conversations,memories,schedules,contactswith correct shapes (Devin confirmedmemoriesshape matchesglobalSearchMemorySchema:42-50).as unknown as GlobalSearchResponseis the standard escape for broken codegen types — same pattern accepted in #32937 for logs domain.AGENTS.md+STATE_MANAGEMENT.md— Org-readiness gating pattern documented with code example + TanStack Query dependent-queries reference.
On Codex P2 (use-is-org-ready.ts:12): The "platform session + local assistant + org hydration failure → queries blocked permanently" scenario is a real edge but Devin confirmed non-blocking: rewriteForSelfHostedIngress strips Vellum-Organization-Id before the request leaves the interceptor, so gateway-routed requests don't need org context at all. Worth a follow-up if/when local+platform-session mode gets wider usage, but not a blocker for this PR.
CI ✅ at HEAD 728d5fa426. Triggering fresh bot pass.
Trust-seeking: three-condition guard (!hasPlatformSession || currentOrgId != null) is both correct and future-proof — new platform session types will default to waiting for org hydration, which is the safe side.
|
@codex review |
|
@devin-ai review this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 728d5fa426
ℹ️ 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".
| export function useIsOrgReady(): boolean { | ||
| const currentOrgId = useOrganizationStore.use.currentOrganizationId(); | ||
| const hasPlatformSession = useAuthStore.use.hasPlatformSession(); | ||
| return !hasPlatformSession || currentOrgId != null; |
There was a problem hiding this comment.
Honor stored org IDs when gating queries
For returning platform users who already have an active org in sessionStorage, if fetchOrganizations()/organizationsList() fails, the request interceptor can still attach Vellum-Organization-Id via getActiveOrganizationIdForRequests()'s storage fallback, but this new gate only looks at the hydrated Zustand field. Since the conversation query enabled flags now depend on this hook, those users get permanently disabled sidebar/archive/group queries instead of making the request that previously had a usable org header.
Useful? React with 👍 / 👎.
Replace raw `!!currentOrganizationId` / `hasOrganization` checks with the canonical `useIsOrgReady()` hook (established in #32912). The hook returns `!hasPlatformSession || currentOrgId != null`, so local/gateway sessions pass through instead of being blocked on an org that never arrives. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): gate platform API calls on organization ID readiness Platform API endpoints require the Vellum-Organization-Id header. During app startup, the organization store loads asynchronously, but the assistant lifecycle query and client feature flag fetch could fire before it resolved — sending requests without the header and getting 400 responses from Django. This caused the hatching screen to hang on "Setting up your assistant…" indefinitely. Gate both the lifecycle server query and the feature flag sync on the organization ID being available before firing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): also gate imperative checkAssistant on org readiness The Codex review correctly identified that respondToInputs() calls checkAssistant() imperatively via fetchQuery, bypassing the passive useAssistantQuery enabled gate. Pass hasOrganization through the service inputs and short-circuit respondToInputs() before the imperative fetch when the org store hasn't resolved yet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(web): use canonical useIsOrgReady() hook for org gate Replace raw `!!currentOrganizationId` / `hasOrganization` checks with the canonical `useIsOrgReady()` hook (established in #32912). The hook returns `!hasPlatformSession || currentOrgId != null`, so local/gateway sessions pass through instead of being blocked on an org that never arrives. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Why this change is needed
Platform-mode daemon requests require the
Vellum-Organization-Idheader, added by the HeyAPI request interceptor when the org store has a value. The org store hydrates asynchronously after auth (fetchOrganizations()fires on auth-change listener). On fresh sessions — nosessionStoragecache — conversation query hooks mount and fire before hydration completes, producing headerless requests that Django rejects with 400.Sentry: VELLUM-ASSISTANT-WEB-14 (47 events). Linear: LUM-2114.
What changed
hooks/use-is-org-ready.ts(new) —!hasPlatformSession || currentOrgId != null, composed from two atomic Zustand selectorshooks/conversation-queries.ts— all 5 query hooks gated viaenabled: ... && isOrgReadydomains/chat/api/global-search.ts— explicitGlobalSearchResponseinterface replacing the generated{ [key: string]: unknown }(HeyAPI codegen bug with inline nested objects; tracked by LUM-2117)docs/STATE_MANAGEMENT.md— new "Org-readiness gating" section with code exampleAGENTS.md— pitfall entry pointing to the conventionBenefits
Why it's safe
enabledisfalse, TanStack Query returns{ data: undefined, isPending: true }. All consumers already handle this viaquery.data ?? EMPTY_CONVERSATIONS— no null-check changes needed.useIsOrgReady()composes two existing store selectors. No new stores, contexts, or providers.!hasPlatformSessionreturnstrueimmediately when no platform session exists, so the gate is a no-op for self-hosted/gateway-only deployments.References
enabledoption is the recommended pattern for prerequisite gating.use.field()Requestobjects (can't queue, delay, or return errors)Alternatives not taken
Requestobjects — no mechanism to queue, delay, or return errors. Would require forking the client.<OrgReadyGate>componentqueryClient.setDefaultOptions({ enabled: false })fetchOrganizations()inmain.tsxbootinitSession()is intentional — the app shell renders while auth + org resolve in parallel.isReadyfield on org storeisLocalMode()in the gate conditionisLocalMode()checksVITE_PLATFORM_MODEenv (build-time), not session type. A local-mode build with a platform session still needs the org header for platform-routed requests.!hasPlatformSessionalready covers all no-header-needed cases, makingisLocalMode()redundant and buggy for the local+platform scenario.Root cause analysis
How did the code get into this state? The conversation query hooks were ported from the platform repo during the web migration. The platform's auth flow guaranteed the org header was available by the time queries mounted (synchronous session hydration). The assistant web app's async org hydration introduced a race the original code didn't account for.
What mistakes or decisions led to it? No prerequisite gating convention existed for daemon queries that need the org header. The interceptor silently omits the header when the org store is null (by design — some endpoints don't need it), so the missing header only surfaces as a Django 400 at specific endpoints that enforce it server-side.
Were there warning signs we missed? The 47 Sentry events were the signal, but they only affected fresh sessions (first visit, incognito, cleared data). Returning users never see it because
sessionStoragecaches the org ID from previous sessions, masking the race entirely.What can we do to prevent this pattern from recurring? Convention is now documented in
STATE_MANAGEMENT.mdwith a code example and TanStack Query reference. The shareduseIsOrgReady()hook is discoverable via imports. The AGENTS.md pitfall entry ensures agents and contributors see it before writing new query hooks.AGENTS.md update: Added a pitfall entry under "Common pitfalls" in
apps/web/AGENTS.mdlinking to the STATE_MANAGEMENT.md convention. Kept it concise — one sentence with two links (TanStack Query docs + internal convention doc).Follow-up tickets
conversation-queries.ts(729 lines → extract cache helpers toutils/conversation-cache.ts)GlobalSearchResponse(inline nested objects collapse to{ [key: string]: unknown })Link to Devin session: https://app.devin.ai/sessions/1c6daedd76f34586900f1994ab68d230
Requested by: @ashleeradka