Files
qiming/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/SessionsPage.tsx

427 lines
14 KiB
TypeScript

/**
* 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<string | undefined>();
const webviewRef = useRef<HTMLElement | null>(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 <webview> don't bubble to the host renderer page.
// ---------- Sessions ----------
const [sessions, setSessions] = useState<DetailedSession[]>([]);
const [loading, setLoading] = useState(true);
const [stoppingSessions, setStoppingSessions] = useState<Set<string>>(
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 <Tag color="processing">{t("Claw.Sessions.statusActive")}</Tag>;
case "pending":
return <Tag color="warning">{t("Claw.Sessions.statusPending")}</Tag>;
case "terminating":
return <Tag color="error">{t("Claw.Sessions.statusTerminating")}</Tag>;
case "idle":
default:
return <Tag>{t("Claw.Sessions.statusIdle")}</Tag>;
}
};
const getEngineTag = (engineType: DetailedSession["engineType"]) => {
return engineType === "claude-code" ? (
<Tag color="blue">{t("Claw.Sessions.engine01")}</Tag>
) : (
<Tag color="purple">{t("Claw.Sessions.engine02")}</Tag>
);
};
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 (
<div className={styles.webviewFullscreen}>
<webview
ref={webviewRef as any}
src={webviewUrl}
useragent={webviewUA}
style={{ flex: 1, width: "100%", border: "none" }}
allowpopups={"true" as any}
/>
</div>
);
}
// View A: Session list
return (
<div className={styles.page}>
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<TeamOutlined
style={{ fontSize: 14, color: "var(--color-text-secondary)" }}
/>
<span className={styles.toolbarTitle}>
{t("Claw.Sessions.title")}
</span>
{sessions.length > 0 && (
<Tag style={{ margin: 0, fontSize: 11 }}>{sessions.length}</Tag>
)}
</div>
<div className={styles.toolbarActions}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={fetchSessions}
>
{t("Claw.Sessions.refresh")}
</Button>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={handleNewSession}
>
{t("Claw.Sessions.newSession")}
</Button>
</div>
</div>
{/* Session list or empty state */}
{loading ? (
<div className={styles.emptyState}>
<Spin size="default" />
</div>
) : sessions.length === 0 ? (
<div className={styles.emptyState}>
<TeamOutlined className={styles.emptyIcon} />
<span>{t("Claw.Sessions.noActiveSessions")}</span>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleNewSession}
>
{t("Claw.Sessions.newSession")}
</Button>
</div>
) : (
<div className={styles.sessionList}>
{sessions.map((session) => {
const isStopping = stoppingSessions.has(session.id);
return (
<div key={session.id} className={styles.sessionRow}>
<div className={styles.sessionInfo}>
<span className={styles.sessionTitle}>
{session.title || session.id.substring(0, 12)}
</span>
<div className={styles.sessionMeta}>
{getEngineTag(session.engineType)}
{getStatusTag(session.status)}
<span>{formatTime(session.createdAt)}</span>
{session.lastActivity && (
<span>
{t("Claw.Sessions.lastActivity")}:{" "}
{formatTime(session.lastActivity)}
</span>
)}
</div>
</div>
<div className={styles.sessionActions}>
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => handleOpenSession(session.projectId || "")}
>
{t("Claw.Sessions.open")}
</Button>
<Button
size="small"
danger
icon={<StopOutlined />}
loading={isStopping}
onClick={() => handleStopSession(session.id)}
>
{t("Claw.Sessions.stop")}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export default SessionsPage;