diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 856037431a0..9e72a100603 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -20,6 +20,7 @@ Read these before making changes: - **Don't ship cross-route state through outlet context.** React Router outlet context [re-renders every consumer when any field changes](https://reactrouter.com/start/framework/outlet), forces a bundled value through every layout, and silently resolves to `undefined` whenever an intermediate `` (a gate, a wrapper) sits between writer and reader. Cross-route state — auth, lifecycle, selection, feature flags, layout slots — belongs in a Zustand store so consumers can subscribe atomically and so intermediate routes don't break the channel. Use outlet context only for one-shot parent→direct-child wiring with no intermediate routes. - **HeyAPI client interceptors**: The daemon, platform, and auth clients have different routing requirements. Daemon SDK requests forward unconditionally to the self-hosted gateway; platform requests use a segment allowlist. Don't share interceptors across clients with different routing needs. Interceptors [chain sequentially](https://heyapi.dev/openapi-ts/clients/fetch#interceptors) — a gate registered after a rewrite interceptor receives the *rewritten* request, so it must check the final URL, not assume platform origin. - **Type colocation**: Types live with the module that owns them. `src/types/` is only for cross-domain types with no clear owning module. Don't create `-types.ts` files to break circular dependencies — use [`import type`](https://www.typescriptlang.org/docs/handbook/modules/reference.html#type-only-imports-and-exports) instead (erased at compile time, no runtime cycle). See [`docs/CONVENTIONS.md` — Top-level shared directories](./docs/CONVENTIONS.md#top-level-shared-directories). +- **Org-readiness gating for daemon queries**: Platform-mode requests need the `Vellum-Organization-Id` header, which the interceptor reads from the org store. The store hydrates asynchronously after auth, so TanStack Query hooks that mount eagerly must gate on `useIsOrgReady()` via the [`enabled` option](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries). See [`docs/STATE_MANAGEMENT.md` — Org-readiness gating](./docs/STATE_MANAGEMENT.md#org-readiness-gating-for-daemon-queries). When a topic in `docs/CONVENTIONS.md` grows past ~100 lines and has a coherent boundary, extract it into a `docs/TOPIC.md` sibling with a diff --git a/apps/web/docs/STATE_MANAGEMENT.md b/apps/web/docs/STATE_MANAGEMENT.md index 2fb2a1e652c..7e5a003a196 100644 --- a/apps/web/docs/STATE_MANAGEMENT.md +++ b/apps/web/docs/STATE_MANAGEMENT.md @@ -282,6 +282,29 @@ References: - [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand) - [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components) +### Org-readiness gating for daemon queries + +Platform-mode daemon requests require the `Vellum-Organization-Id` +header. The org store hydrates asynchronously after auth, so queries +that mount before hydration completes (e.g. conversation queries on +the eager `ChatPage` path) must gate on `useIsOrgReady()`: + +```ts +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; + +const isOrgReady = useIsOrgReady(); +const query = useQuery({ + ...queryOptions, + enabled: enabled && Boolean(assistantId) && isOrgReady, +}); +``` + +Queries mounted inside `` typically don't race +because the lifecycle resolves after org hydration, but the gate is +cheap and safe to add defensively. + +Reference: [TanStack Query — Dependent Queries](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries) + ### Canonical migration example When migrating an imperative `setTimeout`-driven fetch loop to diff --git a/apps/web/src/domains/chat/api/global-search.ts b/apps/web/src/domains/chat/api/global-search.ts index de97c45e8c5..7075e347e74 100644 --- a/apps/web/src/domains/chat/api/global-search.ts +++ b/apps/web/src/domains/chat/api/global-search.ts @@ -1,12 +1,50 @@ import { searchGlobalGet } from "@/generated/daemon/sdk.gen"; -import type { SearchGlobalGetResponse } from "@/generated/daemon/types.gen"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** Search results grouped by category, as returned by the daemon. */ -export type GlobalSearchResponse = SearchGlobalGetResponse["results"]; +/** + * Search results grouped by category, as returned by the daemon. + * + * The generated `SearchGlobalGetResponse["results"]` type resolves to + * `{ [key: string]: unknown }` because HeyAPI collapses the inline nested + * object despite the spec having full property definitions. This interface + * mirrors the Zod schemas in `assistant/src/runtime/routes/global-search-routes.ts` + * (lines 34–76) so consumers get proper type safety. + */ +export interface GlobalSearchResponse { + conversations: Array<{ + id: string; + title: string | null; + updatedAt: number; + excerpt: string; + matchCount: number; + }>; + memories: Array<{ + id: string; + kind: string; + text: string; + subject: string | null; + confidence: number; + updatedAt: number; + source: "lexical" | "semantic"; + }>; + schedules: Array<{ + id: string; + name: string; + expression: string | null; + message: string; + enabled: boolean; + nextRunAt: number | null; + }>; + contacts: Array<{ + id: string; + displayName: string; + notes: string | null; + lastInteraction: number | null; + }>; +} // --------------------------------------------------------------------------- // API @@ -48,7 +86,7 @@ export async function searchGlobal( return EMPTY_RESULTS; } - return data.results; + return data.results as unknown as GlobalSearchResponse; } catch (err) { // AbortError is expected when debounced queries supersede each other. if (err instanceof DOMException && err.name === "AbortError") { diff --git a/apps/web/src/hooks/conversation-queries.ts b/apps/web/src/hooks/conversation-queries.ts index 4ae36069ff4..5bcc5ee17c4 100644 --- a/apps/web/src/hooks/conversation-queries.ts +++ b/apps/web/src/hooks/conversation-queries.ts @@ -56,6 +56,7 @@ import { assertHasResponse, extractErrorMessage, } from "@/utils/api-errors"; +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; import { archivedConversationsQueryKey, backgroundConversationsQueryKey, @@ -312,10 +313,11 @@ export function useConversationListQuery( error: Error | null; refetch: () => void; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: conversationsQueryKey(assistantId), queryFn: () => listConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -349,10 +351,11 @@ export function useBackgroundConversationListQuery( isLoading: boolean; isPending: boolean; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: backgroundConversationsQueryKey(assistantId), queryFn: () => listBackgroundConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -382,10 +385,11 @@ export function useScheduledConversationListQuery( isLoading: boolean; isPending: boolean; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: scheduledConversationsQueryKey(assistantId), queryFn: () => listScheduledConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -415,10 +419,11 @@ export function useArchivedConversationListQuery( error: Error | null; refetch: () => void; } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ queryKey: archivedConversationsQueryKey(assistantId), queryFn: () => listArchivedConversations(assistantId!), - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { @@ -442,12 +447,13 @@ export function useConversationGroupsQuery( assistantId: string | null, enabled: boolean = true, ): { conversationGroups: ConversationGroup[]; isLoading: boolean } { + const isOrgReady = useIsOrgReady(); const query = useQuery({ ...groupsGetOptions({ path: { assistant_id: assistantId ?? "" }, } as Options), select: (data) => data.groups, - enabled: enabled && Boolean(assistantId), + enabled: enabled && Boolean(assistantId) && isOrgReady, staleTime: QUERY_STALE_TIME_MS, }); return { diff --git a/apps/web/src/hooks/use-is-org-ready.ts b/apps/web/src/hooks/use-is-org-ready.ts new file mode 100644 index 00000000000..ab6cba7778f --- /dev/null +++ b/apps/web/src/hooks/use-is-org-ready.ts @@ -0,0 +1,13 @@ +import { useAuthStore } from "@/stores/auth-store"; +import { useOrganizationStore } from "@/stores/organization-store"; + +/** + * Gate for queries that need the `Vellum-Organization-Id` header. + * Returns `true` when the org store has hydrated, or when no + * platform session exists (self-hosted / gateway-only auth). + */ +export function useIsOrgReady(): boolean { + const currentOrgId = useOrganizationStore.use.currentOrganizationId(); + const hasPlatformSession = useAuthStore.use.hasPlatformSession(); + return !hasPlatformSession || currentOrgId != null; +}