Files
CodePilot/src/components/layout/AppShell.tsx
T
7418andClaude Opus 4.6 ad34fe387b chore: 禁用 native auto-updater,改为引导用户从 GitHub 下载
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>
2026-02-28 11:45:36 +08:00

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>
);
}