fix(chat): add history loading skeletons
This commit is contained in:
@@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||
|
||||
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", () => {
|
||||
const onRenameSession = jest.fn();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Divider,
|
||||
IconButton,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -34,9 +35,11 @@ type AgentHistoryPanelProps = {
|
||||
sessions: ChatSessionSummary[];
|
||||
activeSessionId?: string;
|
||||
isHydrating?: boolean;
|
||||
isLoadingSessions?: boolean;
|
||||
loadingSessionId?: string;
|
||||
onNewSession: () => void;
|
||||
onRenameSession: (sessionId: string, title: string) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onSelectSession: (sessionId: string, title: string) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
@@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
isHydrating = false,
|
||||
isLoadingSessions = false,
|
||||
loadingSessionId,
|
||||
onNewSession,
|
||||
onRenameSession,
|
||||
onSelectSession,
|
||||
@@ -127,6 +132,30 @@ export const AgentHistoryPanel = ({
|
||||
(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) => {
|
||||
setEditingSessionId(sessionId);
|
||||
setDraftTitle(title);
|
||||
@@ -215,7 +244,9 @@ export const AgentHistoryPanel = ({
|
||||
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
||||
{sessions.length === 0 ? (
|
||||
{isLoadingSessions ? (
|
||||
renderSessionListSkeleton()
|
||||
) : sessions.length === 0 ? (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
@@ -271,27 +302,42 @@ export const AgentHistoryPanel = ({
|
||||
<Stack spacing={1}>
|
||||
{groupSessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isLoading = session.id === loadingSessionId;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={session.id}
|
||||
elevation={0}
|
||||
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
|
||||
onClick={() => {
|
||||
if (editingSessionId === session.id) return;
|
||||
onSelectSession(session.id);
|
||||
if (editingSessionId === session.id || isLoading) return;
|
||||
onSelectSession(session.id, session.title);
|
||||
}}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: 3,
|
||||
cursor: isHydrating ? "default" : "pointer",
|
||||
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
|
||||
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
|
||||
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||
cursor: isHydrating || isLoading ? "default" : "pointer",
|
||||
bgcolor:
|
||||
isActive || isLoading
|
||||
? 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",
|
||||
pointerEvents: isHydrating ? "none" : "auto",
|
||||
pointerEvents: isHydrating || isLoading ? "none" : "auto",
|
||||
"&: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),
|
||||
},
|
||||
}}
|
||||
@@ -382,6 +428,11 @@ export const AgentHistoryPanel = ({
|
||||
<CloseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</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 ? (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
||||
<Box
|
||||
@@ -437,7 +488,7 @@ export const AgentHistoryPanel = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
|
||||
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
|
||||
<Stack direction="row" spacing={0.25}>
|
||||
<Tooltip title="修改会话标题">
|
||||
<span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
@@ -51,6 +51,20 @@ describe("AgentWorkspace", () => {
|
||||
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", () => {
|
||||
const userMessage: Message = {
|
||||
id: "user-1",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
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 SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
isLoadingSession?: boolean;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
speakingMessageId: string | null;
|
||||
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 = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingSession = false,
|
||||
bottomRef,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
@@ -270,6 +334,10 @@ export const AgentWorkspace = ({
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{isLoadingSession ? (
|
||||
<SessionLoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.length === 0 ? <EmptyState /> : null}
|
||||
</AnimatePresence>
|
||||
@@ -311,8 +379,10 @@ export const AgentWorkspace = ({
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTypingIndicator ? (
|
||||
{!isLoadingSession && showTypingIndicator ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
|
||||
@@ -68,6 +68,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
chatSessions,
|
||||
activeSessionId,
|
||||
isHydrating,
|
||||
loadingSessionId,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sendPrompt,
|
||||
@@ -159,9 +160,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
}, []);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
(sessionId: string, title: string) => {
|
||||
composerRef.current?.clear();
|
||||
void switchSession(sessionId);
|
||||
void switchSession(sessionId, title);
|
||||
},
|
||||
[switchSession],
|
||||
);
|
||||
@@ -326,12 +327,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
sessions={chatSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
isHydrating={isHydrating}
|
||||
isLoadingSessions={isHydrating && chatSessions.length === 0}
|
||||
loadingSessionId={loadingSessionId}
|
||||
onNewSession={() => {
|
||||
handleNewConversation();
|
||||
setIsHistoryOpen(false);
|
||||
}}
|
||||
onSelectSession={(id) => {
|
||||
handleSelectSession(id);
|
||||
onSelectSession={(id, title) => {
|
||||
handleSelectSession(id, title);
|
||||
setIsHistoryOpen(false);
|
||||
}}
|
||||
onRenameSession={handleRenameSession}
|
||||
@@ -343,6 +346,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
<AgentWorkspace
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
isLoadingSession={Boolean(loadingSessionId)}
|
||||
bottomRef={bottomRef}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const useAgentChatSession = ({
|
||||
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isHydrating, setIsHydrating] = useState(true);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | undefined>(undefined);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
@@ -756,12 +757,17 @@ export const useAgentChatSession = ({
|
||||
}, [isHydrating, isStreaming]);
|
||||
|
||||
const switchSession = useCallback(
|
||||
async (nextSessionId: string) => {
|
||||
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsHydrating(true);
|
||||
setLoadingSessionId(nextSessionId);
|
||||
const nextTitle = optimisticTitle?.trim();
|
||||
if (nextTitle) {
|
||||
setSessionTitle(nextTitle);
|
||||
}
|
||||
try {
|
||||
const [nextState, sessions] = await Promise.all([
|
||||
loadChatSessionById(nextSessionId),
|
||||
@@ -785,6 +791,7 @@ export const useAgentChatSession = ({
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to switch chat session:", error);
|
||||
} finally {
|
||||
setLoadingSessionId(undefined);
|
||||
setIsHydrating(false);
|
||||
}
|
||||
},
|
||||
@@ -932,6 +939,7 @@ export const useAgentChatSession = ({
|
||||
chatSessions,
|
||||
activeSessionId: sessionIdRef.current,
|
||||
isHydrating,
|
||||
loadingSessionId,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sessionId,
|
||||
|
||||
Reference in New Issue
Block a user