"use client"; // The workspace body: it mounts BOTH panes — the search/results rail and the // chat/report pane — once, and a [ Search | Chat | Split ] control governs which // are shown. Because both stay mounted (hidden via CSS when off-screen), toggling // views never re-runs the search or drops chat state; they read the one shared // SearchSession the shell provides. // // • single-pane view follows the ROUTE (`/` → search, `/ask` → chat), so a // direct load or a Back/Forward lands on the right pane, and the two // single-pane buttons are real navigation Links; // • Split is the one cross-route state (persisted): show both, side by side on // `lg+`. Below `lg` Split isn't offered and the control is Search ⇄ Chat tabs. // // The routed `children` is each route's own intro chrome (the transcript-count // header on `/`, the "Ask a question" header on `/ask`); it's slotted above the // pane it belongs to. import { createContext, useContext, useEffect, useMemo, useState, type ReactNode, } from "react"; import { usePathname } from "next/navigation"; import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery"; import SearchResults from "yt-dlp-transcript-common/components/SearchResults"; import AskChat from "../ask/AskChat"; export type ViewMode = "search" | "chat" | "split"; const SPLIT_KEY = "ytdlp-tb:workspace-split"; type WorkspaceViewValue = { onAsk: boolean; view: ViewMode; wide: boolean; setSplit: (on: boolean) => void; }; const Ctx = createContext(null); export function useWorkspaceView(): WorkspaceViewValue { const v = useContext(Ctx); if (!v) throw new Error("useWorkspaceView outside WorkspaceViewProvider"); return v; } // The view state lives ABOVE both the search bar and the panes, because the // [Search | Chat | Split] control now sits in the bar's chip row — beside the // search box, where the choice is made — while the panes it governs are here. export function WorkspaceViewProvider({ children }: { children: ReactNode }) { const pathname = usePathname(); const onAsk = pathname?.startsWith("/ask") ?? false; // The only persisted view state: whether the user opted into Split. The single- // pane choice always follows the route, so a direct /ask load shows the chat. const [splitOn, setSplitOn] = useState(false); const wide = useMediaQuery("(min-width: 1024px)"); useEffect(() => { /* eslint-disable react-hooks/set-state-in-effect -- one-time restore from localStorage after hydration; there is no localStorage during SSR, so this can't be a lazy useState initializer. */ try { setSplitOn(localStorage.getItem(SPLIT_KEY) === "1"); } catch { /* storage unavailable */ } /* eslint-enable react-hooks/set-state-in-effect */ }, []); // Split only takes effect on a wide viewport; otherwise the view follows the // route (the two-state tab degradation). const view: ViewMode = splitOn && wide ? "split" : onAsk ? "chat" : "search"; const value = useMemo( () => ({ onAsk, view, wide, setSplit: (on: boolean) => { setSplitOn(on); try { localStorage.setItem(SPLIT_KEY, on ? "1" : "0"); } catch { /* ignore */ } }, }), [onAsk, view, wide], ); return {children}; } export default function WorkspaceView({ children }: { children: ReactNode }) { const { onAsk, view } = useWorkspaceView(); const split = view === "split"; const showSearch = split || view === "search"; const showChat = split || view === "chat"; // Mount each pane lazily and LATCH it mounted once revealed. This keeps a // single-pane route light to hydrate (a fresh `/` doesn't mount the whole chat, // and vice-versa) — which matters because a heavy first paint widens the // pre-hydration window where a nav click can be lost. Once a pane has been // shown it stays mounted (hidden via CSS), so switching back never drops its // state or re-runs its work. Latched with React's "adjust state during render" // pattern (a one-way false→true flip), so no effect / cascading render. const [searchMounted, setSearchMounted] = useState(showSearch); const [chatMounted, setChatMounted] = useState(showChat); if (showSearch && !searchMounted) setSearchMounted(true); if (showChat && !chatMounted) setChatMounted(true); return (
{!onAsk && children} {searchMounted && }
{onAsk && children} {chatMounted && }
); }