client-providers.tsx7.9 KBView on GitHub
import type { ChatExtras } from '@/modules/cedar-os/src/chat-extras';
import { useCanvases } from '@/modules/canvas/hooks/use-canvases';
import { useSettings } from '@/modules/userSettings/hooks/use-settings';
import { LoadingProvider } from '@/components/context/loading-context';
import { AdministeredUserProvider } from '@/modules/administeredUser';
import { useSession } from '@/modules/auth/utils/auth-client';
import { markSessionSettled } from '@/lib/session-ready';
import { getRuntimeBackendUrl } from '@/lib/runtime-urls';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { SidebarProvider } from '@/components/ui/sidebar';
import { selectChatThreadId } from '@/modules/ux/uxSlice';
import { PostHogProvider } from '@/lib/posthog-provider';
// Import the component directly, NOT via the `@/modules/cedar-os/src` barrel. The barrel
// re-exports `useCedarEditor`, which imports `@tiptap/extension-emoji` — pulling the entire
// GitHub emoji dataset (~580 KB) onto the critical path of every route, login included.
// `apps/mail/package.json`'s `sideEffects` list does not cover that barrel, so Rollup treats the
// re-export as load-bearing and cannot shake it out. See docs/design/frontend-load-performance.md §2.3.
import { CedarCopilot } from '@/modules/cedar-os/src/components/CedarCopilot';
import { ThemeProvider, useTheme } from 'next-themes';
import { useRealThemeDriver } from '@/modules/userSettings/theme/use-real-theme';
import { Provider as JotaiProvider } from 'jotai';
import { useCedarStore, useTranscriptModal } from '@/modules/store';
import type { PropsWithChildren } from 'react';
import Toaster from '@/components/ui/toast';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';

/**
 * The transcript viewer, behind a dynamic-import boundary AND behind its own open flag.
 *
 * It pulls `MarkdownRenderer` -> `react-markdown` (plus the remark/rehype/micromark stack), and
 * `ClientProviders` renders on every route — so a static import here put ~1.1 MB of markdown
 * machinery in the chunk `index.html` modulepreloads, on `/login` and `/calendar` included. That
 * is the same leak `modules/cedar-os/src/chat-extras.ts` closes on the chat side; this was the
 * surviving path.
 *
 * `useTranscriptModal` lives in the eager root store, so reading `isOpen` costs nothing. Mounting
 * only while open means the chunk is fetched the first time someone clicks a citation, which is
 * also the first moment its content could be visible.
 */
const LazyTranscriptViewerModal = lazy(() =>
  import('@/components/ui/citations/TranscriptViewerModal').then((m) => ({
    default: m.TranscriptViewerModal,
  })),
);

function TranscriptViewerModalGate() {
  const { isOpen } = useTranscriptModal();
  if (!isOpen) return null;
  return (
    <Suspense fallback={null}>
      <LazyTranscriptViewerModal />
    </Suspense>
  );
}

/**
 * Stable empty defaults. `CedarCopilot` registers renderers/processors in effects keyed on these
 * arrays, so a fresh `[]` literal per render would register-and-unregister on every pass.
 */
const NO_RENDERERS: ChatExtras['messageRenderers'] = [];
const NO_PROCESSORS: ChatExtras['responseProcessors'] = [];

/**
 * Load the chat renderers and response processors after mount instead of at the root.
 *
 * They are only read inside CedarCopilot's registration effects, which key on the arrays — so
 * arriving a tick late is fine, and it keeps react-markdown, DOMPurify, motion and the tool-mapping
 * table off the critical path. See modules/cedar-os/src/chat-extras.ts.
 */
function useChatExtras(): ChatExtras {
  const [extras, setExtras] = useState<ChatExtras | null>(null);

  useEffect(() => {
    let cancelled = false;
    import('@/modules/cedar-os/src/chat-extras')
      .then(({ getChatExtras }) => {
        if (!cancelled) setExtras(getChatExtras());
      })
      .catch((err) => {
        console.error('[ClientProviders] failed to load chat extras', err);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  return {
    messageRenderers: extras?.messageRenderers ?? NO_RENDERERS,
    responseProcessors: extras?.responseProcessors ?? NO_PROCESSORS,
  };
}
// Sync theme from database to next-themes on initial mount only.
// localStorage is checked first — if the user has an explicit local preference
// already stored, we leave it alone. Only apply the DB value when there is no
// local preference (e.g. first load on a new device).
function ThemeSync() {
  const { data } = useSettings();
  const { setTheme } = useTheme();
  const hasInitialized = useRef(false);

  useEffect(() => {
    if (hasInitialized.current || !data?.settings?.colorTheme) return;
    hasInitialized.current = true;

    // The 'real' theme is driven by useRealThemeDriver, not next-themes directly.
    if (data.settings.colorTheme === 'real') return;

    const stored = localStorage.getItem('cedar-mail-theme');
    // If localStorage already has an explicit, non-default preference, respect it.
    if (stored && stored !== 'system') return;

    setTheme(data.settings.colorTheme);
  }, [data?.settings?.colorTheme, setTheme]);

  useRealThemeDriver();

  return null;
}

// Prefetch HomeView canvases early so they're ready when HomeView mounts
function HomeViewConfigPrefetch() {
  // Fetches canvases and seeds canvasSlice (staleTime: 30s)
  useCanvases();
  return null;
}

export function ClientProviders({ children }: PropsWithChildren) {
  const { data } = useSettings();
  const { data: session, isPending: isSessionPending } = useSession();
  const chatThreadId = useCedarStore(selectChatThreadId);

  // Release the tRPC transport once better-auth has answered "is there a
  // session?" — either way. Until this trips, requests queue in the fetch
  // handler instead of going out cookie-less and 401ing (see lib/session-ready).
  // This provider wraps every route, including /login, so the latch trips for
  // signed-out users too and public pages are not held past their session check.
  useEffect(() => {
    if (!isSessionPending) markSessionSettled();
  }, [isSessionPending]);

  // next-themes doesn't know about 'real' — the driver applies a concrete
  // light/dark value at runtime. Start in 'system' so next-themes initializes
  // sensibly; the driver will override once settings load.
  const persistedTheme = data?.settings.colorTheme;
  const theme = !persistedTheme || persistedTheme === 'real' ? 'system' : persistedTheme;

  const backendUrl = getRuntimeBackendUrl() || 'https://modern-lemon-whale.mastra.cloud';

  const { messageRenderers, responseProcessors } = useChatExtras();

  return (
    <NuqsAdapter>
      <JotaiProvider>
        <ThemeProvider
          attribute="class"
          enableSystem
          disableTransitionOnChange
          defaultTheme={theme}
          storageKey=[redacted]
          enableColorScheme={false}
        >
          <ThemeSync />
          <HomeViewConfigPrefetch />
          <SidebarProvider>
            <PostHogProvider>
              <LoadingProvider>
                <AdministeredUserProvider>
                  <CedarCopilot
                    userId={session?.user?.id ?? null}
                    threadId={chatThreadId}
                    llmProvider={{
                      provider: 'mastra',
                      baseURL: backendUrl,
                      chatPath: '/api/trpc/mastra.chat',
                      voiceRoute: '/ai/voice',
                    }}
                    messageStorage={{
                      type: 'database',
                    }}
                    messageRenderers={messageRenderers}
                    responseProcessors={responseProcessors}
                  >
                    {children}
                    <Toaster />
                    <TranscriptViewerModalGate />
                  </CedarCopilot>
                </AdministeredUserProvider>
              </LoadingProvider>
            </PostHogProvider>
          </SidebarProvider>
        </ThemeProvider>
      </JotaiProvider>
    </NuqsAdapter>
  );
}