476 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|