"use client"; import { useEffect } from "react"; import { DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { Sidebar } from "./sidebar"; import { Topbar } from "./topbar"; import { CommandPalette } from "./command-palette"; import { OfflineBanner } from "@/components/collaboration/OfflineBanner"; import { SearchDialog } from "@/components/search/search-dialog"; import { AiPanel } from "@/components/ai/ai-panel"; import { GeneratePageDialog } from "@/components/ai/generate-page-dialog"; import { AssistantDialog } from "@/components/ai/assistant-dialog"; import { MoveToDialog } from "@/components/dialogs/move-to-dialog"; import { BulkActionsDialog } from "@/components/dialogs/bulk-actions-dialog"; import { initOutbox } from "@/stores/offlineQueue"; import { useUiStore } from "@/stores/ui-store"; import { authStore, useAuthStore } from "@/lib/auth-store"; import { realtime } from "@/lib/realtime"; /** * App shell: sidebar + main column (topbar + content), global search dialog * (⌘K, TASK-0098) + command palette, and the global DnD context shared by * the sidebar tree and the block editor (dnd-kit). */ export function WorkspaceShell({ children }: { children: React.ReactNode }) { const setSearchDialog = useUiStore((s) => s.setSearchDialog); // The offline outbox (R1): load the persisted items + drain on `online`. // Idempotent (the store guards against a double-wire). useEffect(() => { initOutbox(); }, []); // The realtime socket is an app-wide singleton ("one socket per tab") but // the only places that OPENED it were the page-editor and database-home // routes (usePageRealtime / useDatabasePresence). On every other menu the // socket was never connected, so `realtime.connectionStatus` stayed // "disconnected" and the top-bar StatusChip read "Offline" — even though // the page's REST data loaded fine. Open it here, from the shell that // renders on every authenticated route, so the connection (and the Live/ // Offline chip + presence) is tab-wide. // // We connect on the *presence* of the access token (reactive): the (app) // layout gates on `!initializing && user`, so the token is hydrated before // first render, but keying the effect on the token also re-arms `connect` // if it is ever briefly null. `connect` is idempotent (a no-op while the // socket is OPEN/CONNECTING), so the route-level `connect` calls that were // the only openers before are now redundant-but-harmless. const accessToken = useAuthStore((s) => s.accessToken); useEffect(() => { if (!accessToken) { // Signed out (logout / session kill): drop the tab's socket. The client // never re-authenticates an OPEN socket, so without this a second login // in the same tab would keep the previous user's handshake. realtime.disconnect(); return; } realtime.connect(() => authStore.accessToken); }, [accessToken]); // ⌘K / Ctrl+K opens the GLOBAL SEARCH dialog (TASK-0098 — the bare // meta/ctrl+k was the command palette's binding before Phase 8; the // palette remains reachable through the sidebar and stays wired for its // OWN in-palette shortcuts, but the bare hotkey belongs to search now). useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setSearchDialog(!useUiStore.getState().searchOpen); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [setSearchDialog]); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); // Sidebar drags are handled inside the tree; this shell-level context // exists so blocks and tree rows share one DnD provider for cross-surface // drags. const onDragEnd = (_event: DragEndEvent) => { /* per-surface drag handlers own the mutation */ }; return (
{/* The offline banner (R2): top, below the topbar — visible when offline / the provider is down / reconnecting ≥ 5 s. */}
{children}
); }