Cache session replay on client

This commit is contained in:
wuyang6
2026-06-12 17:28:15 +08:00
parent ddf06c727d
commit 00a83ba4c3
4 changed files with 286 additions and 56 deletions
@@ -29,6 +29,12 @@ import {
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
invalidateCachedSessionReplay,
readCachedSessionReplay,
type SessionReplaySnapshot,
} from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
@@ -113,6 +119,27 @@ type ClawStoredToolCall = {
};
};
type ReplaySessionFn = (
sessionId: string,
repository: ExportedMessageRepository,
) => void;
type ClawReplaySnapshot = SessionReplaySnapshot<
ClawStoredSession,
ClawRunStatus
>;
function replaySessionSnapshot(
replaySession: ReplaySessionFn,
sessionId: string,
snapshot: ClawReplaySnapshot,
) {
replaySession(
sessionId,
toReplayRepository(snapshot.session, sessionId, snapshot.runStatus),
);
}
export const ThreadList: FC = () => {
return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
@@ -168,7 +195,7 @@ function ensureSidebarWorkspaceSessionId(): {
}
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string {
if (entry.label && entry.label.trim()) return entry.label.trim();
if (entry.label?.trim()) return entry.label.trim();
if (entry.base_url) {
try {
return new URL(entry.base_url).host;
@@ -243,16 +270,15 @@ const JupyterWorkspaceList: FC = () => {
window.dispatchEvent(new Event("claw-sessions-changed"));
window.dispatchEvent(new Event("claw-workspaces-changed"));
} catch (err) {
setError(
err instanceof Error ? err.message : "切换工作区时网络异常",
);
setError(err instanceof Error ? err.message : "切换工作区时网络异常");
} finally {
setBindingId(null);
}
};
const remove = async (entry: JupyterWorkspaceEntry) => {
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return;
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`))
return;
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
@@ -331,6 +357,8 @@ const ClawSessionList: FC = () => {
null,
);
const renameInputRef = useRef<HTMLInputElement | null>(null);
const openRequestRef = useRef(0);
const openAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
setActiveSessionId(readActiveSessionId());
@@ -394,6 +422,51 @@ const ClawSessionList: FC = () => {
setDraftTitle("");
};
const openSession = async (sessionId: string) => {
const requestId = openRequestRef.current + 1;
openRequestRef.current = requestId;
openAbortRef.current?.abort();
const abortController = new AbortController();
openAbortRef.current = abortController;
const cached = readCachedSessionReplay<ClawStoredSession, ClawRunStatus>(
sessionId,
);
writeActiveSessionId(sessionId);
pushSessionUrl(sessionId);
setActiveSessionId(sessionId);
if (cached) {
replaySessionSnapshot(replaySession, sessionId, cached);
setLoadingSessionId(null);
} else {
setLoadingSessionId(sessionId);
}
try {
const snapshot = await fetchSessionReplaySnapshot<
ClawStoredSession,
ClawRunStatus
>(sessionId, { signal: abortController.signal });
if (requestId !== openRequestRef.current) return;
if (readActiveSessionId() !== sessionId) return;
if (!snapshot) return;
if (!cached || snapshot.signature !== cached.signature) {
replaySessionSnapshot(replaySession, sessionId, snapshot);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
console.warn("[session-list] failed to open session", error);
}
} finally {
if (requestId === openRequestRef.current) {
setLoadingSessionId(null);
}
if (openAbortRef.current === abortController) {
openAbortRef.current = null;
}
}
};
return (
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="flex flex-col gap-1">
@@ -443,31 +516,7 @@ const ClawSessionList: FC = () => {
preview: session.preview,
});
setOpenMenuSessionId(null);
writeActiveSessionId(session.session_id);
pushSessionUrl(session.session_id);
setActiveSessionId(session.session_id);
setLoadingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
);
if (!response.ok) return;
const payload =
(await response.json()) as ClawStoredSession;
const runStatus = await fetchLatestRunStatus(
session.session_id,
);
replaySession(
session.session_id,
toReplayRepository(
payload,
session.session_id,
runStatus,
),
);
} finally {
setLoadingSessionId(null);
}
await openSession(session.session_id);
}}
>
<span className="truncate">
@@ -525,6 +574,7 @@ const ClawSessionList: FC = () => {
(item) => item.session_id !== session.session_id,
),
);
invalidateCachedSessionReplay(session.session_id);
setOpenMenuSessionId(null);
if (activeSessionId === session.session_id) {
clearActiveSessionId();
@@ -14,12 +14,12 @@ import {
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ClawAccount } from "@/app/claw-account-gate";
import { ClawLlmSettings } from "@/app/claw-llm-settings";
import { ClawThemeSwitcher } from "@/app/claw-theme-switcher";
import {
fetchLatestRunStatus,
type ClawRunStatus,
ThreadList,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
@@ -49,6 +49,10 @@ import {
readActiveSessionId,
writeActiveSessionId,
} from "@/lib/claw-active-session";
import {
fetchSessionReplaySnapshot,
readCachedSessionReplay,
} from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
@@ -384,6 +388,8 @@ function SessionResultButton({
function useSidebarSessions(open: boolean) {
const [sessions, setSessions] = useState<SidebarSession[]>([]);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const openRequestRef = useRef(0);
const openAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!open) return;
@@ -417,22 +423,54 @@ function useSidebarSessions(open: boolean) {
) {
const cleanSessionId = normalizeSessionId(sessionId);
if (!cleanSessionId) return;
const requestId = openRequestRef.current + 1;
openRequestRef.current = requestId;
openAbortRef.current?.abort();
const abortController = new AbortController();
openAbortRef.current = abortController;
const cached = readCachedSessionReplay<ReplaySessionPayload, ClawRunStatus>(
cleanSessionId,
);
writeActiveSessionId(cleanSessionId);
pushSessionUrl(cleanSessionId);
setLoadingSessionId(sessionId);
try {
const response = await fetch(`/api/claw/sessions/${cleanSessionId}`, {
cache: "no-store",
});
if (!response.ok) return;
const payload = (await response.json()) as ReplaySessionPayload;
const runStatus = await fetchLatestRunStatus(cleanSessionId);
if (cached) {
replaySession(
cleanSessionId,
toReplayRepository(payload, cleanSessionId, runStatus),
toReplayRepository(cached.session, cleanSessionId, cached.runStatus),
);
} finally {
setLoadingSessionId(null);
} else {
setLoadingSessionId(sessionId);
}
try {
const snapshot = await fetchSessionReplaySnapshot<
ReplaySessionPayload,
ClawRunStatus
>(cleanSessionId, { signal: abortController.signal });
if (requestId !== openRequestRef.current) return;
if (readActiveSessionId() !== cleanSessionId) return;
if (!snapshot) return;
if (!cached || snapshot.signature !== cached.signature) {
replaySession(
cleanSessionId,
toReplayRepository(
snapshot.session,
cleanSessionId,
snapshot.runStatus,
),
);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
console.warn("[sidebar] failed to open session", error);
}
} finally {
if (requestId === openRequestRef.current) {
setLoadingSessionId(null);
}
if (openAbortRef.current === abortController) {
openAbortRef.current = null;
}
}
}