diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index a5d801f13f..25512ed689 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,14 +1,13 @@ /* eslint-disable ts/no-explicit-any */ import type { ReactNode } from 'react' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' const mockFetchChatMessages = vi.fn() const mockUpdateLogMessageFeedbacks = vi.fn() const mockUpdateLogMessageAnnotations = vi.fn() -const mockPush = vi.fn() -const mockReplace = vi.fn() const mockOnRefresh = vi.fn() const mockSetCurrentLogItem = vi.fn() const mockSetShowPromptLogModal = vi.fn() @@ -17,7 +16,6 @@ const mockSetShowMessageLogModal = vi.fn() const mockCompletionRefetch = vi.fn() const mockDelAnnotation = vi.fn() -let mockSearchParams = new URLSearchParams() let mockChatConversationDetail: Record | undefined let mockCompletionConversationDetail: Record | undefined let mockShowMessageLogModal = false @@ -53,18 +51,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({ }, })) -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - replace: mockReplace, - }), - usePathname: () => '/apps/app-1/logs', - useSearchParams: () => ({ - get: (key: string) => mockSearchParams.get(key), - toString: () => mockSearchParams.toString(), - }), -})) - vi.mock('@/service/use-log', () => ({ useChatConversationDetail: () => ({ data: mockChatConversationDetail, @@ -256,10 +242,28 @@ const createChatMessage = (id: string, overrides: Record = {}) ...overrides, }) +const renderConversationList = ({ + appDetail = { id: 'app-1', mode: AppModeEnum.CHAT } as any, + logs = createLogs() as any, + searchParams = '?page=2', +}: { + appDetail?: any + logs?: any + searchParams?: string +} = {}) => { + return renderWithNuqs( + , + { searchParams }, + ) +} + describe('ConversationList', () => { beforeEach(() => { vi.clearAllMocks() - mockSearchParams = new URLSearchParams('page=2') mockChatConversationDetail = undefined mockCompletionConversationDetail = undefined mockShowMessageLogModal = false @@ -273,34 +277,29 @@ describe('ConversationList', () => { }) }) - it('should render chat rows and push the conversation id into the url when a row is clicked', () => { - render( - , - ) + it('should render chat rows and push the conversation id into the url when a row is clicked', async () => { + const { onUrlUpdate } = renderConversationList() expect(screen.getByText('hello world')).toBeInTheDocument() expect(screen.getAllByText('formatted-1710000000')).toHaveLength(2) fireEvent.click(screen.getByText('hello world')) - expect(mockPush).toHaveBeenCalledWith('/apps/app-1/logs?page=2&conversation_id=conversation-1', { scroll: false }) - expect(screen.getByTestId('drawer')).toBeInTheDocument() + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.get('conversation_id')).toBe('conversation-1') + expect(update.options.history).toBe('push') }) - it('should close the drawer, refresh, and clear modal flags', () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') - - render( - , - ) + it('should close the drawer, refresh, and clear modal flags', async () => { + const { onUrlUpdate } = renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) fireEvent.click(screen.getByText('close-drawer')) @@ -308,11 +307,18 @@ describe('ConversationList', () => { expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) - expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalled() + }) + + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('page')).toBe('2') + expect(update.searchParams.has('conversation_id')).toBe(false) + expect(update.options.history).toBe('replace') }) it('should render chat conversation details and submit feedback from the chat panel', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -355,13 +361,9 @@ describe('ConversationList', () => { mockShowMessageLogModal = true mockCurrentLogItem = { id: 'log-1' } - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(mockFetchChatMessages).toHaveBeenCalledWith({ @@ -396,7 +398,6 @@ describe('ConversationList', () => { }) it('should render completion details and refetch after feedback updates', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -423,13 +424,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('text-generation')).toBeInTheDocument() @@ -454,64 +453,61 @@ describe('ConversationList', () => { }) it('should render chatflow status cells and feedback counters for advanced chat logs', () => { - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } as any, + logs: { + data: [ + { + id: 'conversation-pending', + name: 'Pending row', + from_account_name: 'user-a', + read_at: 1710000001, + message_count: 3, + status_count: { paused: 1, success: 0, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 2, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-success', + name: 'Success row', + from_account_name: 'user-b', + read_at: 1710000001, + message_count: 4, + status_count: { paused: 0, success: 4, failed: 0, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-partial', + name: 'Partial row', + from_account_name: 'user-c', + read_at: 1710000001, + message_count: 5, + status_count: { paused: 0, success: 3, failed: 0, partial_success: 1 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + { + id: 'conversation-failure', + name: 'Failure row', + from_account_name: 'user-d', + read_at: 1710000001, + message_count: 1, + status_count: { paused: 0, success: 0, failed: 2, partial_success: 0 }, + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 0 }, + updated_at: 1710000000, + created_at: 1710000000, + }, + ], + } as any, + }) expect(screen.getByText('Pending')).toBeInTheDocument() expect(screen.getByText('Success')).toBeInTheDocument() @@ -522,7 +518,6 @@ describe('ConversationList', () => { }) it('should support annotation changes, modal closing, and paginated scroll loading in the detail drawer', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -568,13 +563,9 @@ describe('ConversationList', () => { has_more: false, }) - render( - , - ) + renderConversationList({ + searchParams: '?page=2&conversation_id=conversation-1', + }) await waitFor(() => { expect(screen.getByTestId('chat-panel')).toBeInTheDocument() @@ -609,7 +600,6 @@ describe('ConversationList', () => { }) it('should close the prompt log modal from completion detail drawers', async () => { - mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') mockCompletionConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -636,13 +626,11 @@ describe('ConversationList', () => { mockShowPromptLogModal = true mockCurrentLogItem = { id: 'log-2' } - render( - , - ) + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, + logs: createCompletionLogs() as any, + searchParams: '?page=2&conversation_id=conversation-1', + }) expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument() diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 79323d34ab..01621e0d2a 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -13,6 +13,7 @@ import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { noop } from 'es-toolkit/function' +import { parseAsString, useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -33,7 +34,6 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' @@ -46,7 +46,6 @@ import { applyAnnotationEdited, applyAnnotationRemoved, buildChatThreadState, - buildConversationUrl, getCompletionMessageFiles, getConversationRowValues, getDetailVarList, @@ -674,10 +673,7 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string } const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined + const [conversationIdInUrl, setConversationIdInUrl] = useQueryState('conversation_id', parseAsString) const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -697,8 +693,6 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - const buildUrlWithConversation = useCallback((conversationId?: string) => buildConversationUrl(pathname, searchParams.toString(), conversationId), [pathname, searchParams]) - const handleRowClick = useCallback((log: ConversationListItem) => { if (conversationIdInUrl === log.id) { if (!showDrawer) @@ -717,8 +711,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (currentConversation?.id !== log.id) setCurrentConversation(undefined) - router.push(buildUrlWithConversation(log.id), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) + void setConversationIdInUrl(log.id, { history: 'push' }) + }, [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer]) const currentConversationId = currentConversation?.id @@ -755,7 +749,7 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) pendingConversationCacheRef.current = undefined - }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) + }, [conversationIdInUrl, currentConversation, currentConversationId, logs?.data, showDrawer]) const onCloseDrawer = useCallback(() => { onRefresh() @@ -769,8 +763,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) closingConversationIdRef.current = conversationIdInUrl ?? null if (conversationIdInUrl) - router.replace(buildUrlWithConversation(), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + void setConversationIdInUrl(null, { history: 'replace' }) + }, [conversationIdInUrl, onRefresh, setConversationIdInUrl, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => {