/** * SessionsPage - 会话管理页面 * * 两个视图: * A. 会话列表 - 展示所有活跃会话,支持打开/停止 * B. 内嵌 webview - 在主窗口内展示会话页面 */ import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button, Tag, message, Spin } from "antd"; import { PlusOutlined, ReloadOutlined, TeamOutlined, PlayCircleOutlined, StopOutlined, } from "@ant-design/icons"; import { syncCookieAndGetRedirectUrl, syncCookieAndGetNewSessionUrl, syncCookieAndGetChatUrl, persistTicketCookie, normalizeLocalBackendRedirect, } from "../../services/utils/sessionUrl"; import { logger } from "../../services/utils/logService"; import { APP_DISPLAY_NAME } from "@shared/constants"; import { t } from "../../services/core/i18n"; import type { DetailedSession } from "@shared/types/sessions"; import styles from "../../styles/components/SessionsPage.module.css"; export interface WebviewHeaderActions { onBack: () => void; onReload: () => void; } interface SessionsPageProps { /** When true, automatically open webview on mount (used by "开始会话" button). */ autoOpen?: boolean; /** Called after autoOpen has been consumed, so it doesn't re-trigger. */ onAutoOpenConsumed?: () => void; /** Notify parent when entering/leaving webview mode (for hiding sidebar/logo). */ onWebviewChange?: (actions: WebviewHeaderActions | null) => void; } function SessionsPage({ autoOpen, onAutoOpenConsumed, onWebviewChange, }: SessionsPageProps) { // ---------- View state ---------- const [view, setView] = useState<"list" | "webview">("list"); const [webviewUrl, setWebviewUrl] = useState(""); const [webviewUA, setWebviewUA] = useState(); const webviewRef = useRef(null); // Build custom user agent with app version useEffect(() => { window.electronAPI?.app .getVersion() .then((version) => { const ua = navigator.userAgent + ` ${APP_DISPLAY_NAME}/${version}`; setWebviewUA(ua); }) .catch(() => {}); }, []); // Debug: log webview navigation events to track login redirects useEffect(() => { const el = webviewRef.current as any; if (!el || view !== "webview") return; const onDidNavigate = (e: any) => { const url: string = e.url || "(unknown)"; const isLogin = url.includes("/login"); const level = isLogin ? "warn" : "info"; logger[level]( `[SessionsPage][WebviewNav] did-navigate${isLogin ? " ⚠️ LOGIN DETECTED" : ""}`, "SessionsPage", { url, httpCode: e.httpResponseCode, isLogin }, ); // webview 登录成功后(从 /login 跳到非 login 页面),持久化 ticket cookie if (!isLogin && url.startsWith("http")) { try { const origin = new URL(url).origin; persistTicketCookie(origin).catch(() => {}); } catch { // URL 解析失败,忽略 } } }; const onWillRedirect = (e: any) => { const normalizedUrl = normalizeLocalBackendRedirect( e.newURL, webviewUrl, ); if (normalizedUrl) { logger.warn( "[SessionsPage][WebviewNav] normalized local backend redirect", "SessionsPage", { from: e.oldURL, to: e.newURL, normalizedUrl, }, ); e.preventDefault?.(); el.loadURL?.(normalizedUrl); return; } logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", { from: e.oldURL, to: e.newURL, }); }; const onDidFailLoad = (e: any) => { logger.warn("[SessionsPage][WebviewNav] did-fail-load", "SessionsPage", { url: e.validatedURL || e.url, errorCode: e.errorCode, errorDescription: e.errorDescription, }); }; el.addEventListener("did-navigate", onDidNavigate); el.addEventListener("did-navigate-in-page", onDidNavigate); el.addEventListener("will-redirect", onWillRedirect); el.addEventListener("did-fail-load", onDidFailLoad); return () => { el.removeEventListener("did-navigate", onDidNavigate); el.removeEventListener("did-navigate-in-page", onDidNavigate); el.removeEventListener("will-redirect", onWillRedirect); el.removeEventListener("did-fail-load", onDidFailLoad); }; }, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps // Notify parent when entering/leaving webview useEffect(() => { if (view === "webview") { onWebviewChange?.({ onBack: () => { setView("list"); setWebviewUrl(""); fetchSessions(); }, onReload: () => { (webviewRef.current as any)?.reload?.(); }, }); } else { onWebviewChange?.(null); } return () => { onWebviewChange?.(null); }; }, [view]); // eslint-disable-line react-hooks/exhaustive-deps // Note: Ctrl/Cmd+Shift+I for webview DevTools is handled in the main process // (webviewPolicy.ts) via before-input-event, because keyboard events inside // a don't bubble to the host renderer page. // ---------- Sessions ---------- const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [stoppingSessions, setStoppingSessions] = useState>( new Set(), ); // ======================== Data fetching ======================== const fetchSessions = useCallback(async () => { try { const result = await window.electronAPI?.agent.listSessionsDetailed(); if (result?.success && Array.isArray(result.data)) { setSessions(result.data); } } catch (error) { console.error("[SessionsPage] fetchSessions failed:", error); } finally { setLoading(false); } }, []); // Poll sessions every 3s when in list view useEffect(() => { if (view !== "list") return; fetchSessions(); const timer = setInterval(fetchSessions, 3000); return () => clearInterval(timer); }, [fetchSessions, view]); // ======================== Actions ======================== const handleOpenWebview = useCallback(async () => { try { const url = await syncCookieAndGetRedirectUrl(); if (!url) { message.warning(t("Claw.Sessions.loginFirst")); return; } setWebviewUrl(url); setView("webview"); } catch (error) { console.error("[SessionsPage] syncCookieAndGetUrl failed:", error); message.error(t("Claw.Sessions.getSessionUrlFailed")); } }, []); const handleNewSession = useCallback(async () => { try { const url = await syncCookieAndGetNewSessionUrl(); if (!url) { message.warning(t("Claw.Sessions.loginFirst")); return; } setWebviewUrl(url); setView("webview"); } catch (error) { console.error( "[SessionsPage] syncCookieAndGetNewSessionUrl failed:", error, ); message.error(t("Claw.Sessions.getSessionUrlFailed")); } }, []); const handleOpenSession = useCallback(async (sessionId: string) => { try { const url = await syncCookieAndGetChatUrl(sessionId); if (!url) { message.warning(t("Claw.Sessions.loginFirst")); return; } setWebviewUrl(url); setView("webview"); } catch (error) { console.error("[SessionsPage] syncCookieAndGetChatUrl failed:", error); message.error(t("Claw.Sessions.getSessionUrlFailed")); } }, []); // Auto-open webview when navigated from "开始会话" useEffect(() => { if (autoOpen) { onAutoOpenConsumed?.(); handleOpenWebview(); } }, [autoOpen, handleOpenWebview, onAutoOpenConsumed]); const handleStopSession = useCallback( async (sessionId: string) => { setStoppingSessions((prev) => new Set(prev).add(sessionId)); try { const result = await window.electronAPI?.agent.stopSession(sessionId); if (result?.success) { message.success(t("Claw.Sessions.sessionStopped")); await fetchSessions(); } else { message.error(t("Claw.Sessions.stopSessionFailed")); } } catch (error) { console.error("[SessionsPage] stopSession failed:", error); message.error(t("Claw.Sessions.stopSessionFailed")); } finally { setStoppingSessions((prev) => { const next = new Set(prev); next.delete(sessionId); return next; }); } }, [fetchSessions], ); // ======================== Render helpers ======================== const getStatusTag = (status: DetailedSession["status"]) => { switch (status) { case "active": return {t("Claw.Sessions.statusActive")}; case "pending": return {t("Claw.Sessions.statusPending")}; case "terminating": return {t("Claw.Sessions.statusTerminating")}; case "idle": default: return {t("Claw.Sessions.statusIdle")}; } }; const getEngineTag = (engineType: DetailedSession["engineType"]) => { return engineType === "claude-code" ? ( {t("Claw.Sessions.engine01")} ) : ( {t("Claw.Sessions.engine02")} ); }; const formatTime = (ts: number) => { const d = new Date(ts); const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; }; // ======================== Render ======================== // View B: Embedded webview (toolbar is in the app header via onWebviewChange) if (view === "webview" && webviewUrl) { return (
); } // View A: Session list return (
{/* Toolbar */}
{t("Claw.Sessions.title")} {sessions.length > 0 && ( {sessions.length} )}
{/* Session list or empty state */} {loading ? (
) : sessions.length === 0 ? (
{t("Claw.Sessions.noActiveSessions")}
) : (
{sessions.map((session) => { const isStopping = stoppingSessions.has(session.id); return (
{session.title || session.id.substring(0, 12)}
{getEngineTag(session.engineType)} {getStatusTag(session.status)} {formatTime(session.createdAt)} {session.lastActivity && ( {t("Claw.Sessions.lastActivity")}:{" "} {formatTime(session.lastActivity)} )}
); })}
)}
); } export default SessionsPage;