fix(web): use nuqs for log conversation url state (#34820)

This commit is contained in:
yyh 2026-04-09 14:09:27 +08:00 committed by GitHub
parent 4c05316a7b
commit 8225f98565
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 141 deletions

View File

@ -1,14 +1,13 @@
/* eslint-disable ts/no-explicit-any */ /* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react' 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 { AppModeEnum } from '@/types/app'
import ConversationList from '../list' import ConversationList from '../list'
const mockFetchChatMessages = vi.fn() const mockFetchChatMessages = vi.fn()
const mockUpdateLogMessageFeedbacks = vi.fn() const mockUpdateLogMessageFeedbacks = vi.fn()
const mockUpdateLogMessageAnnotations = vi.fn() const mockUpdateLogMessageAnnotations = vi.fn()
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockOnRefresh = vi.fn() const mockOnRefresh = vi.fn()
const mockSetCurrentLogItem = vi.fn() const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn() const mockSetShowPromptLogModal = vi.fn()
@ -17,7 +16,6 @@ const mockSetShowMessageLogModal = vi.fn()
const mockCompletionRefetch = vi.fn() const mockCompletionRefetch = vi.fn()
const mockDelAnnotation = vi.fn() const mockDelAnnotation = vi.fn()
let mockSearchParams = new URLSearchParams()
let mockChatConversationDetail: Record<string, unknown> | undefined let mockChatConversationDetail: Record<string, unknown> | undefined
let mockCompletionConversationDetail: Record<string, unknown> | undefined let mockCompletionConversationDetail: Record<string, unknown> | undefined
let mockShowMessageLogModal = false 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', () => ({ vi.mock('@/service/use-log', () => ({
useChatConversationDetail: () => ({ useChatConversationDetail: () => ({
data: mockChatConversationDetail, data: mockChatConversationDetail,
@ -256,10 +242,28 @@ const createChatMessage = (id: string, overrides: Record<string, unknown> = {})
...overrides, ...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(
<ConversationList
appDetail={appDetail}
logs={logs}
onRefresh={mockOnRefresh}
/>,
{ searchParams },
)
}
describe('ConversationList', () => { describe('ConversationList', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockSearchParams = new URLSearchParams('page=2')
mockChatConversationDetail = undefined mockChatConversationDetail = undefined
mockCompletionConversationDetail = undefined mockCompletionConversationDetail = undefined
mockShowMessageLogModal = false 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', () => { it('should render chat rows and push the conversation id into the url when a row is clicked', async () => {
render( const { onUrlUpdate } = renderConversationList()
<ConversationList
appDetail={{ id: 'app-1', mode: AppModeEnum.CHAT } as any}
logs={createLogs() as any}
onRefresh={mockOnRefresh}
/>,
)
expect(screen.getByText('hello world')).toBeInTheDocument() expect(screen.getByText('hello world')).toBeInTheDocument()
expect(screen.getAllByText('formatted-1710000000')).toHaveLength(2) expect(screen.getAllByText('formatted-1710000000')).toHaveLength(2)
fireEvent.click(screen.getByText('hello world')) fireEvent.click(screen.getByText('hello world'))
expect(mockPush).toHaveBeenCalledWith('/apps/app-1/logs?page=2&conversation_id=conversation-1', { scroll: false }) await waitFor(() => {
expect(screen.getByTestId('drawer')).toBeInTheDocument() 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', () => { it('should close the drawer, refresh, and clear modal flags', async () => {
mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1') const { onUrlUpdate } = renderConversationList({
searchParams: '?page=2&conversation_id=conversation-1',
render( })
<ConversationList
appDetail={{ id: 'app-1', mode: AppModeEnum.CHAT } as any}
logs={createLogs() as any}
onRefresh={mockOnRefresh}
/>,
)
fireEvent.click(screen.getByText('close-drawer')) fireEvent.click(screen.getByText('close-drawer'))
@ -308,11 +307,18 @@ describe('ConversationList', () => {
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
expect(mockSetShowMessageLogModal).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 () => { it('should render chat conversation details and submit feedback from the chat panel', async () => {
mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1')
mockChatConversationDetail = { mockChatConversationDetail = {
id: 'conversation-1', id: 'conversation-1',
created_at: 1710000000, created_at: 1710000000,
@ -355,13 +361,9 @@ describe('ConversationList', () => {
mockShowMessageLogModal = true mockShowMessageLogModal = true
mockCurrentLogItem = { id: 'log-1' } mockCurrentLogItem = { id: 'log-1' }
render( renderConversationList({
<ConversationList searchParams: '?page=2&conversation_id=conversation-1',
appDetail={{ id: 'app-1', mode: AppModeEnum.CHAT } as any} })
logs={createLogs() as any}
onRefresh={mockOnRefresh}
/>,
)
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith({ expect(mockFetchChatMessages).toHaveBeenCalledWith({
@ -396,7 +398,6 @@ describe('ConversationList', () => {
}) })
it('should render completion details and refetch after feedback updates', async () => { it('should render completion details and refetch after feedback updates', async () => {
mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1')
mockCompletionConversationDetail = { mockCompletionConversationDetail = {
id: 'conversation-1', id: 'conversation-1',
created_at: 1710000000, created_at: 1710000000,
@ -423,13 +424,11 @@ describe('ConversationList', () => {
mockShowPromptLogModal = true mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2' } mockCurrentLogItem = { id: 'log-2' }
render( renderConversationList({
<ConversationList appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
appDetail={{ id: 'app-1', mode: AppModeEnum.COMPLETION } as any} logs: createCompletionLogs() as any,
logs={createCompletionLogs() as any} searchParams: '?page=2&conversation_id=conversation-1',
onRefresh={mockOnRefresh} })
/>,
)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('text-generation')).toBeInTheDocument() 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', () => { it('should render chatflow status cells and feedback counters for advanced chat logs', () => {
render( renderConversationList({
<ConversationList appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } as any,
appDetail={{ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } as any} logs: {
logs={{ data: [
data: [ {
{ id: 'conversation-pending',
id: 'conversation-pending', name: 'Pending row',
name: 'Pending row', from_account_name: 'user-a',
from_account_name: 'user-a', read_at: 1710000001,
read_at: 1710000001, message_count: 3,
message_count: 3, status_count: { paused: 1, success: 0, failed: 0, partial_success: 0 },
status_count: { paused: 1, success: 0, failed: 0, partial_success: 0 }, user_feedback_stats: { like: 2, dislike: 0 },
user_feedback_stats: { like: 2, dislike: 0 }, admin_feedback_stats: { like: 0, dislike: 1 },
admin_feedback_stats: { like: 0, dislike: 1 }, updated_at: 1710000000,
updated_at: 1710000000, created_at: 1710000000,
created_at: 1710000000, },
}, {
{ id: 'conversation-success',
id: 'conversation-success', name: 'Success row',
name: 'Success row', from_account_name: 'user-b',
from_account_name: 'user-b', read_at: 1710000001,
read_at: 1710000001, message_count: 4,
message_count: 4, status_count: { paused: 0, success: 4, failed: 0, partial_success: 0 },
status_count: { paused: 0, success: 4, failed: 0, partial_success: 0 }, user_feedback_stats: { like: 0, dislike: 0 },
user_feedback_stats: { like: 0, dislike: 0 }, admin_feedback_stats: { like: 0, dislike: 0 },
admin_feedback_stats: { like: 0, dislike: 0 }, updated_at: 1710000000,
updated_at: 1710000000, created_at: 1710000000,
created_at: 1710000000, },
}, {
{ id: 'conversation-partial',
id: 'conversation-partial', name: 'Partial row',
name: 'Partial row', from_account_name: 'user-c',
from_account_name: 'user-c', read_at: 1710000001,
read_at: 1710000001, message_count: 5,
message_count: 5, status_count: { paused: 0, success: 3, failed: 0, partial_success: 1 },
status_count: { paused: 0, success: 3, failed: 0, partial_success: 1 }, user_feedback_stats: { like: 0, dislike: 0 },
user_feedback_stats: { like: 0, dislike: 0 }, admin_feedback_stats: { like: 0, dislike: 0 },
admin_feedback_stats: { like: 0, dislike: 0 }, updated_at: 1710000000,
updated_at: 1710000000, created_at: 1710000000,
created_at: 1710000000, },
}, {
{ id: 'conversation-failure',
id: 'conversation-failure', name: 'Failure row',
name: 'Failure row', from_account_name: 'user-d',
from_account_name: 'user-d', read_at: 1710000001,
read_at: 1710000001, message_count: 1,
message_count: 1, status_count: { paused: 0, success: 0, failed: 2, partial_success: 0 },
status_count: { paused: 0, success: 0, failed: 2, partial_success: 0 }, user_feedback_stats: { like: 0, dislike: 0 },
user_feedback_stats: { like: 0, dislike: 0 }, admin_feedback_stats: { like: 0, dislike: 0 },
admin_feedback_stats: { like: 0, dislike: 0 }, updated_at: 1710000000,
updated_at: 1710000000, created_at: 1710000000,
created_at: 1710000000, },
}, ],
], } as any,
} as any} })
onRefresh={mockOnRefresh}
/>,
)
expect(screen.getByText('Pending')).toBeInTheDocument() expect(screen.getByText('Pending')).toBeInTheDocument()
expect(screen.getByText('Success')).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 () => { 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 = { mockChatConversationDetail = {
id: 'conversation-1', id: 'conversation-1',
created_at: 1710000000, created_at: 1710000000,
@ -568,13 +563,9 @@ describe('ConversationList', () => {
has_more: false, has_more: false,
}) })
render( renderConversationList({
<ConversationList searchParams: '?page=2&conversation_id=conversation-1',
appDetail={{ id: 'app-1', mode: AppModeEnum.CHAT } as any} })
logs={createLogs() as any}
onRefresh={mockOnRefresh}
/>,
)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('chat-panel')).toBeInTheDocument() expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
@ -609,7 +600,6 @@ describe('ConversationList', () => {
}) })
it('should close the prompt log modal from completion detail drawers', async () => { it('should close the prompt log modal from completion detail drawers', async () => {
mockSearchParams = new URLSearchParams('page=2&conversation_id=conversation-1')
mockCompletionConversationDetail = { mockCompletionConversationDetail = {
id: 'conversation-1', id: 'conversation-1',
created_at: 1710000000, created_at: 1710000000,
@ -636,13 +626,11 @@ describe('ConversationList', () => {
mockShowPromptLogModal = true mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2' } mockCurrentLogItem = { id: 'log-2' }
render( renderConversationList({
<ConversationList appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
appDetail={{ id: 'app-1', mode: AppModeEnum.COMPLETION } as any} logs: createCompletionLogs() as any,
logs={createCompletionLogs() as any} searchParams: '?page=2&conversation_id=conversation-1',
onRefresh={mockOnRefresh} })
/>,
)
expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument() expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument()

View File

@ -13,6 +13,7 @@ import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { parseAsString, useQueryState } from 'nuqs'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -33,7 +34,6 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { AppSourceType } from '@/service/share' import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
@ -46,7 +46,6 @@ import {
applyAnnotationEdited, applyAnnotationEdited,
applyAnnotationRemoved, applyAnnotationRemoved,
buildChatThreadState, buildChatThreadState,
buildConversationUrl,
getCompletionMessageFiles, getCompletionMessageFiles,
getConversationRowValues, getConversationRowValues,
getDetailVarList, getDetailVarList,
@ -674,10 +673,7 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }
const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) => { const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { formatTime } = useTimestamp() const { formatTime } = useTimestamp()
const router = useRouter() const [conversationIdInUrl, setConversationIdInUrl] = useQueryState('conversation_id', parseAsString)
const pathname = usePathname()
const searchParams = useSearchParams()
const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile
@ -697,8 +693,6 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id
const buildUrlWithConversation = useCallback((conversationId?: string) => buildConversationUrl(pathname, searchParams.toString(), conversationId), [pathname, searchParams])
const handleRowClick = useCallback((log: ConversationListItem) => { const handleRowClick = useCallback((log: ConversationListItem) => {
if (conversationIdInUrl === log.id) { if (conversationIdInUrl === log.id) {
if (!showDrawer) if (!showDrawer)
@ -717,8 +711,8 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
if (currentConversation?.id !== log.id) if (currentConversation?.id !== log.id)
setCurrentConversation(undefined) setCurrentConversation(undefined)
router.push(buildUrlWithConversation(log.id), { scroll: false }) void setConversationIdInUrl(log.id, { history: 'push' })
}, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) }, [conversationIdInUrl, currentConversation, setConversationIdInUrl, showDrawer])
const currentConversationId = currentConversation?.id const currentConversationId = currentConversation?.id
@ -755,7 +749,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation)
pendingConversationCacheRef.current = undefined pendingConversationCacheRef.current = undefined
}, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) }, [conversationIdInUrl, currentConversation, currentConversationId, logs?.data, showDrawer])
const onCloseDrawer = useCallback(() => { const onCloseDrawer = useCallback(() => {
onRefresh() onRefresh()
@ -769,8 +763,8 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
closingConversationIdRef.current = conversationIdInUrl ?? null closingConversationIdRef.current = conversationIdInUrl ?? null
if (conversationIdInUrl) if (conversationIdInUrl)
router.replace(buildUrlWithConversation(), { scroll: false }) void setConversationIdInUrl(null, { history: 'replace' })
}, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) }, [conversationIdInUrl, onRefresh, setConversationIdInUrl, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal])
// Annotated data needs to be highlighted // Annotated data needs to be highlighted
const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => {