fix(chat): add history loading skeletons
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-06-10 17:51:28 +08:00
parent ab9e2a0420
commit 9c0a7a2864
6 changed files with 254 additions and 57 deletions
@@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>); render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHistoryPanel", () => { describe("AgentHistoryPanel", () => {
it("shows skeleton rows while history sessions are loading", () => {
renderWithTheme(
<AgentHistoryPanel
sessions={[]}
isLoadingSessions
onNewSession={jest.fn()}
onRenameSession={jest.fn()}
onSelectSession={jest.fn()}
onDeleteSession={jest.fn()}
/>,
);
expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument();
expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument();
});
it("disables the loading history session item", () => {
const onSelectSession = jest.fn();
const onRenameSession = jest.fn();
const onDeleteSession = jest.fn();
renderWithTheme(
<AgentHistoryPanel
sessions={[
{
id: "session-loading",
title: "正在加载的会话",
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
loadingSessionId="session-loading"
onNewSession={jest.fn()}
onRenameSession={onRenameSession}
onSelectSession={onSelectSession}
onDeleteSession={onDeleteSession}
/>,
);
expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话"));
expect(onSelectSession).not.toHaveBeenCalled();
expect(onRenameSession).not.toHaveBeenCalled();
expect(onDeleteSession).not.toHaveBeenCalled();
});
it("renames a history session from the list", () => { it("renames a history session from the list", () => {
const onRenameSession = jest.fn(); const onRenameSession = jest.fn();
+62 -11
View File
@@ -13,6 +13,7 @@ import {
Divider, Divider,
IconButton, IconButton,
Paper, Paper,
Skeleton,
Stack, Stack,
TextField, TextField,
Tooltip, Tooltip,
@@ -34,9 +35,11 @@ type AgentHistoryPanelProps = {
sessions: ChatSessionSummary[]; sessions: ChatSessionSummary[];
activeSessionId?: string; activeSessionId?: string;
isHydrating?: boolean; isHydrating?: boolean;
isLoadingSessions?: boolean;
loadingSessionId?: string;
onNewSession: () => void; onNewSession: () => void;
onRenameSession: (sessionId: string, title: string) => void; onRenameSession: (sessionId: string, title: string) => void;
onSelectSession: (sessionId: string) => void; onSelectSession: (sessionId: string, title: string) => void;
onDeleteSession: (sessionId: string) => void; onDeleteSession: (sessionId: string) => void;
}; };
@@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({
sessions, sessions,
activeSessionId, activeSessionId,
isHydrating = false, isHydrating = false,
isLoadingSessions = false,
loadingSessionId,
onNewSession, onNewSession,
onRenameSession, onRenameSession,
onSelectSession, onSelectSession,
@@ -127,6 +132,30 @@ export const AgentHistoryPanel = ({
(session) => session.id === pendingDeleteSessionId, (session) => session.id === pendingDeleteSessionId,
); );
const renderSessionListSkeleton = () => (
<Stack spacing={1} aria-label="正在加载历史会话">
{Array.from({ length: 6 }, (_, index) => (
<Paper
key={index}
elevation={0}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
bgcolor: alpha("#fff", 0.48),
border: `1px solid ${alpha("#fff", 0.68)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`,
}}
>
<Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}>
<Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} />
<Skeleton variant="text" width="32%" height={14} />
</Stack>
</Paper>
))}
</Stack>
);
const handleStartRename = (sessionId: string, title: string) => { const handleStartRename = (sessionId: string, title: string) => {
setEditingSessionId(sessionId); setEditingSessionId(sessionId);
setDraftTitle(title); setDraftTitle(title);
@@ -215,7 +244,9 @@ export const AgentHistoryPanel = ({
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} /> <Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}> <Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
{sessions.length === 0 ? ( {isLoadingSessions ? (
renderSessionListSkeleton()
) : sessions.length === 0 ? (
<Stack <Stack
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
@@ -271,27 +302,42 @@ export const AgentHistoryPanel = ({
<Stack spacing={1}> <Stack spacing={1}>
{groupSessions.map((session) => { {groupSessions.map((session) => {
const isActive = session.id === activeSessionId; const isActive = session.id === activeSessionId;
const isLoading = session.id === loadingSessionId;
return ( return (
<Paper <Paper
key={session.id} key={session.id}
elevation={0} elevation={0}
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
onClick={() => { onClick={() => {
if (editingSessionId === session.id) return; if (editingSessionId === session.id || isLoading) return;
onSelectSession(session.id); onSelectSession(session.id, session.title);
}} }}
sx={{ sx={{
px: 1.25, px: 1.25,
py: 1, py: 1,
borderRadius: 3, borderRadius: 3,
cursor: isHydrating ? "default" : "pointer", cursor: isHydrating || isLoading ? "default" : "pointer",
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56), bgcolor:
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`, isActive || isLoading
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`, ? alpha("#00acc1", 0.12)
: alpha("#fff", 0.56),
border: `1px solid ${
isActive || isLoading
? alpha("#00acc1", 0.25)
: alpha("#fff", 0.72)
}`,
boxShadow:
isActive || isLoading
? `0 8px 20px ${alpha("#00acc1", 0.12)}`
: `0 4px 12px ${alpha("#000", 0.03)}`,
transition: "all 0.2s ease", transition: "all 0.2s ease",
pointerEvents: isHydrating ? "none" : "auto", pointerEvents: isHydrating || isLoading ? "none" : "auto",
"&:hover": { "&:hover": {
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86), bgcolor:
isActive || isLoading
? alpha("#00acc1", 0.14)
: alpha("#fff", 0.86),
borderColor: alpha("#00acc1", 0.2), borderColor: alpha("#00acc1", 0.2),
}, },
}} }}
@@ -382,6 +428,11 @@ export const AgentHistoryPanel = ({
<CloseRounded sx={{ fontSize: 16 }} /> <CloseRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Stack> </Stack>
) : isLoading ? (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Skeleton variant="text" width="74%" height={18} />
<Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} />
</Box>
) : pendingDeleteSessionId === session.id ? ( ) : pendingDeleteSessionId === session.id ? (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}> <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
<Box <Box
@@ -437,7 +488,7 @@ export const AgentHistoryPanel = ({
)} )}
</Box> </Box>
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && ( {!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
<Stack direction="row" spacing={0.25}> <Stack direction="row" spacing={0.25}>
<Tooltip title="修改会话标题"> <Tooltip title="修改会话标题">
<span> <span>
+15 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import React from "react"; import React from "react";
import { render } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
@@ -51,6 +51,20 @@ describe("AgentWorkspace", () => {
renderCounts.clear(); renderCounts.clear();
}); });
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
render(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
isLoadingSession
messages={[]}
/>,
);
expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument();
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => { it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = { const userMessage: Message = {
id: "user-1", id: "user-1",
+110 -40
View File
@@ -3,7 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import React from "react"; import React from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
@@ -20,6 +20,7 @@ import type {
type AgentWorkspaceProps = { type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
isStreaming: boolean; isStreaming: boolean;
isLoadingSession?: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
@@ -226,9 +227,72 @@ const EmptyState = () => {
); );
}; };
const SessionLoadingSkeleton = () => (
<Stack
spacing={2.25}
aria-label="正在加载历史记录"
sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }}
>
{Array.from({ length: 2 }, (_, turnIndex) => (
<Stack key={turnIndex} spacing={1.25}>
<Stack direction="row" justifyContent="flex-end">
<Paper
elevation={0}
sx={{
width: turnIndex === 0 ? "72%" : "64%",
maxWidth: "86%",
p: 1.75,
borderRadius: 5,
borderBottomRightRadius: 2,
bgcolor: alpha("#00acc1", 0.16),
border: `1px solid ${alpha("#00acc1", 0.12)}`,
boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`,
}}
>
<Stack spacing={0.85}>
<Skeleton variant="text" width="76%" height={18} />
<Skeleton variant="text" width="48%" height={15} />
</Stack>
</Paper>
</Stack>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Skeleton
variant="circular"
width={34}
height={34}
sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }}
/>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 2,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.52),
border: `1px solid ${alpha("#fff", 0.72)}`,
boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`,
}}
>
<Stack spacing={1}>
<Skeleton variant="text" width="38%" height={16} />
<Skeleton variant="text" width="94%" height={16} />
<Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} />
<Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} />
<Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} />
</Stack>
</Paper>
</Stack>
</Stack>
))}
</Stack>
);
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
isStreaming, isStreaming,
isLoadingSession = false,
bottomRef, bottomRef,
speakingMessageId, speakingMessageId,
speechState, speechState,
@@ -270,49 +334,55 @@ export const AgentWorkspace = ({
zIndex: 5, zIndex: 5,
}} }}
> >
<AnimatePresence initial={false}> {isLoadingSession ? (
{messages.length === 0 ? <EmptyState /> : null} <SessionLoadingSkeleton />
</AnimatePresence> ) : (
<>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
</AnimatePresence>
{messages.length > 0 ? ( {messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList <TurnList
messages={historyMessages} messages={historyMessages}
isStreaming={isStreaming} isStreaming={isStreaming}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech} onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech} onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch} onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission} onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion} onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion} onRejectQuestion={onRejectQuestion}
/> />
{streamingMessage ? ( {streamingMessage ? (
<TurnList <TurnList
messages={[streamingMessage]} messages={[streamingMessage]}
isStreaming={isStreaming} isStreaming={isStreaming}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech} onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech} onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch} onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission} onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion} onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion} onRejectQuestion={onRejectQuestion}
/> />
) : null}
</Box>
) : null} ) : null}
</Box> </>
) : null} )}
{showTypingIndicator ? ( {!isLoadingSession && showTypingIndicator ? (
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }} initial={{ opacity: 0, y: 10, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
+8 -4
View File
@@ -68,6 +68,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
chatSessions, chatSessions,
activeSessionId, activeSessionId,
isHydrating, isHydrating,
loadingSessionId,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sendPrompt, sendPrompt,
@@ -159,9 +160,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, []); }, []);
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
(sessionId: string) => { (sessionId: string, title: string) => {
composerRef.current?.clear(); composerRef.current?.clear();
void switchSession(sessionId); void switchSession(sessionId, title);
}, },
[switchSession], [switchSession],
); );
@@ -326,12 +327,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
sessions={chatSessions} sessions={chatSessions}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
isHydrating={isHydrating} isHydrating={isHydrating}
isLoadingSessions={isHydrating && chatSessions.length === 0}
loadingSessionId={loadingSessionId}
onNewSession={() => { onNewSession={() => {
handleNewConversation(); handleNewConversation();
setIsHistoryOpen(false); setIsHistoryOpen(false);
}} }}
onSelectSession={(id) => { onSelectSession={(id, title) => {
handleSelectSession(id); handleSelectSession(id, title);
setIsHistoryOpen(false); setIsHistoryOpen(false);
}} }}
onRenameSession={handleRenameSession} onRenameSession={handleRenameSession}
@@ -343,6 +346,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentWorkspace <AgentWorkspace
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
isLoadingSession={Boolean(loadingSessionId)}
bottomRef={bottomRef} bottomRef={bottomRef}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
@@ -27,6 +27,7 @@ export const useAgentChatSession = ({
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true); const [isHydrating, setIsHydrating] = useState(true);
const [loadingSessionId, setLoadingSessionId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined); const sessionIdRef = useRef<string | undefined>(undefined);
const messagesRef = useRef<Message[]>([]); const messagesRef = useRef<Message[]>([]);
@@ -756,12 +757,17 @@ export const useAgentChatSession = ({
}, [isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
const switchSession = useCallback( const switchSession = useCallback(
async (nextSessionId: string) => { async (nextSessionId: string, optimisticTitle?: string) => {
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) { if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
return; return;
} }
setIsHydrating(true); setIsHydrating(true);
setLoadingSessionId(nextSessionId);
const nextTitle = optimisticTitle?.trim();
if (nextTitle) {
setSessionTitle(nextTitle);
}
try { try {
const [nextState, sessions] = await Promise.all([ const [nextState, sessions] = await Promise.all([
loadChatSessionById(nextSessionId), loadChatSessionById(nextSessionId),
@@ -785,6 +791,7 @@ export const useAgentChatSession = ({
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to switch chat session:", error); console.error("[GlobalChatbox] Failed to switch chat session:", error);
} finally { } finally {
setLoadingSessionId(undefined);
setIsHydrating(false); setIsHydrating(false);
} }
}, },
@@ -932,6 +939,7 @@ export const useAgentChatSession = ({
chatSessions, chatSessions,
activeSessionId: sessionIdRef.current, activeSessionId: sessionIdRef.current,
isHydrating, isHydrating,
loadingSessionId,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sessionId, sessionId,