import { AgentSidebar, AgentToggleButton, } from "@agent-native/core/client/agent-chat"; import { useT } from "@agent-native/core/client/i18n"; import { InvitationBanner } from "@agent-native/core/client/org"; import { useAppearanceSync } from "@agent-native/core/client/ui"; import type { CalendarEvent, CalendarEventDraft } from "@shared/api"; import { IconMenu } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { createContext, useContext, useState, useEffect, useMemo, useCallback, type ReactNode, } from "react"; import { useLocation } from "react-router"; import { AddCalendarDialog } from "@/components/calendar/AddCalendarDialog"; import { GoogleConnectBanner } from "@/components/calendar/GoogleConnectBanner"; import { KeyboardShortcutsHelp } from "@/components/calendar/KeyboardShortcutsHelp"; import { Button } from "@/components/ui/button"; import { useGoogleAuthStatus } from "@/hooks/use-google-auth"; import { useHiddenCalendars } from "@/hooks/use-hidden-calendars"; import { useIsMobile } from "@/hooks/use-mobile"; import { useNavigationState } from "@/hooks/use-navigation-state"; import { prefetchPeopleContacts } from "@/hooks/use-people"; import { Sidebar } from "./Sidebar"; const EVENT_DETAIL_MODE_KEY = "calendar-event-detail-mode"; const SIDEBAR_COLLAPSE_KEY = "calendar.sidebar.collapsed"; /** Routes that render without the full AppLayout chrome (sidebar, agent panel). */ const BARE_ROUTES = new Set(["/event"]); /** * Routes whose page renders its own toolbar. Layout still mounts Sidebar + * AgentSidebar, but skips its own header so there's no double-header. */ function pageOwnsToolbar(pathname: string): boolean { if (pathname === "/") return true; if (pathname === "/extensions" || pathname.startsWith("/extensions/")) return true; return false; } function readSidebarCollapsed() { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === "1"; } catch { return false; } } export type ViewMode = "month" | "week" | "day"; interface CalendarContextValue { selectedDate: Date; setSelectedDate: (date: Date) => void; viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; peopleSearchOpen: boolean; setPeopleSearchOpen: (open: boolean) => void; addCalendarOpen: boolean; setAddCalendarOpen: (open: boolean) => void; addCalendarDefaultTab: "people" | "url"; setAddCalendarDefaultTab: (tab: "people" | "url") => void; hiddenCalendars: ReturnType["hidden"]; toggleHiddenCalendar: ReturnType["toggle"]; isHiddenCalendar: ReturnType["isHidden"]; /** Whether to show event details in sidebar instead of popover */ eventDetailSidebar: boolean; setEventDetailSidebar: (sidebar: boolean) => void; /** The currently selected event for the sidebar panel */ sidebarEvent: CalendarEvent | null; setSidebarEvent: (event: CalendarEvent | null) => void; /** The last-clicked/focused event (for keyboard shortcuts like Delete) */ focusedEvent: CalendarEvent | null; setFocusedEvent: (event: CalendarEvent | null) => void; /** The currently open unsent event draft, if any */ eventDraft: CalendarEventDraft | null; setEventDraft: (draft: CalendarEventDraft | null) => void; openSidebar: () => void; } /** * Setter-only slice. Every value here is a stable function reference (state * setters, or callbacks with empty/near-empty dep arrays), so this context's * value object only needs to be rebuilt when AppLayout itself remounts. * Consumers that only dispatch changes (not read current values) should read * from this context so they don't re-render on every focus/selection change. */ interface CalendarSettersValue { setSelectedDate: (date: Date) => void; setViewMode: (mode: ViewMode) => void; setPeopleSearchOpen: (open: boolean) => void; setAddCalendarOpen: (open: boolean) => void; setAddCalendarDefaultTab: (tab: "people" | "url") => void; toggleHiddenCalendar: ReturnType["toggle"]; setEventDetailSidebar: (sidebar: boolean) => void; setSidebarEvent: (event: CalendarEvent | null) => void; setFocusedEvent: (event: CalendarEvent | null) => void; setEventDraft: (draft: CalendarEventDraft | null) => void; openSidebar: () => void; } /** Values that change on navigation/preference actions, not on every event interaction. */ interface CalendarRareValuesContextValue { selectedDate: Date; viewMode: ViewMode; peopleSearchOpen: boolean; addCalendarOpen: boolean; addCalendarDefaultTab: "people" | "url"; hiddenCalendars: ReturnType["hidden"]; isHiddenCalendar: ReturnType["isHidden"]; eventDetailSidebar: boolean; } /** Values that change on nearly every event click/drag — kept separate so * rare-value and setter-only consumers don't re-render alongside them. */ interface CalendarHighFrequencyContextValue { sidebarEvent: CalendarEvent | null; focusedEvent: CalendarEvent | null; eventDraft: CalendarEventDraft | null; } const noopSetters: CalendarSettersValue = { setSelectedDate: () => {}, setViewMode: () => {}, setPeopleSearchOpen: () => {}, setAddCalendarOpen: () => {}, setAddCalendarDefaultTab: () => {}, toggleHiddenCalendar: () => {}, setEventDetailSidebar: () => {}, setSidebarEvent: () => {}, setFocusedEvent: () => {}, setEventDraft: () => {}, openSidebar: () => {}, }; const CalendarSettersContext = createContext(noopSetters); const CalendarRareValuesContext = createContext( { selectedDate: new Date(), viewMode: "week", peopleSearchOpen: false, addCalendarOpen: false, addCalendarDefaultTab: "people", hiddenCalendars: { people: [], external: [], accounts: [] }, isHiddenCalendar: () => false, eventDetailSidebar: false, }, ); const CalendarHighFrequencyContext = createContext({ sidebarEvent: null, focusedEvent: null, eventDraft: null, }); /** * Full merged calendar context, for consumers that need a mix of setters and * values. Prefer the narrower `useCalendarSetters`, `useCalendarRareValues`, * or `useCalendarHighFrequency` hooks in render-hot components so they only * re-render for the slice they actually read. */ export function useCalendarContext(): CalendarContextValue { const setters = useContext(CalendarSettersContext); const rare = useContext(CalendarRareValuesContext); const highFrequency = useContext(CalendarHighFrequencyContext); return { ...setters, ...rare, ...highFrequency }; } /** Setter-only slice — safe for components that dispatch but never read focus/selection state. */ export function useCalendarSetters(): CalendarSettersValue { return useContext(CalendarSettersContext); } /** Rarely-changing slice (view mode, hidden calendars, selected date, dialogs). */ export function useCalendarRareValues(): CalendarRareValuesContextValue { return useContext(CalendarRareValuesContext); } /** High-frequency slice (focused/sidebar event, in-progress draft). */ export function useCalendarHighFrequency(): CalendarHighFrequencyContextValue { return useContext(CalendarHighFrequencyContext); } type HeaderControls = { left?: ReactNode; right?: ReactNode; }; const HeaderControlsContext = createContext< (controls: HeaderControls | null) => void >(() => {}); export function useAppHeaderControls(controls: HeaderControls | null) { const setHeaderControls = useContext(HeaderControlsContext); useEffect(() => { setHeaderControls(controls); return () => setHeaderControls(null); }, [controls, setHeaderControls]); } function NavigationSync() { useNavigationState(); useAppearanceSync(); return null; } interface AppLayoutProps { children: ReactNode; } export function AppLayout({ children }: AppLayoutProps) { const t = useT(); const isMobile = useIsMobile(); const location = useLocation(); const queryClient = useQueryClient(); const googleStatus = useGoogleAuthStatus(); const hasAccounts = (googleStatus.data?.accounts?.length ?? 0) > 0; const isSettingsPage = location.pathname === "/settings"; const isCalendarPage = location.pathname === "/"; const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed); const [selectedDate, setSelectedDate] = useState(new Date()); const [viewMode, setViewMode] = useState(isMobile ? "day" : "week"); const [peopleSearchOpen, setPeopleSearchOpen] = useState(false); const [addCalendarOpen, setAddCalendarOpen] = useState(false); const [addCalendarDefaultTab, setAddCalendarDefaultTab] = useState< "people" | "url" >("people"); const { hidden: hiddenCalendars, toggle: toggleHiddenCalendar, isHidden: isHiddenCalendar, } = useHiddenCalendars(); const [eventDetailSidebar, setEventDetailSidebarState] = useState(false); const [sidebarEvent, setSidebarEvent] = useState(null); const [focusedEvent, setFocusedEvent] = useState(null); const [eventDraft, setEventDraft] = useState(null); const [headerControls, setHeaderControls] = useState( null, ); const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false); // Load preference from localStorage useEffect(() => { try { const saved = localStorage.getItem(EVENT_DETAIL_MODE_KEY); if (saved === "sidebar") setEventDetailSidebarState(true); } catch {} }, []); useEffect(() => { if (!hasAccounts) return; void prefetchPeopleContacts(queryClient); }, [hasAccounts, queryClient]); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_COLLAPSE_KEY, sidebarCollapsed ? "1" : "0", ); } catch { // Ignore storage failures; the in-memory preference still works. } }, [sidebarCollapsed]); // Global keyboard-shortcuts help: opens via `?` (or shift+/) or the sidebar // button on any page, not just the calendar view. Calendar-specific shortcuts // (j/k/c/etc.) still live in CalendarView. useEffect(() => { const openShortcuts = () => setShortcutsHelpOpen(true); window.addEventListener("calendar:open-shortcuts", openShortcuts); function handleKey(e: KeyboardEvent) { const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) { return; } if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === "?" || (e.key === "/" && e.shiftKey)) { e.preventDefault(); setShortcutsHelpOpen(true); } } window.addEventListener("keydown", handleKey); return () => { window.removeEventListener("calendar:open-shortcuts", openShortcuts); window.removeEventListener("keydown", handleKey); }; }, []); const setEventDetailSidebar = useCallback((sidebar: boolean) => { setEventDetailSidebarState(sidebar); try { localStorage.setItem( EVENT_DETAIL_MODE_KEY, sidebar ? "sidebar" : "popover", ); } catch {} }, []); const openSidebar = useCallback(() => setSidebarOpen(true), []); const settersValue = useMemo( () => ({ setSelectedDate, setViewMode, setPeopleSearchOpen, setAddCalendarOpen, setAddCalendarDefaultTab, toggleHiddenCalendar, setEventDetailSidebar, setSidebarEvent, setFocusedEvent, setEventDraft, openSidebar, }), [toggleHiddenCalendar, setEventDetailSidebar, openSidebar], ); const rareValuesValue = useMemo( () => ({ selectedDate, viewMode, peopleSearchOpen, addCalendarOpen, addCalendarDefaultTab, hiddenCalendars, isHiddenCalendar, eventDetailSidebar, }), [ selectedDate, viewMode, peopleSearchOpen, addCalendarOpen, addCalendarDefaultTab, hiddenCalendars, isHiddenCalendar, eventDetailSidebar, ], ); const highFrequencyValue = useMemo( () => ({ sidebarEvent, focusedEvent, eventDraft }), [sidebarEvent, focusedEvent, eventDraft], ); // Render chromeless for embed/preview routes — all hooks must be called above if (BARE_ROUTES.has(location.pathname)) { return <>{children}; } return ( setShortcutsHelpOpen(false)} />
setSidebarOpen(false)} collapsed={!isMobile && sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} />
{!pageOwnsToolbar(location.pathname) && (
{headerControls?.left ?? ( {t("navigation.calendar")} )}
{headerControls?.right}
)} {/* Show the full-page Google prompt only on the calendar view. */} {!googleStatus.isLoading && !googleStatus.isError && !hasAccounts && !eventDraft && isCalendarPage && !isSettingsPage ? (
) : (
{children}
)}
); }