diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx new file mode 100644 index 0000000..235eac9 --- /dev/null +++ b/src/components/chat/GlobalChatbox.test.tsx @@ -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: () =>
Agent header
, +})); + +jest.mock("./AgentHistoryPanel", () => ({ + AgentHistoryPanel: () =>
History
, +})); + +jest.mock("./AgentWorkspace", () => ({ + AgentWorkspace: () =>
Workspace
, +})); + +jest.mock("./AgentComposer", () => ({ + AgentComposer: React.forwardRef(function MockAgentComposer() { + return
Composer
; + }), +})); + +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(); + + act(() => jest.runOnlyPendingTimers()); + expect(createSession).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("agent-workspace")).toBeInTheDocument(); + + rerender(); + act(() => jest.advanceTimersByTime(300)); + + expect(screen.getByTestId("agent-workspace")).toBeInTheDocument(); + + rerender(); + act(() => jest.runOnlyPendingTimers()); + + expect(createSession).toHaveBeenCalledTimes(1); + + mockCurrentProjectId = "project-2"; + rerender(); + act(() => jest.runOnlyPendingTimers()); + + expect(createSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index eeaae1c..6c0e51f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const isNearBottomRef = useRef(true); const streamingScrollFrameRef = useRef(null); const composerRef = useRef(null); - const hasResetForOpenRef = useRef(false); + const initializedProjectIdRef = useRef(undefined); const theme = useTheme(); const { open: openNotification } = useNotification(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { 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(() => { if (isStreaming) { if (!isNearBottomRef.current) return; @@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { ); useEffect(() => { - if (!open) { - hasResetForOpenRef.current = false; + if ( + !open || + isHydrating || + initializedProjectIdRef.current === currentProjectId + ) { return; } - if (hasResetForOpenRef.current || isHydrating) return; - hasResetForOpenRef.current = true; - const timer = window.setTimeout(() => { - createSession(); - composerRef.current?.clear(); - setIsHistoryOpen(false); - composerRef.current?.focus(); - isNearBottomRef.current = true; - cancelStreamingScroll(); - scrollToBottom("auto"); - }, 0); - return () => window.clearTimeout(timer); - }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]); + initializedProjectIdRef.current = currentProjectId; + createSession(); + resetConversationView(); + }, [createSession, currentProjectId, isHydrating, open, resetConversationView]); const handleSend = useCallback(async (prompt: string) => { if (isStreaming || isCheckingAuth) return; @@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { handleStopSpeech(); stopListening(); createSession(); - composerRef.current?.clear(); - window.setTimeout(() => { - composerRef.current?.focus(); - isNearBottomRef.current = true; - cancelStreamingScroll(); - scrollToBottom("auto"); - }, 0); - }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]); + resetConversationView(); + }, [createSession, handleStopSpeech, resetConversationView, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { hideBackdrop disableScrollLock disableEnforceFocus + ModalProps={{ keepMounted: true }} sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, pointerEvents: "none",