Files
CodePilot/src/components/layout/AppShell.tsx
T

476 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { usePathname } 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 { ErrorBoundary } from "./ErrorBoundary";
import { getActiveSessionIds, getSnapshot } from "@/lib/stream-session-manager";
const EMPTY_SET = new Set<string>();
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 [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";
const isChatDetailRoute = pathname.startsWith("/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<PanelContent>("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<Set<string>>(EMPTY_SET);
const [pendingApprovalSessionIds, setPendingApprovalSessionIds] = useState<Set<string>>(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<string>();
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);
}, []);
// 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<string | null>(null);
const [previewViewMode, setPreviewViewMode] = useState<PreviewViewMode>("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<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const isNativeUpdater = typeof window !== "undefined" && !!window.electronAPI?.updater;
// --- Browser-mode update check (fallback) ---
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);
}
}, []);
// --- Electron native updater check ---
const checkForUpdatesNative = useCallback(async () => {
setChecking(true);
setUpdateInfo((prev) => prev ? { ...prev, lastError: null } : prev);
try {
await window.electronAPI?.updater?.checkForUpdates();
} catch {
setChecking(false);
}
}, []);
const checkForUpdates = isNativeUpdater ? checkForUpdatesNative : checkForUpdatesBrowser;
// Subscribe to native updater IPC events
useEffect(() => {
if (!isNativeUpdater) return;
const unsubscribe = window.electronAPI!.updater!.onStatus((event) => {
switch (event.status) {
case 'checking':
setChecking(true);
break;
case 'available':
setChecking(false);
setUpdateInfo((prev) => {
const releaseNotes = typeof event.info?.releaseNotes === 'string'
? event.info.releaseNotes
: '';
const newInfo: UpdateInfo = {
updateAvailable: true,
latestVersion: event.info?.version ?? '',
currentVersion: prev?.currentVersion ?? (process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'),
releaseName: event.info?.releaseName ?? `v${event.info?.version}`,
releaseNotes,
releaseUrl: `https://github.com/op7418/CodePilot/releases/tag/v${event.info?.version}`,
publishedAt: event.info?.releaseDate ?? '',
downloadProgress: prev?.downloadProgress ?? null,
readyToInstall: prev?.readyToInstall ?? false,
isNativeUpdate: true,
lastError: null,
};
return newInfo;
});
// Show dialog if not dismissed
if (event.info?.version) {
const dismissed = localStorage.getItem(DISMISSED_VERSION_KEY);
if (dismissed !== event.info.version) {
setShowDialog(true);
}
}
break;
case 'not-available':
setChecking(false);
break;
case 'downloading':
setUpdateInfo((prev) => prev ? {
...prev,
downloadProgress: event.progress?.percent ?? null,
lastError: null,
} : prev);
break;
case 'downloaded':
setUpdateInfo((prev) => {
if (!prev) return prev;
return {
...prev,
readyToInstall: true,
downloadProgress: 100,
lastError: null,
};
});
break;
case 'error':
setChecking(false);
// Reset download progress so the download button re-appears
setUpdateInfo((prev) => prev ? {
...prev,
downloadProgress: null,
lastError: event.error ?? 'Update failed',
} : prev);
console.warn('[updater] Error:', event.error);
break;
}
});
return unsubscribe;
}, [isNativeUpdater]);
// Browser mode: check on mount + every 8 hours
useEffect(() => {
if (isNativeUpdater) return;
checkForUpdatesBrowser();
const id = setInterval(checkForUpdatesBrowser, CHECK_INTERVAL);
return () => clearInterval(id);
}, [isNativeUpdater, checkForUpdatesBrowser]);
const dismissUpdate = useCallback(() => {
setShowDialog(false);
}, []);
const downloadUpdate = useCallback(async () => {
// Immediately show downloading state so user gets feedback
setUpdateInfo((prev) => prev ? { ...prev, downloadProgress: 0, lastError: null } : prev);
try {
await window.electronAPI?.updater?.downloadUpdate();
} catch (err) {
console.warn('[updater] Download failed:', err);
const message = err instanceof Error ? err.message : String(err);
// Reset progress so the download button re-appears
setUpdateInfo((prev) => prev ? {
...prev,
downloadProgress: null,
lastError: message,
} : prev);
}
}, []);
const quitAndInstall = useCallback(() => {
window.electronAPI?.updater?.quitAndInstall();
}, []);
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 (
<UpdateContext.Provider value={updateContextValue}>
<PanelContext.Provider value={panelContextValue}>
<ImageGenContext.Provider value={imageGenValue}>
<BatchImageGenContext.Provider value={batchImageGenValue}>
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
<NavRail
chatListOpen={chatListOpen}
onToggleChatList={() => setChatListOpen(!chatListOpen)}
hasUpdate={updateInfo?.updateAvailable ?? false}
readyToInstall={updateInfo?.readyToInstall ?? false}
skipPermissionsActive={skipPermissionsActive}
/>
<ErrorBoundary>
<ChatListPanel open={chatListOpen} width={chatListWidth} />
</ErrorBoundary>
{chatListOpen && (
<ResizeHandle side="left" onResize={handleChatListResize} onResizeEnd={handleChatListResizeEnd} />
)}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Electron draggable title bar region — matches side panels' mt-5 */}
<div
className="h-5 w-full shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<UpdateBanner />
<main className="relative flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
{isChatDetailRoute && previewFile && (
<ResizeHandle side="right" onResize={handleDocPreviewResize} onResizeEnd={handleDocPreviewResizeEnd} />
)}
{isChatDetailRoute && previewFile && (
<ErrorBoundary>
<DocPreview
filePath={previewFile}
viewMode={previewViewMode}
onViewModeChange={setPreviewViewMode}
onClose={() => setPreviewFile(null)}
width={docPreviewWidth}
/>
</ErrorBoundary>
)}
{isChatDetailRoute && panelOpen && (
<ResizeHandle side="right" onResize={handleRightPanelResize} onResizeEnd={handleRightPanelResizeEnd} />
)}
{isChatDetailRoute && (
<ErrorBoundary>
<RightPanel width={rightPanelWidth} />
</ErrorBoundary>
)}
</div>
<UpdateDialog />
</TooltipProvider>
</BatchImageGenContext.Provider>
</ImageGenContext.Provider>
</PanelContext.Provider>
</UpdateContext.Provider>
);
}