"use client"; import { useState, useEffect, useCallback, useMemo } from "react"; import { usePathname, useRouter } from "next/navigation"; import { TooltipProvider } from "@/components/ui/tooltip"; import { NavRail } from "./NavRail"; import { ChatListPanel } from "./ChatListPanel"; import { RightPanel } from "./RightPanel"; import { ResizeHandle } from "./ResizeHandle"; import { UpdateDialog } from "./UpdateDialog"; import { UpdateBanner } from "./UpdateBanner"; import { DocPreview } from "./DocPreview"; import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel"; import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate"; import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen"; import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen"; import { SplitContext, type SplitSession } from "@/hooks/useSplit"; import { SplitChatContainer } from "./SplitChatContainer"; import { ErrorBoundary } from "./ErrorBoundary"; import { getActiveSessionIds, getSnapshot } from "@/lib/stream-session-manager"; const SPLIT_SESSIONS_KEY = "codepilot:split-sessions"; const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column"; function loadSplitSessions(): SplitSession[] { if (typeof window === "undefined") return []; try { const raw = localStorage.getItem(SPLIT_SESSIONS_KEY); if (raw) return JSON.parse(raw); } catch { // ignore } return []; } function saveSplitSessions(sessions: SplitSession[]) { if (sessions.length >= 2) { localStorage.setItem(SPLIT_SESSIONS_KEY, JSON.stringify(sessions)); } else { localStorage.removeItem(SPLIT_SESSIONS_KEY); localStorage.removeItem(SPLIT_ACTIVE_COLUMN_KEY); } } function loadActiveColumn(): string { if (typeof window === "undefined") return ""; return localStorage.getItem(SPLIT_ACTIVE_COLUMN_KEY) || ""; } const EMPTY_SET = new Set(); const CHATLIST_MIN = 180; const CHATLIST_MAX = 400; const RIGHTPANEL_MIN = 200; const RIGHTPANEL_MAX = 480; const DOCPREVIEW_MIN = 320; const DOCPREVIEW_MAX = 800; /** Extensions that default to "rendered" view mode */ const RENDERED_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]); function defaultViewMode(filePath: string): PreviewViewMode { const dot = filePath.lastIndexOf("."); const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : ""; return RENDERED_EXTENSIONS.has(ext) ? "rendered" : "source"; } const LG_BREAKPOINT = 1024; const CHECK_INTERVAL = 8 * 60 * 60 * 1000; // 8 hours const DISMISSED_VERSION_KEY = "codepilot_dismissed_update_version"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); const [chatListOpen, setChatListOpenRaw] = useState(false); // Panel width state with localStorage persistence const [chatListWidth, setChatListWidth] = useState(() => { if (typeof window === "undefined") return 240; return parseInt(localStorage.getItem("codepilot_chatlist_width") || "240"); }); const [rightPanelWidth, setRightPanelWidth] = useState(() => { if (typeof window === "undefined") return 288; return parseInt(localStorage.getItem("codepilot_rightpanel_width") || "288"); }); const handleChatListResize = useCallback((delta: number) => { setChatListWidth((w) => Math.min(CHATLIST_MAX, Math.max(CHATLIST_MIN, w + delta))); }, []); const handleChatListResizeEnd = useCallback(() => { setChatListWidth((w) => { localStorage.setItem("codepilot_chatlist_width", String(w)); return w; }); }, []); const handleRightPanelResize = useCallback((delta: number) => { setRightPanelWidth((w) => Math.min(RIGHTPANEL_MAX, Math.max(RIGHTPANEL_MIN, w - delta))); }, []); const handleRightPanelResizeEnd = useCallback(() => { setRightPanelWidth((w) => { localStorage.setItem("codepilot_rightpanel_width", String(w)); return w; }); }, []); // Panel state const isChatRoute = pathname.startsWith("/chat/") || pathname === "/chat"; // Auto-close chat list when leaving chat routes const setChatListOpen = useCallback((open: boolean) => { setChatListOpenRaw(open); }, []); useEffect(() => { if (!isChatRoute) { setChatListOpenRaw(false); } }, [isChatRoute]); const [panelOpen, setPanelOpenRaw] = useState(false); const [panelContent, setPanelContent] = useState("files"); const [workingDirectory, setWorkingDirectory] = useState(""); const [sessionId, setSessionId] = useState(""); const [sessionTitle, setSessionTitle] = useState(""); const [streamingSessionId, setStreamingSessionId] = useState(""); const [pendingApprovalSessionId, setPendingApprovalSessionId] = useState(""); // --- Multi-session stream tracking (driven by stream-session-manager) --- const [activeStreamingSessions, setActiveStreamingSessions] = useState>(EMPTY_SET); const [pendingApprovalSessionIds, setPendingApprovalSessionIds] = useState>(EMPTY_SET); // Listen for global stream events from stream-session-manager useEffect(() => { const handler = () => { const activeIds = getActiveSessionIds(); setActiveStreamingSessions(activeIds.length > 0 ? new Set(activeIds) : EMPTY_SET); const approvals = new Set(); for (const sid of activeIds) { const snap = getSnapshot(sid); if (snap?.pendingPermission && !snap.permissionResolved) { approvals.add(sid); } } setPendingApprovalSessionIds(approvals.size > 0 ? approvals : EMPTY_SET); }; window.addEventListener('stream-session-event', handler); return () => window.removeEventListener('stream-session-event', handler); }, []); // --- Split-screen state --- const [splitSessions, setSplitSessions] = useState(() => loadSplitSessions()); const [activeColumnId, setActiveColumnIdRaw] = useState(() => loadActiveColumn()); const isSplitActive = splitSessions.length >= 2; const isChatDetailRoute = pathname.startsWith("/chat/") || isSplitActive; // Persist split sessions to localStorage useEffect(() => { saveSplitSessions(splitSessions); if (activeColumnId) { localStorage.setItem(SPLIT_ACTIVE_COLUMN_KEY, activeColumnId); } }, [splitSessions, activeColumnId]); // URL sync: when activeColumn changes, update router useEffect(() => { if (isSplitActive && activeColumnId) { const target = `/chat/${activeColumnId}`; if (pathname !== target) { router.replace(target); } } }, [isSplitActive, activeColumnId, pathname, router]); const setActiveColumn = useCallback((sessionId: string) => { setActiveColumnIdRaw(sessionId); }, []); const addToSplit = useCallback((session: SplitSession) => { setSplitSessions((prev) => { // If already in split, don't add again if (prev.some((s) => s.sessionId === session.sessionId)) return prev; if (prev.length < 2) { // First time entering split: add current active session + new session // The current session info comes from PanelContext const currentSessionId = sessionId; if (currentSessionId && currentSessionId !== session.sessionId) { const currentSession: SplitSession = { sessionId: currentSessionId, title: sessionTitle || "New Conversation", workingDirectory: workingDirectory || "", projectName: "", mode: "code", }; // Check if current is already in the list const hasCurrentAlready = prev.some((s) => s.sessionId === currentSessionId); const next = hasCurrentAlready ? [...prev, session] : [...prev, currentSession, session]; setActiveColumnIdRaw(session.sessionId); return next; } } // Append to existing split const next = [...prev, session]; setActiveColumnIdRaw(session.sessionId); return next; }); }, [sessionId, sessionTitle, workingDirectory]); const removeFromSplit = useCallback((removeId: string) => { setSplitSessions((prev) => { const next = prev.filter((s) => s.sessionId !== removeId); if (next.length <= 1) { // Exit split mode if (next.length === 1) { // Navigate to the remaining session router.replace(`/chat/${next[0].sessionId}`); } return []; } // If removing active column, switch to first remaining setActiveColumnIdRaw((currentActive) => currentActive === removeId ? next[0].sessionId : currentActive ); return next; }); }, [router]); const exitSplit = useCallback(() => { const firstSession = splitSessions[0]; setSplitSessions([]); setActiveColumnIdRaw(""); if (firstSession) { router.replace(`/chat/${firstSession.sessionId}`); } }, [splitSessions, router]); const isInSplit = useCallback((sid: string) => { return splitSessions.some((s) => s.sessionId === sid); }, [splitSessions]); // Handle delete of a session that's in split useEffect(() => { const handler = () => { // Re-validate split sessions exist setSplitSessions((prev) => { // We don't remove here; deletion handler in ChatListPanel will call removeFromSplit return prev; }); }; window.addEventListener("session-deleted", handler); return () => window.removeEventListener("session-deleted", handler); }, []); // Exit split when navigating to non-chat routes useEffect(() => { if (isSplitActive && !pathname.startsWith("/chat")) { setSplitSessions([]); setActiveColumnIdRaw(""); } }, [isSplitActive, pathname]); const splitContextValue = useMemo( () => ({ splitSessions, activeColumnId, isSplitActive, addToSplit, removeFromSplit, setActiveColumn, exitSplit, isInSplit, }), [splitSessions, activeColumnId, isSplitActive, addToSplit, removeFromSplit, setActiveColumn, exitSplit, isInSplit] ); // Warn before closing window/tab while any session is streaming useEffect(() => { if (activeStreamingSessions.size === 0) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler); }, [activeStreamingSessions]); // --- Doc Preview state --- const [previewFile, setPreviewFileRaw] = useState(null); const [previewViewMode, setPreviewViewMode] = useState("source"); const [docPreviewWidth, setDocPreviewWidth] = useState(() => { if (typeof window === "undefined") return 480; return parseInt(localStorage.getItem("codepilot_docpreview_width") || "480"); }); const setPreviewFile = useCallback((path: string | null) => { setPreviewFileRaw(path); if (path) { setPreviewViewMode(defaultViewMode(path)); } }, []); const handleDocPreviewResize = useCallback((delta: number) => { setDocPreviewWidth((w) => Math.min(DOCPREVIEW_MAX, Math.max(DOCPREVIEW_MIN, w - delta))); }, []); const handleDocPreviewResizeEnd = useCallback(() => { setDocPreviewWidth((w) => { localStorage.setItem("codepilot_docpreview_width", String(w)); return w; }); }, []); // Auto-open panel on chat detail routes, close on others // Also close doc preview when navigating away or switching sessions useEffect(() => { setPanelOpenRaw(isChatDetailRoute); setPreviewFileRaw(null); }, [isChatDetailRoute, pathname]); const setPanelOpen = useCallback((open: boolean) => { setPanelOpenRaw(open); }, []); // Keep chat list state in sync when resizing across the breakpoint (only on chat routes) useEffect(() => { if (!isChatRoute) return; const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`); const handler = (e: MediaQueryListEvent) => setChatListOpenRaw(e.matches); mql.addEventListener("change", handler); setChatListOpenRaw(mql.matches); return () => mql.removeEventListener("change", handler); }, [isChatRoute]); // --- Skip-permissions indicator --- const [skipPermissionsActive, setSkipPermissionsActive] = useState(false); const fetchSkipPermissions = useCallback(async () => { try { const res = await fetch("/api/settings/app"); if (res.ok) { const data = await res.json(); setSkipPermissionsActive(data.settings?.dangerously_skip_permissions === "true"); } } catch { // ignore } }, []); // Re-fetch when window gains focus / becomes visible instead of polling every 5s useEffect(() => { fetchSkipPermissions(); const handleVisibility = () => { if (document.visibilityState === "visible") { fetchSkipPermissions(); } }; document.addEventListener("visibilitychange", handleVisibility); window.addEventListener("focus", fetchSkipPermissions); return () => { document.removeEventListener("visibilitychange", handleVisibility); window.removeEventListener("focus", fetchSkipPermissions); }; }, [fetchSkipPermissions]); // --- Update check state --- const [updateInfo, setUpdateInfo] = useState(null); const [checking, setChecking] = useState(false); const [showDialog, setShowDialog] = useState(false); // Native auto-updater disabled — always use browser-mode (GitHub API) check. // Users are directed to download from GitHub Releases. // TODO: Re-enable native updater after obtaining Apple Developer certificate. const isNativeUpdater = false; // --- Browser-mode update check --- const checkForUpdatesBrowser = useCallback(async () => { setChecking(true); try { const res = await fetch("/api/app/updates"); if (!res.ok) return; const data = await res.json(); const info: UpdateInfo = { ...data, downloadProgress: null, readyToInstall: false, isNativeUpdate: false, lastError: null, }; setUpdateInfo(info); if (info.updateAvailable) { const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY); if (dismissed !== info.latestVersion) { setShowDialog(true); } } } catch { // silently ignore network errors } finally { setChecking(false); } }, []); // Native updater check disabled — always use browser-mode check const checkForUpdates = checkForUpdatesBrowser; // Browser mode: check on mount + every 8 hours useEffect(() => { checkForUpdatesBrowser(); const id = setInterval(checkForUpdatesBrowser, CHECK_INTERVAL); return () => clearInterval(id); }, [checkForUpdatesBrowser]); const dismissUpdate = useCallback(() => { setShowDialog(false); }, []); // Native download/install disabled — no-ops while native updater is disabled. // The UI shows "View Release" (opens GitHub) instead. const downloadUpdate = useCallback(async () => { // no-op: native updater disabled }, []); const quitAndInstall = useCallback(() => { // no-op: native updater disabled }, []); const updateContextValue = useMemo( () => ({ updateInfo, checking, checkForUpdates, downloadUpdate, dismissUpdate, showDialog, setShowDialog, quitAndInstall, }), [updateInfo, checking, checkForUpdates, downloadUpdate, dismissUpdate, showDialog, quitAndInstall] ); const panelContextValue = useMemo( () => ({ panelOpen, setPanelOpen, panelContent, setPanelContent, workingDirectory, setWorkingDirectory, sessionId, setSessionId, sessionTitle, setSessionTitle, streamingSessionId, setStreamingSessionId, pendingApprovalSessionId, setPendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, previewFile, setPreviewFile, previewViewMode, setPreviewViewMode, }), [panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, previewFile, setPreviewFile, previewViewMode] ); const imageGenValue = useImageGenState(); const batchImageGenValue = useBatchImageGenState(); return (
setChatListOpen(!chatListOpen)} hasUpdate={updateInfo?.updateAvailable ?? false} readyToInstall={updateInfo?.readyToInstall ?? false} skipPermissionsActive={skipPermissionsActive} /> {chatListOpen && ( )}
{/* Electron draggable title bar region — matches side panels' mt-5 */}
{isSplitActive ? ( ) : ( {children} )}
{isChatDetailRoute && previewFile && ( )} {isChatDetailRoute && previewFile && ( setPreviewFile(null)} width={docPreviewWidth} /> )} {isChatDetailRoute && panelOpen && ( )} {isChatDetailRoute && ( )}
); }