macOS 上 ad-hoc 签名无法通过 ShipIt 的 kSecCSStrictValidate 校验, 导致热更新始终失败。在获取 Apple 开发者证书之前,暂时禁用 native auto-updater,改为 browser-mode 检查 GitHub Releases API: - electron/updater.ts: 注释掉 electron-updater 逻辑,initAutoUpdater 和 setUpdaterWindow 改为 no-op - electron/preload.ts: 注释掉 updater IPC bridge,前端不再暴露 window.electronAPI.updater - AppShell.tsx: isNativeUpdater 硬编码为 false,移除 native IPC 订阅 和 native check/download/quitAndInstall 逻辑;保留 browser-mode 的 GitHub API 检查(mount 时 + 每 8 小时),发现新版本时弹窗引导 用户跳转 GitHub Release 页面下载 设置页 UpdateCard 保持不变:「检查更新」按钮通过 /api/app/updates 查询 GitHub,有新版本时显示「查看发布」按钮跳转下载。 TODO: 获取 Apple 开发者证书后,取消注释恢复 native auto-updater。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
"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<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 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<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);
|
|
}, []);
|
|
|
|
// --- Split-screen state ---
|
|
const [splitSessions, setSplitSessions] = useState<SplitSession[]>(() => loadSplitSessions());
|
|
const [activeColumnId, setActiveColumnIdRaw] = useState<string>(() => 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<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);
|
|
|
|
// 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 (
|
|
<UpdateContext.Provider value={updateContextValue}>
|
|
<PanelContext.Provider value={panelContextValue}>
|
|
<SplitContext.Provider value={splitContextValue}>
|
|
<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">
|
|
{isSplitActive ? (
|
|
<SplitChatContainer />
|
|
) : (
|
|
<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>
|
|
</SplitContext.Provider>
|
|
</PanelContext.Provider>
|
|
</UpdateContext.Provider>
|
|
);
|
|
}
|