'use client' /** * useChatCardItem — fetches the full mapped item for an inline chat * card via the SAME list-API endpoint the public pages use. * * Single source of truth: no parallel `buildImageUrl` / `buildMetadata` * synthesis. The chat renders the same shape, with the same joins * (author / categories / platforms / hosts / etc.), as `/blog`, * `/case-studies`, `/podcasts`, `/roadmap`, etc. * * Batching: every compact card with the same `(type, id)` shares one * TanStack-Query entry; CONCURRENT refs of the same type in the same * chat message produce ONE network request (TanStack dedups in-flight * fetches by queryKey). Data is always stale — see the query options. * * URL builder is read from `runtime.endpoints.buildListUrl` so the * embedded app can supply a per-type URL builder against the reverse * proxy; the hub wires the registry-driven builder directly. */ import { useQuery } from '@tanstack/react-query' import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context' import { embedAuthedFetch } from '../../../utils/embed-authed-fetch' import { extractItems, extractItemId } from '../../../utils/extract-items' export interface UseChatCardItemResult { item: T | undefined isLoading: boolean isError: boolean /** True only after a fetch actually completed — false for disabled * queries (no list URL for the type / empty id). */ isFetched: boolean } // `extractItems` / `extractItemId` hoisted to `src/utils/extract-items.ts` // (shared with the related-content rail — react-query-free home). export function useChatCardItem( type: string, id: string, ): UseChatCardItemResult { // Read the list-URL builder from the chat runtime — hub uses the // rag-table-config registry directly; embedded apps supply a per-type // URL builder against the reverse proxy. const runtime = useRequiredChatRuntime() const url = runtime.endpoints.buildListUrl(type, [id]) const query = useQuery({ queryKey: ['chat-card-item', type, id], queryFn: async (): Promise => { if (!url) return null // Go through `embedAuthedFetch` (NOT bare `fetch`) so the request // rides the same auth path as the chat stream/commands: it consults // the host-registered `EmbedAuthAdapter` (cookie `credentials:'include'` // cross-origin, dev-ticket Bearer, 401 refresh-and-retry). Bare `fetch` // sent no credentials, so list endpoints behind the gateway returned // 401 and the card rendered blank. const res = await embedAuthedFetch(url) // THROW on non-OK (was `return null`): callers must be able to // tell "fetched fine, entity absent" (→ deleted tombstone) from // "fetch failed" (401 refresh miss, 5xx, 429 → transient; render // nothing, never a false 'deleted' claim). TanStack surfaces the // throw as `isError`. if (!res.ok) throw new Error(`chat card fetch failed: ${res.status}`) const data = await res.json() const items = extractItems(data) const match = items.find((it) => extractItemId(type, it) === id) return (match ?? null) as T | null }, enabled: !!url && id.length > 0, // ALWAYS FRESH — no freshness window. Card entities are mutable // (tickets, tasks change status), so a cached row is a lie waiting to // render: a 5-min staleTime shipped a "NEW" badge on a ticket the // receipt right above it said was just CLOSED (2026-08-18). Every // mount refetches. `gcTime` is kept ONLY as render continuity: a card // inside a STREAMING assistant message re-mounts on every chunk as the // surrounding markdown re-parses, and the previous data renders while // the refetch is in flight — no skeleton flash, never stale-forever. staleTime: 0, gcTime: 30 * 60 * 1000, }) return { item: (query.data ?? undefined) as T | undefined, isLoading: query.isLoading, isError: query.isError, // True only after the query actually completed a fetch. DISABLED // queries (no list URL registered for the type, empty id) never // fetch — `item` is undefined there as well, and callers must not // read that as "fetched fine, entity absent" (tombstone gate in // entity-cards/dispatch.tsx). isFetched: query.isFetched, } }