fix(chat): preserve agent UI across close
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { GlobalChatbox } from "./GlobalChatbox";
|
||||||
|
|
||||||
|
const createSession = jest.fn();
|
||||||
|
let mockCurrentProjectId = "project-1";
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatModels", () => ({
|
||||||
|
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/projectStore", () => ({
|
||||||
|
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
|
||||||
|
selector({ currentProjectId: mockCurrentProjectId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./globalChatboxVoice", () => ({
|
||||||
|
useSpeechSynthesis: () => ({
|
||||||
|
speechState: "idle",
|
||||||
|
speakingMessageId: null,
|
||||||
|
speak: jest.fn(),
|
||||||
|
pause: jest.fn(),
|
||||||
|
resume: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
useSpeechRecognition: () => ({
|
||||||
|
isListening: false,
|
||||||
|
start: jest.fn(),
|
||||||
|
stop: jest.fn(),
|
||||||
|
isSupported: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentToolActions", () => ({
|
||||||
|
useAgentToolActions: () => jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./hooks/useAgentChatSession", () => ({
|
||||||
|
useAgentChatSession: () => ({
|
||||||
|
messages: [],
|
||||||
|
chatSessions: [],
|
||||||
|
activeSessionId: undefined,
|
||||||
|
isHydrating: false,
|
||||||
|
loadingSessionId: null,
|
||||||
|
isStreaming: false,
|
||||||
|
sessionTitle: "新会话",
|
||||||
|
sendPrompt: jest.fn(),
|
||||||
|
createBranch: jest.fn(),
|
||||||
|
abort: jest.fn(),
|
||||||
|
replyPermission: jest.fn(),
|
||||||
|
replyQuestion: jest.fn(),
|
||||||
|
rejectQuestion: jest.fn(),
|
||||||
|
createSession,
|
||||||
|
renameSession: jest.fn(),
|
||||||
|
removeSession: jest.fn(),
|
||||||
|
switchSession: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHeader", () => ({
|
||||||
|
AgentHeader: () => <div>Agent header</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentHistoryPanel", () => ({
|
||||||
|
AgentHistoryPanel: () => <div>History</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentWorkspace", () => ({
|
||||||
|
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentComposer", () => ({
|
||||||
|
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
||||||
|
return <div>Composer</div>;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatboxParts", () => ({
|
||||||
|
Blob: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GlobalChatbox lifecycle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
createSession.mockClear();
|
||||||
|
mockCurrentProjectId = "project-1";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||||
|
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
|
||||||
|
act(() => jest.advanceTimersByTime(300));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockCurrentProjectId = "project-2";
|
||||||
|
rerender(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
act(() => jest.runOnlyPendingTimers());
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
const streamingScrollFrameRef = useRef<number | null>(null);
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { open: openNotification } = useNotification();
|
const { open: openNotification } = useNotification();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
@@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isNearBottomRef.current = isNearBottom;
|
isNearBottomRef.current = isNearBottom;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resetConversationView = useCallback(() => {
|
||||||
|
composerRef.current?.clear();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("auto");
|
||||||
|
}, 0);
|
||||||
|
}, [cancelStreamingScroll, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
if (!isNearBottomRef.current) return;
|
if (!isNearBottomRef.current) return;
|
||||||
@@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (
|
||||||
hasResetForOpenRef.current = false;
|
!open ||
|
||||||
|
isHydrating ||
|
||||||
|
initializedProjectIdRef.current === currentProjectId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasResetForOpenRef.current || isHydrating) return;
|
|
||||||
hasResetForOpenRef.current = true;
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
initializedProjectIdRef.current = currentProjectId;
|
||||||
createSession();
|
createSession();
|
||||||
composerRef.current?.clear();
|
resetConversationView();
|
||||||
setIsHistoryOpen(false);
|
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||||
composerRef.current?.focus();
|
|
||||||
isNearBottomRef.current = true;
|
|
||||||
cancelStreamingScroll();
|
|
||||||
scrollToBottom("auto");
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(async (prompt: string) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming || isCheckingAuth) return;
|
if (isStreaming || isCheckingAuth) return;
|
||||||
@@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
createSession();
|
createSession();
|
||||||
composerRef.current?.clear();
|
resetConversationView();
|
||||||
window.setTimeout(() => {
|
}, [createSession, handleStopSpeech, resetConversationView, stopListening]);
|
||||||
composerRef.current?.focus();
|
|
||||||
isNearBottomRef.current = true;
|
|
||||||
cancelStreamingScroll();
|
|
||||||
scrollToBottom("auto");
|
|
||||||
}, 0);
|
|
||||||
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
@@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
hideBackdrop
|
hideBackdrop
|
||||||
disableScrollLock
|
disableScrollLock
|
||||||
disableEnforceFocus
|
disableEnforceFocus
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
|||||||
Reference in New Issue
Block a user