diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 65af5626f5a..e41b0364e0b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -495,7 +495,7 @@ }, "web/app/components/app/log/__tests__/list.spec.tsx": { "typescript/no-explicit-any": { - "count": 11 + "count": 9 } }, "web/app/components/app/log/filter.tsx": { @@ -777,14 +777,6 @@ "count": 12 } }, - "web/app/components/base/chat/chat/log/index.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/chat/chat/question.tsx": { "jsx-a11y/no-autofocus": { "count": 1 diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx index 44325a66767..aba2748da9e 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx @@ -1,8 +1,8 @@ -import * as React from 'react' import Configuration from '@/app/components/app/configuration' -const IConfiguration = async () => { - return +const IConfiguration = async ({ params }: { params: Promise<{ appId: string }> }) => { + const { appId } = await params + return } export default IConfiguration diff --git a/web/app/components/app/__tests__/store.spec.ts b/web/app/components/app/__tests__/store.spec.ts deleted file mode 100644 index 3ec07fd575f..00000000000 --- a/web/app/components/app/__tests__/store.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useStore } from '../store' - -describe('app store', () => { - beforeEach(() => { - useStore.setState({ - currentLogModalActiveTab: 'DETAIL', - showMessageLogModal: false, - }) - }) - - it('resets the active tab when the message log modal closes', () => { - useStore.getState().setCurrentLogModalActiveTab('TRACE') - useStore.getState().setShowMessageLogModal(true) - - useStore.getState().setShowMessageLogModal(false) - - expect(useStore.getState().showMessageLogModal).toBe(false) - expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL') - }) -}) diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx index e7ed69e4017..7e9586c2560 100644 --- a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx +++ b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx @@ -98,6 +98,7 @@ const createDeletedAgentTool = (providerId: string): AgentTool => ({ const createContextValue = (): ComponentProps['value'] => ({ appId: 'app-1', + onOpenFeatures: vi.fn(), isTrailFinished: false, mode: AppModeEnum.CHAT, modelModeType: ModelModeType.chat, diff --git a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx index b4e00af30e9..6c5dfa75401 100644 --- a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx @@ -1,7 +1,9 @@ import type { ComponentProps } from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import Log from '@/app/components/base/chat/chat/log' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app' @@ -21,14 +23,6 @@ const mockState = vi.hoisted(() => ({ mockToastUpdate: vi.fn(), mockToastPromise: vi.fn(), mockText2speechDefaultModel: null as unknown, - mockStoreState: { - currentLogItem: null as unknown, - setCurrentLogItem: vi.fn(), - showPromptLogModal: false, - setShowPromptLogModal: vi.fn(), - showAgentLogModal: false, - setShowAgentLogModal: vi.fn(), - }, mockFeaturesState: { moreLikeThis: { enabled: false }, moderation: { enabled: false }, @@ -121,18 +115,45 @@ vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ ), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: ( - selector: (state: { - currentLogItem: unknown - setCurrentLogItem: () => void - showPromptLogModal: boolean - setShowPromptLogModal: () => void - showAgentLogModal: boolean - setShowAgentLogModal: () => void - }) => unknown, - ) => selector(mockState.mockStoreState), -})) +const promptLogItem: IChatItem = { + id: 'prompt-1', + content: 'First answer', + isAnswer: true, + log: [{ role: 'user', text: 'First prompt' }], +} +const secondPromptLogItem: IChatItem = { + id: 'prompt-2', + content: 'Second answer', + isAnswer: true, + log: [{ role: 'user', text: 'Second prompt' }], +} +const agentLogItem: IChatItem = { + id: 'agent-1', + content: 'Agent answer', + isAnswer: true, + conversationId: 'conversation-1', + agent_thoughts: [ + { + id: 'thought-1', + thought: 'Agent thought', + tool: '', + tool_input: '', + message_id: 'agent-1', + conversation_id: 'conversation-1', + observation: '', + position: 1, + }, + ], +} +const LogActions = ({ onOpenLog }: { onOpenLog: (item: IChatItem) => void }) => ( + <> + {[promptLogItem, secondPromptLogItem, agentLogItem].map((item) => ( +
+ +
+ ))} + +) vi.mock('@/app/components/app/text-generate/item', () => ({ default: ({ @@ -140,10 +161,12 @@ vi.mock('@/app/components/app/text-generate/item', () => ({ isLoading, isShowTextToSpeech, messageId, + onOpenLog, }: { content: string isLoading: boolean isShowTextToSpeech: boolean + onOpenLog: (item: IChatItem) => void messageId: string | null }) => (
({ data-message-id={messageId || ''} > {content} +
), })) vi.mock('@/app/components/base/agent-log-modal', () => ({ - default: ({ onCancel }: { onCancel: () => void }) => ( -
+ default: ({ onCancel, currentLogItem }: { onCancel: () => void; currentLogItem: IChatItem }) => ( +
+ {currentLogItem.content} @@ -191,8 +216,9 @@ vi.mock('@/app/components/base/features/hooks', () => ({ })) vi.mock('@/app/components/base/prompt-log-modal', () => ({ - default: ({ onCancel }: { onCancel: () => void }) => ( -
+ default: ({ onCancel, currentLogItem }: { onCancel: () => void; currentLogItem: IChatItem }) => ( +
+ {currentLogItem.log?.map((item) => item.text).join(' ')} @@ -250,8 +276,10 @@ vi.mock('../debug-with-multiple-model', () => ({ default: ({ checkCanSend, onDebugWithMultipleModelChange, + onOpenLog, }: { checkCanSend: () => boolean + onOpenLog: (item: IChatItem) => void onDebugWithMultipleModelChange: (item: { id: string model: string @@ -260,6 +288,7 @@ vi.mock('../debug-with-multiple-model', () => ({ }) => void }) => (
+ @@ -284,9 +313,13 @@ vi.mock('../debug-with-multiple-model', () => ({ vi.mock('../debug-with-single-model', () => { function DebugWithSingleModelMock({ checkCanSend, + onOpenLog, + chatContainerRef, ref, }: { checkCanSend: () => boolean + onOpenLog: (item: IChatItem) => void + chatContainerRef: React.Ref ref?: React.Ref<{ handleRestart: () => void }> }) { React.useImperativeHandle(ref, () => ({ @@ -294,7 +327,8 @@ vi.mock('../debug-with-single-model', () => { })) return ( -
+
+ @@ -309,6 +343,7 @@ const createContextValue = (overrides: Partial = {}): DebugCo readonly: false, canTestAndRun: true, appId: 'app-id', + onOpenFeatures: vi.fn(), isTrailFinished: false, mode: AppModeEnum.CHAT, modelModeType: ModelModeType.chat, @@ -460,14 +495,14 @@ const renderDebug = ( ...options.props, } - render( + const view = render( // oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector contexts are not React 19 context components. , ) - return { onSetting, notify: mockState.mockToastCall, props } + return { ...view, onSetting, notify: mockState.mockToastCall, props } } describe('Debug', () => { @@ -478,14 +513,6 @@ describe('Debug', () => { mockState.mockSetFeatures.mockReset() mockState.mockEventEmitterEmit.mockReset() mockState.mockText2speechDefaultModel = null - mockState.mockStoreState = { - currentLogItem: null, - setCurrentLogItem: vi.fn(), - showPromptLogModal: false, - setShowPromptLogModal: vi.fn(), - showAgentLogModal: false, - setShowAgentLogModal: vi.fn(), - } mockState.mockFeaturesState = { moreLikeThis: { enabled: false }, moderation: { enabled: false }, @@ -966,42 +993,22 @@ describe('Debug', () => { expect(screen.getByText('appDebug.noResult'))!.toBeInTheDocument() }) - it('should render prompt log modal in completion mode when store flag is enabled', () => { - mockState.mockStoreState = { - ...mockState.mockStoreState, - showPromptLogModal: true, - } - - renderDebug({ - contextValue: { - mode: AppModeEnum.COMPLETION, - }, + it('opens and closes the completion result log from its action', async () => { + const user = userEvent.setup() + mockState.mockSendCompletionMessage.mockImplementation((_appId, _data, handlers) => { + handlers.onData('Completion answer', true, { messageId: 'completion-1' }) + handlers.onCompleted() }) - - expect(screen.getByTestId('prompt-log-modal'))!.toBeInTheDocument() - }) - - it('should close prompt log modal in completion mode', () => { - const setCurrentLogItem = vi.fn() - const setShowPromptLogModal = vi.fn() - - mockState.mockStoreState = { - ...mockState.mockStoreState, - currentLogItem: { id: 'log-1' }, - setCurrentLogItem, - showPromptLogModal: true, - setShowPromptLogModal, - } - - renderDebug({ - contextValue: { - mode: AppModeEnum.COMPLETION, - }, - }) - - fireEvent.click(screen.getByTestId('prompt-log-cancel')) - expect(setCurrentLogItem).toHaveBeenCalledTimes(1) - expect(setShowPromptLogModal).toHaveBeenCalledWith(false) + renderDebug({ contextValue: { mode: AppModeEnum.COMPLETION } }) + await user.click(screen.getByRole('button', { name: 'Send' })) + await user.click( + within(screen.getByRole('region', { name: 'First answer' })).getByRole('button', { + name: 'common.operation.log', + }), + ) + expect(screen.getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent('First prompt') + await user.click(screen.getByTestId('prompt-log-cancel')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) @@ -1177,57 +1184,55 @@ describe('Debug', () => { }), ) }) - - it('should render prompt and agent log modals in multiple-model mode', () => { - mockState.mockStoreState = { - ...mockState.mockStoreState, - showPromptLogModal: true, - showAgentLogModal: true, - } - - renderDebug({ - props: { - debugWithMultipleModel: true, - multipleModelConfigs: [ - { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, - ], - }, - }) - - expect(screen.getByTestId('prompt-log-modal'))!.toBeInTheDocument() - expect(screen.getByTestId('agent-log-modal'))!.toBeInTheDocument() - }) - - it('should close prompt and agent log modals in multiple-model mode', () => { - const setCurrentLogItem = vi.fn() - const setShowPromptLogModal = vi.fn() - const setShowAgentLogModal = vi.fn() - - mockState.mockStoreState = { - ...mockState.mockStoreState, - currentLogItem: { id: 'log-1' }, - setCurrentLogItem, - showPromptLogModal: true, - setShowPromptLogModal, - showAgentLogModal: true, - setShowAgentLogModal, - } - - renderDebug({ - props: { - debugWithMultipleModel: true, - multipleModelConfigs: [ - { id: '1', model: 'vision-model', provider: 'openai', parameters: {} }, - ], - }, - }) - - fireEvent.click(screen.getByTestId('prompt-log-cancel')) - fireEvent.click(screen.getByTestId('agent-log-cancel')) - - expect(setCurrentLogItem).toHaveBeenCalledTimes(2) - expect(setShowPromptLogModal).toHaveBeenCalledWith(false) - expect(setShowAgentLogModal).toHaveBeenCalledWith(false) - }) }) + + it('keeps log selections within each mounted debug session', async () => { + const user = userEvent.setup() + const first = renderDebug() + const second = renderDebug() + await user.click( + within(second.container).getAllByRole('button', { name: 'common.operation.log' })[0]!, + ) + expect(within(second.container).getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent( + 'First prompt', + ) + expect(within(first.container).queryByRole('dialog')).not.toBeInTheDocument() + first.unmount() + expect(within(second.container).getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent( + 'First prompt', + ) + second.unmount() + renderDebug() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it.each([false, true])( + 'selects one log, switches item and type, and reopens after close (multiple=%s)', + async (debugWithMultipleModel) => { + const user = userEvent.setup() + renderDebug({ props: { debugWithMultipleModel } }) + const openLog = (answer: string) => + user.click( + within(screen.getByRole('region', { name: answer })).getByRole('button', { + name: 'common.operation.log', + }), + ) + await openLog('First answer') + expect(screen.getAllByRole('dialog')).toHaveLength(1) + expect(screen.getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent('First prompt') + await openLog('Second answer') + expect(screen.getAllByRole('dialog')).toHaveLength(1) + expect(screen.getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent('Second prompt') + await openLog('Agent answer') + expect(screen.getAllByRole('dialog')).toHaveLength(1) + expect(screen.getByRole('dialog', { name: 'Agent log' })).toHaveTextContent('Agent answer') + expect(screen.queryByRole('dialog', { name: 'Prompt log' })).not.toBeInTheDocument() + await user.click(screen.getByTestId('agent-log-cancel')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await openLog('First answer') + expect(screen.getByRole('dialog', { name: 'Prompt log' })).toHaveTextContent('First prompt') + await user.click(screen.getByTestId('prompt-log-cancel')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }, + ) }) diff --git a/web/app/components/app/configuration/debug/__tests__/log-position.browser.spec.tsx b/web/app/components/app/configuration/debug/__tests__/log-position.browser.spec.tsx new file mode 100644 index 00000000000..135b73b94b3 --- /dev/null +++ b/web/app/components/app/configuration/debug/__tests__/log-position.browser.spec.tsx @@ -0,0 +1,156 @@ +import type { Ref } from 'react' +import type { ChatItem } from '@/app/components/base/chat/types' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import Chat from '@/app/components/base/chat/chat' +import { FeaturesProvider } from '@/app/components/base/features/context' +import ConfigContext, { useDebugConfigurationContext } from '@/context/debug-configuration' +import Debug from '../index' + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: () => ({ data: [] }), +})) +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useDefaultModel: () => ({ data: undefined }), +})) +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({}), +})) +vi.mock('@/app/components/app/configuration/debug/chat-user-input', () => ({ default: () => null })) +vi.mock('@/app/components/app/configuration/prompt-value-panel', () => ({ default: () => null })) +vi.mock('../debug-with-multiple-model', () => ({ default: () => null })) +vi.mock('@/app/components/base/agent-log-modal', () => ({ default: () => null })) +vi.mock('@/app/components/app/text-generate/item', () => ({ default: () => null })) +vi.mock('@/service/debug', () => ({ sendCompletionMessage: vi.fn() })) +vi.mock('@/app/components/app/configuration/toast', () => ({ toast: { error: vi.fn() } })) + +// Replace model execution, keeping the production Chat scroll container and log action path. +vi.mock('../debug-with-single-model', () => ({ + default: ({ + onOpenLog, + chatContainerRef, + }: { + onOpenLog: (item: ChatItem) => void + chatContainerRef: Ref + }) => ( + + ), +})) +vi.mock('@/app/components/base/chat/chat/answer', async () => { + const { default: Operation } = await import('@/app/components/base/chat/chat/answer/operation') + return { + default: ({ item }: { item: ChatItem }) => ( +
+
{item.content}
+ +
+ ), + } +}) +vi.mock('@/app/components/base/chat/chat/question', () => ({ default: () => null })) +vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({ default: () => null })) +vi.mock('@/app/components/base/new-audio-button', () => ({ default: () => null })) +vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ default: () => null })) +vi.mock( + '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', + () => ({ default: () => null }), +) + +function DebugSession({ width, doubleGutter = false }: { width: number; doubleGutter?: boolean }) { + const defaults = useDebugConfigurationContext() + return ( + // oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector exposes a Provider, not a React 19 provider component. + + + +
+ +
+
+
+ ) +} + +it('keeps the log beside the actual scrolling chat after resizing its pane', async () => { + await page.viewport(1440, 900) + const view = await render() + const chat = page.getByTestId('chat-container').element() + expect(chat.scrollHeight).toBeGreaterThan(chat.clientHeight) + expect(chat.clientWidth).toBeLessThan(chat.getBoundingClientRect().width) + + await page.getByText('Answer', { exact: true }).hover() + await page.getByRole('button', { name: 'common.operation.log' }).click() + await expect.element(page.getByText('Measured prompt')).toBeVisible() + const title = page.getByText('PROMPT LOG').element() + const initialTitleLeft = title.getBoundingClientRect().left + const initialChatWidth = chat.clientWidth + expect(title.getBoundingClientRect().right).toBeLessThan(chat.getBoundingClientRect().left) + + await view.rerender() + await expect.poll(() => chat.clientWidth).toBeLessThan(initialChatWidth) + await expect + .poll(() => title.getBoundingClientRect().left - initialTitleLeft) + .toBe(initialChatWidth - chat.clientWidth) + + await view.rerender() + await expect + .poll(() => title.getBoundingClientRect().left - initialTitleLeft) + .toBe(initialChatWidth - chat.clientWidth) + expect(title.getBoundingClientRect().right).toBeLessThan(chat.getBoundingClientRect().left) + await expect.element(page.getByText('Measured prompt')).toBeVisible() + await page.getByRole('button', { name: 'common.operation.close' }).click() + await expect.element(page.getByText('Measured prompt')).not.toBeInTheDocument() + await page.getByText('Answer', { exact: true }).hover() + await page.getByRole('button', { name: 'common.operation.log' }).click() + await expect.element(page.getByText('Measured prompt')).toBeVisible() +}) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx index 9ea7884d95c..58e4368fb11 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx @@ -1,11 +1,15 @@ import type { ModelAndParameter } from '../../types' +import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { ChatConfig, ChatItem as ChatItemType, OnSend } from '@/app/components/base/chat/types' import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Log from '@/app/components/base/chat/chat/log' import { TransferMethod } from '@/app/components/base/chat/types' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { renderWithAccountProfile as render } from '@/test/console/account-profile' import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../../types' import ChatItem from '../chat-item' +import { DebugWithMultipleModelContextProvider } from '../context-provider' const mockConsoleStateReader = vi.fn() const mockUseDebugConfigurationContext = vi.fn() @@ -23,6 +27,7 @@ const { mockToastError } = vi.hoisted(() => ({ })) let capturedChatProps: { + onOpenLog: (item: IChatItem) => void config: ChatConfig chatList: ChatItemType[] isResponding: boolean @@ -87,6 +92,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ capturedChatProps = props return (
+ {props?.chatList[0] && } {props?.chatList?.length || 0} {props?.isResponding ? 'yes' : 'no'}
@@ -637,21 +630,25 @@ const Debug: FC = ({ )} )} - {mode === AppModeEnum.COMPLETION && showPromptLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} {isShowCannotQueryDataset && ( setShowCannotQueryDataset(false)} /> )}
)} + {selectedLogItem && + (selectedLogItem.agent_thoughts?.length ? ( + setSelectedLogItem(undefined)} + /> + ) : ( + setSelectedLogItem(undefined)} + /> + ))} {isShowFormattingChangeConfirm && ( )} diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 74803bfe17c..a8edd2bf7fe 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -25,7 +25,6 @@ const renderHook = (callback: () => ReturnType) => { } const mockSetSettingsDestination = vi.fn() -const mockSetShowAppConfigureFeaturesModal = vi.fn() const mockHandleMultipleModelConfigsChange = vi.fn() const mockFetchCollectionList = vi.fn() const mockFetchAppDetailDirect = vi.fn() @@ -98,8 +97,6 @@ vi.mock('@/app/components/app/store', () => ({ mode: AppModeEnum.CHAT, permission_keys: mockAppPermissionKeys, }, - showAppConfigureFeaturesModal: false, - setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, }), })) @@ -275,6 +272,18 @@ describe('useConfiguration', () => { }) }) + it('opens, closes, and reopens feature configuration within the current session', async () => { + const { result } = renderHook(() => useConfiguration()) + await waitFor(() => expect(result.current.showLoading).toBe(false)) + expect(result.current.showAppConfigureFeaturesModal).toBe(false) + act(() => result.current.contextValue.onOpenFeatures()) + expect(result.current.showAppConfigureFeaturesModal).toBe(true) + act(() => result.current.onCloseFeaturePanel()) + expect(result.current.showAppConfigureFeaturesModal).toBe(false) + act(() => result.current.contextValue.onOpenFeatures()) + expect(result.current.showAppConfigureFeaturesModal).toBe(true) + }) + it('should leave loading through the error boundary when the app has no model configuration', async () => { mockFetchAppDetailDirect.mockResolvedValueOnce(createAppDetailFixture({ model_config: null })) const queryClient = createTestQueryClient() @@ -742,7 +751,7 @@ describe('useConfiguration', () => { }) }) - expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true) + expect(result.current.showAppConfigureFeaturesModal).toBe(true) expect(mockFormattingChangedDispatcher).toHaveBeenCalled() expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled() expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse') @@ -855,6 +864,6 @@ describe('useConfiguration', () => { expect(result.current.contextValue.visionConfig.enabled).toBe(true) expect(result.current.contextValue.canReturnToSimpleMode).toBe(false) expect(mockFormattingChangedDispatcher).toHaveBeenCalledTimes(2) - expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(false) + expect(result.current.showAppConfigureFeaturesModal).toBe(false) }) }) diff --git a/web/app/components/app/configuration/hooks/build-configuration-context.ts b/web/app/components/app/configuration/hooks/build-configuration-context.ts index 12d60053793..a19462c28d7 100644 --- a/web/app/components/app/configuration/hooks/build-configuration-context.ts +++ b/web/app/components/app/configuration/hooks/build-configuration-context.ts @@ -12,6 +12,7 @@ type ModelConfiguration = ReturnType type ContextBase = Pick< DebugConfigurationValue, | 'appId' + | 'onOpenFeatures' | 'canReturnToSimpleMode' | 'canTestAndRun' | 'collectionList' diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts index dd7bb2df0c4..14c00c79a84 100644 --- a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts @@ -1,7 +1,6 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' @@ -18,14 +17,7 @@ export function useConfigurationAppContext() { select: (data) => data.profile.id, }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } = - useAppStore( - useShallow((state) => ({ - appDetail: state.appDetail, - showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, - setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, - })), - ) + const appDetail = useAppStore((state) => state.appDetail) const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) const appId = matched?.[1] || '' @@ -58,8 +50,6 @@ export function useConfigurationAppContext() { currentWorkspace, isLoadingCurrentWorkspace, serverLatestPublishedAt, - setShowAppConfigureFeaturesModal, - showAppConfigureFeaturesModal, updateModelConfig, } } diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index e1b189ffa68..e5c0e67e0a8 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -53,11 +53,10 @@ export const useConfiguration = (): ConfigurationViewModel => { currentWorkspace, isLoadingCurrentWorkspace, serverLatestPublishedAt, - setShowAppConfigureFeaturesModal, - showAppConfigureFeaturesModal, updateModelConfig, } = useConfigurationAppContext() const { data: fileUploadConfigResponse } = useFileUploadConfig() + const [showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal] = useState(false) const [formattingChanged, setFormattingChanged] = useState(false) const [hasFetchedDetail, setHasFetchedDetail] = useState(false) // oxlint-disable-next-line eslint-react/use-state -- This custom hook returns a state object. @@ -354,6 +353,7 @@ export const useConfiguration = (): ConfigurationViewModel => { model: modelConfiguration, base: { appId, + onOpenFeatures: () => setShowAppConfigureFeaturesModal(true), canReturnToSimpleMode, canTestAndRun: appACLCapabilities.canTestAndRun, collectionList, diff --git a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx index 5aebfc7ac2f..d1ad96783d0 100644 --- a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx @@ -7,18 +7,7 @@ import ConfigContext from '@/context/debug-configuration' import { AppModeEnum, ModelModeType, Resolution } from '@/types/app' import PromptValuePanel from '../index' -const mockSetShowAppConfigureFeaturesModal = vi.fn() - -vi.mock('@/app/components/app/store', () => ({ - useStore: ( - selector: (state: { - setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal - }) => unknown, - ) => - selector({ - setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, - }), -})) +const mockOnOpenFeatures = vi.fn() // Use real store - global zustand mock will auto-reset between tests vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({ @@ -79,6 +68,7 @@ const promptVariables = [ ] as const const baseContextValue: any = { + onOpenFeatures: mockOnOpenFeatures, modelModeType: ModelModeType.completion, modelConfig: { configs: { @@ -125,7 +115,7 @@ describe('PromptValuePanel', () => { vi.clearAllMocks() mockSetInputs.mockClear() mockOnSend.mockClear() - mockSetShowAppConfigureFeaturesModal.mockClear() + mockOnOpenFeatures.mockClear() }) it('updates inputs, clears values, and triggers run when ready', async () => { @@ -193,7 +183,7 @@ describe('PromptValuePanel', () => { expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled() fireEvent.click(screen.getByText('feature bar')) - expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled() + expect(mockOnOpenFeatures).toHaveBeenCalled() }) it('disables run for advanced completion mode when the completion prompt is empty', () => { diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index ab5199b27aa..f8c9710c5f3 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -21,7 +21,6 @@ import * as React from 'react' import { useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import { useStore as useAppStore } from '@/app/components/app/store' import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar' import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' @@ -48,6 +47,7 @@ const PromptValuePanel: FC = ({ const { readonly, canTestAndRun = false, + onOpenFeatures, modelModeType, modelConfig, setInputs, @@ -131,8 +131,6 @@ const PromptValuePanel: FC = ({ setInputs(newInputs) } - const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal) - return ( <>
@@ -323,7 +321,7 @@ const PromptValuePanel: FC = ({ diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index 1f852a65f92..6da26aadd64 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,8 +1,12 @@ import type { ReactNode } from 'react' +import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createAccountProfileQueryClient } from '@/test/console/account-profile' import { QueryClientTestProvider } from '@/test/console/query-provider' +import { render } from '@/test/console/render' +import { createAppDetailFixture } from '@/test/fixtures/app' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' @@ -11,20 +15,11 @@ const mockFetchChatMessages = vi.fn() const mockUpdateLogMessageFeedbacks = vi.fn() const mockUpdateLogMessageAnnotations = vi.fn() const mockOnRefresh = vi.fn() -const mockSetCurrentLogItem = vi.fn() -const mockSetShowPromptLogModal = vi.fn() -const mockSetShowAgentLogModal = vi.fn() -const mockSetShowMessageLogModal = vi.fn() const mockCompletionRefetch = vi.fn() const mockDelAnnotation = vi.fn() let mockChatConversationDetail: Record | undefined let mockCompletionConversationDetail: Record | undefined -let mockShowMessageLogModal = false -let mockShowPromptLogModal = false -let mockShowAgentLogModal = false -let mockCurrentLogItem: Record | undefined -let mockCurrentLogModalActiveTab = 'messages' vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: (timestamp: number) => `formatted-${timestamp}`, @@ -59,21 +54,6 @@ vi.mock('@/service/annotation', () => ({ delAnnotation: (...args: unknown[]) => mockDelAnnotation(...args), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - currentLogItem: mockCurrentLogItem, - setCurrentLogItem: mockSetCurrentLogItem, - showMessageLogModal: mockShowMessageLogModal, - setShowPromptLogModal: mockSetShowPromptLogModal, - setShowAgentLogModal: mockSetShowAgentLogModal, - setShowMessageLogModal: mockSetShowMessageLogModal, - showPromptLogModal: mockShowPromptLogModal, - showAgentLogModal: mockShowAgentLogModal, - currentLogModalActiveTab: mockCurrentLogModalActiveTab, - }), -})) - vi.mock('@/app/components/base/loading-placeholder', () => ({ LoadingPlaceholder: () =>
loading
, })) @@ -97,14 +77,14 @@ vi.mock('@/app/components/base/copy-icon', () => ({ vi.mock('@/app/components/app/text-generate/item', () => ({ default: ({ content, - hideLogAction, + onOpenLog, onFeedback, }: { content: string - hideLogAction?: boolean + onOpenLog?: (item: IChatItem) => void onFeedback: (value: { rating: string; content?: string }) => Promise }) => ( -
+
{content}
+ + + + + )} @@ -276,11 +318,6 @@ describe('ConversationList', () => { vi.clearAllMocks() mockChatConversationDetail = undefined mockCompletionConversationDetail = undefined - mockShowMessageLogModal = false - mockShowPromptLogModal = false - mockShowAgentLogModal = false - mockCurrentLogItem = undefined - mockCurrentLogModalActiveTab = 'messages' mockDelAnnotation.mockResolvedValue(undefined) mockFetchChatMessages.mockResolvedValue({ data: [], @@ -352,7 +389,7 @@ describe('ConversationList', () => { }, ) - it('should close the drawer, refresh, and clear modal flags', async () => { + it('should close the drawer and refresh', async () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -378,9 +415,6 @@ describe('ConversationList', () => { fireEvent.click(await screen.findByRole('button', { name: /(?:^|\.)operation\.close(?=$|:)/ })) expect(mockOnRefresh).toHaveBeenCalledTimes(1) - expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) - expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) - expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) await waitFor(() => { expect(onUrlUpdate).toHaveBeenCalled() @@ -432,8 +466,6 @@ describe('ConversationList', () => { ], has_more: false, }) - mockShowMessageLogModal = true - mockCurrentLogItem = { id: 'log-1' } renderConversationList({ searchParams: '?page=2&conversation_id=conversation-1', @@ -455,8 +487,6 @@ describe('ConversationList', () => { expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question') expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o') - expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true') - expect(screen.getByTestId('message-log-modal')).toBeInTheDocument() fireEvent.click(screen.getByText('chat-feedback')) @@ -501,65 +531,7 @@ describe('ConversationList', () => { searchParams: '?conversation_id=conversation-1', }) - expect(await screen.findByTestId('chat-panel')).toHaveAttribute( - 'data-show-prompt-log', - expected, - ) - }) - - it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => { - mockChatConversationDetail = { - id: 'conversation-1', - created_at: 1710000000, - model_config: { - model: 'gpt-4o', - configs: { - introduction: 'Hello there', - }, - user_input_form: [], - }, - message: { - inputs: {}, - }, - } - mockShowAgentLogModal = true - mockCurrentLogItem = { - id: 'message-1', - conversationId: 'conversation-1', - } - mockFetchChatMessages.mockResolvedValue({ - data: [ - { - id: 'message-1', - answer: 'Assistant reply', - query: 'Latest question', - created_at: 1710000000, - inputs: {}, - feedbacks: [], - message: [], - message_files: [], - agent_thoughts: [{ id: 'thought-1' }], - }, - ], - has_more: false, - }) - - renderConversationList({ - searchParams: '?page=2&conversation_id=conversation-1', - }) - - await waitFor(() => { - expect(screen.getByTestId('chat-panel')).toBeInTheDocument() - }) - - expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true') - expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument() - expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true') - - fireEvent.click(screen.getByText('close-agent-log-modal')) - - expect(mockSetCurrentLogItem).toHaveBeenCalled() - expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) + expect(await screen.findByTestId('chat-panel')).toHaveAttribute('data-log-enabled', expected) }) it('should render completion details and refetch after feedback updates', async () => { @@ -586,8 +558,6 @@ describe('ConversationList', () => { message_files: [{ url: 'https://example.com/file.txt' }], }, } - mockShowPromptLogModal = true - mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] } renderConversationList({ appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, @@ -600,8 +570,8 @@ describe('ConversationList', () => { }) expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Question') - expect(screen.getByTestId('text-generation')).toHaveAttribute('data-hide-log-action', 'true') - expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument() + expect(screen.getByTestId('text-generation')).toHaveAttribute('data-log-enabled', 'false') + expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument() fireEvent.click(screen.getByText('completion-feedback')) @@ -683,7 +653,7 @@ describe('ConversationList', () => { expect(screen.getAllByText('1').length).toBeGreaterThan(0) }) - it('should support annotation changes, modal closing, and paginated scroll loading in the detail drawer', async () => { + it('should support annotation changes and paginated scroll loading in the detail drawer', async () => { mockChatConversationDetail = { id: 'conversation-1', created_at: 1710000000, @@ -706,8 +676,6 @@ describe('ConversationList', () => { }, }, } - mockShowMessageLogModal = true - mockCurrentLogItem = { id: 'log-1' } mockFetchChatMessages .mockResolvedValueOnce({ data: [ @@ -747,10 +715,6 @@ describe('ConversationList', () => { expect(mockDelAnnotation).toHaveBeenCalledWith('app-1', 'annotation-2') }) - fireEvent.click(screen.getByText('close-message-log-modal')) - expect(mockSetCurrentLogItem).toHaveBeenCalled() - expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) - const scrollableDiv = document.getElementById('scrollableDiv') as HTMLDivElement Object.defineProperty(scrollableDiv, 'clientHeight', { configurable: true, value: 100 }) Object.defineProperty(scrollableDiv, 'scrollHeight', { configurable: true, value: 500 }) @@ -765,44 +729,82 @@ describe('ConversationList', () => { }) }) - it('should close the prompt log modal from completion detail drawers', async () => { - mockCompletionConversationDetail = { + it.each(['workflow', 'agent', 'prompt'])( + 'should open and close the selected %s log within the conversation', + async (kind) => { + mockChatConversationDetail = { + id: 'conversation-1', + model_config: { user_input_form: [] }, + message: { inputs: {} }, + } + renderConversationList({ + appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, + searchParams: '?conversation_id=conversation-1', + }) + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: `open-${kind}-log` })) + const modalKind = kind === 'workflow' ? 'message' : kind + expect(screen.getByTestId(`${modalKind}-log-modal`)).toBeInTheDocument() + for (const other of ['message', 'agent', 'prompt'].filter((value) => value !== modalKind)) + expect(screen.queryByTestId(`${other}-log-modal`)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: `close-${modalKind}-log-modal` })) + expect(screen.queryByTestId(`${modalKind}-log-modal`)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'open-no-log' })) + expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument() + }, + ) + + it('should discard the selected log when the conversation drawer closes and reopens', async () => { + mockChatConversationDetail = { id: 'conversation-1', - created_at: 1710000000, - model_config: { - model: 'gpt-4o-mini', - user_input_form: [ - { - query: { - variable: 'query', - }, - }, - ], - }, - message: { - id: 'message-1', - answer: 'Generated output', - inputs: { - query: 'Question', - }, - feedbacks: [], - message_files: [{ url: 'https://example.com/file.txt' }], - }, + model_config: { user_input_form: [] }, + message: { inputs: {} }, } - mockShowPromptLogModal = true - mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] } - renderConversationList({ - appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any, - logs: createCompletionLogs() as any, - searchParams: '?page=2&conversation_id=conversation-1', + appDetail: { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, + searchParams: '?conversation_id=conversation-1', }) - - expect(await screen.findByTestId('prompt-log-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByText('close-prompt-log-modal')) - - expect(mockSetCurrentLogItem).toHaveBeenCalled() - expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: 'open-workflow-log' })) + expect(screen.getByTestId('message-log-modal')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /(?:^|\.)operation\.close(?=$|:)/ })) + await waitFor(() => expect(screen.queryByTestId('message-log-modal')).not.toBeInTheDocument()) + await user.click(screen.getByText('hello world')) + await screen.findByTestId('chat-panel') + expect(screen.queryByTestId('message-log-modal')).not.toBeInTheDocument() + }) + it('should clear a selected log when URL navigation changes or closes the conversation', async () => { + const user = userEvent.setup() + const queryClient = createAccountProfileQueryClient({ timezone: 'Asia/Shanghai' }) + const appDetail = createAppDetailFixture({ mode: AppModeEnum.ADVANCED_CHAT }) + const view = (searchParams: string) => ( + + + + + + ) + mockChatConversationDetail = { + id: 'conversation-1', + model_config: { user_input_form: [] }, + message: { inputs: {} }, + } + const { rerender } = render(view('?conversation_id=conversation-1')) + await user.click(await screen.findByRole('button', { name: 'open-workflow-log' })) + expect(screen.getByTestId('message-log-modal')).toBeInTheDocument() + mockChatConversationDetail = { ...mockChatConversationDetail, id: 'conversation-2' } + rerender(view('?conversation_id=conversation-2')) + await waitFor(() => expect(screen.queryByTestId('message-log-modal')).not.toBeInTheDocument()) + await user.click(await screen.findByRole('button', { name: 'open-agent-log' })) + expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument() + rerender(view('')) + await waitFor(() => expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()) + rerender(view('?conversation_id=conversation-2')) + await screen.findByTestId('chat-panel') + expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 61f146d845b..df1f4486d2f 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -42,9 +42,7 @@ import { parseAsString, useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' import ModelInfo from '@/app/components/app/log/model-info' -import { useStore as useAppStore } from '@/app/components/app/store' import TextGeneration from '@/app/components/app/text-generate/item' import AgentLogModal from '@/app/components/base/agent-log-modal' import Chat from '@/app/components/base/chat/chat' @@ -81,7 +79,6 @@ import { } from './list-utils' import VarPanel from './var-panel' -type AppStoreState = ReturnType type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true } @@ -174,29 +171,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { select: (data) => data.profile.timezone ?? undefined, }) const { formatTime } = useTimestamp() - const { - currentLogItem, - setCurrentLogItem, - showMessageLogModal, - setShowMessageLogModal, - showPromptLogModal, - setShowPromptLogModal, - showAgentLogModal, - setShowAgentLogModal, - currentLogModalActiveTab, - } = useAppStore( - useShallow((state: AppStoreState) => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showMessageLogModal: state.showMessageLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - showPromptLogModal: state.showPromptLogModal, - setShowPromptLogModal: state.setShowPromptLogModal, - showAgentLogModal: state.showAgentLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - currentLogModalActiveTab: state.currentLogModalActiveTab, - })), - ) + const [selectedLogItem, setSelectedLogItem] = useState() const { t } = useTranslation(['appLog', 'common']) const [hasMore, setHasMore] = useState(true) const [varValues, setVarValues] = useState>({}) @@ -427,7 +402,13 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION const isAdvanced = appDetail.mode === AppModeEnum.ADVANCED_CHAT - const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log + const logKind = selectedLogItem?.workflow_run_id + ? 'workflow' + : selectedLogItem?.agent_thoughts?.length + ? 'agent' + : selectedLogItem?.log + ? 'prompt' + : undefined const varList = getDetailVarList(detail, varValues) const message_files = getCompletionMessageFiles(detail, isChatMode) @@ -518,7 +499,6 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { onRetry={noop} supportFeedback feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')} - hideLogAction onFeedback={(feedback) => onFeedback(detail.message.id, feedback)} isShowTextToSpeech siteInfo={null} @@ -547,9 +527,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { onAnnotationRemoved={handleAnnotationRemoved} onFeedback={onFeedback} noChatInput - showPromptLog={isAdvanced} + onOpenLog={isAdvanced ? setSelectedLogItem : undefined} hideProcessDetail - hideLogModal chatContainerInnerClassName="px-3" switchSibling={switchSibling} /> @@ -589,9 +568,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { onAnnotationRemoved={handleAnnotationRemoved} onFeedback={onFeedback} noChatInput - showPromptLog={isAdvanced} + onOpenLog={isAdvanced ? setSelectedLogItem : undefined} hideProcessDetail - hideLogModal chatContainerInnerClassName="px-3" switchSibling={switchSibling} /> @@ -607,38 +585,28 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
)}
- {showMessageLogModal && ( + {logKind === 'workflow' && ( { - setCurrentLogItem() - setShowMessageLogModal(false) - }} - defaultTab={currentLogModalActiveTab} + currentLogItem={selectedLogItem} + onCancel={() => setSelectedLogItem(undefined)} /> )} - {showAgentLogModal && ( + {logKind === 'agent' && ( { - setCurrentLogItem() - setShowAgentLogModal(false) - }} + currentLogItem={selectedLogItem} + onCancel={() => setSelectedLogItem(undefined)} /> )} - {shouldShowPromptLogModal && ( + {logKind === 'prompt' && ( { - setCurrentLogItem() - setShowPromptLogModal(false) - }} + currentLogItem={selectedLogItem} + onCancel={() => setSelectedLogItem(undefined)} /> )}
@@ -707,6 +675,7 @@ const CompletionConversationDetailComp: FC = ({ return ( = ({ return ( = ({ logs, appDetail, onRefresh }) const pendingConversationCacheRef = useRef(undefined) const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app - const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore( - useShallow((state: AppStoreState) => ({ - setShowPromptLogModal: state.setShowPromptLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - })), - ) - const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id @@ -874,22 +836,12 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) onRefresh() setShowDrawer(false) setCurrentConversation(undefined) - setShowPromptLogModal(false) - setShowAgentLogModal(false) - setShowMessageLogModal(false) pendingConversationIdRef.current = null pendingConversationCacheRef.current = undefined closingConversationIdRef.current = conversationIdInUrl ?? null if (conversationIdInUrl) void setConversationIdInUrl(null, { history: 'replace' }) - }, [ - conversationIdInUrl, - onRefresh, - setConversationIdInUrl, - setShowAgentLogModal, - setShowMessageLogModal, - setShowPromptLogModal, - ]) + }, [conversationIdInUrl, onRefresh, setConversationIdInUrl]) // Annotated data needs to be highlighted const renderTdValue = ( diff --git a/web/app/components/app/store.ts b/web/app/components/app/store.ts index 64e8dbbdab0..d970cf69ae5 100644 --- a/web/app/components/app/store.ts +++ b/web/app/components/app/store.ts @@ -1,52 +1,15 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import type { IChatItem } from '@/app/components/base/chat/chat/type' import { create } from 'zustand' type State = { appDetail?: AppDetailWithSite - currentLogItem?: IChatItem - currentLogModalActiveTab: string - showPromptLogModal: boolean - showAgentLogModal: boolean - showMessageLogModal: boolean - showAppConfigureFeaturesModal: boolean } type Action = { setAppDetail: (appDetail?: AppDetailWithSite) => void - setCurrentLogItem: (item?: IChatItem) => void - setCurrentLogModalActiveTab: (tab: string) => void - setShowPromptLogModal: (showPromptLogModal: boolean) => void - setShowAgentLogModal: (showAgentLogModal: boolean) => void - setShowMessageLogModal: (showMessageLogModal: boolean) => void - setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal: boolean) => void } export const useStore = create((set) => ({ appDetail: undefined, setAppDetail: (appDetail) => set(() => ({ appDetail })), - currentLogItem: undefined, - currentLogModalActiveTab: 'DETAIL', - setCurrentLogItem: (currentLogItem) => set(() => ({ currentLogItem })), - setCurrentLogModalActiveTab: (currentLogModalActiveTab) => - set(() => ({ currentLogModalActiveTab })), - showPromptLogModal: false, - setShowPromptLogModal: (showPromptLogModal) => set(() => ({ showPromptLogModal })), - showAgentLogModal: false, - setShowAgentLogModal: (showAgentLogModal) => set(() => ({ showAgentLogModal })), - showMessageLogModal: false, - setShowMessageLogModal: (showMessageLogModal) => - set(() => { - if (showMessageLogModal) { - return { showMessageLogModal } - } else { - return { - showMessageLogModal, - currentLogModalActiveTab: 'DETAIL', - } - } - }), - showAppConfigureFeaturesModal: false, - setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal) => - set(() => ({ showAppConfigureFeaturesModal })), })) diff --git a/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx index d32565093a4..258798ab198 100644 --- a/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx @@ -7,7 +7,7 @@ const mockCopy = vi.fn() const mockSuccess = vi.fn() const mockOnFeedback = vi.fn() const mockOnMoreLikeThis = vi.fn() -const mockOnOpenLogModal = vi.fn() +const mockOnOpenLog = vi.fn() const mockOnRetry = vi.fn() const mockOnSave = vi.fn() vi.mock('copy-to-clipboard', () => ({ @@ -37,7 +37,7 @@ describe('GenerationActionGroups', () => { messageId="msg-1" onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} onSave={mockOnSave} supportFeedback @@ -66,7 +66,7 @@ describe('GenerationActionGroups', () => { moreLikeThis onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} onSave={mockOnSave} supportFeedback @@ -96,7 +96,7 @@ describe('GenerationActionGroups', () => { messageId="msg-1" moreLikeThis onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) @@ -106,19 +106,17 @@ describe('GenerationActionGroups', () => { ).toBeDisabled() }) - it('should hide the log action when requested by the owning surface', () => { + it('should hide the log action when the owner does not provide it', () => { render( , ) @@ -139,7 +137,7 @@ describe('GenerationActionGroups', () => { isInWebApp messageId="msg-2" onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) @@ -162,7 +160,7 @@ describe('GenerationActionGroups', () => { messageId="msg-3" onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) @@ -189,7 +187,7 @@ describe('GenerationActionGroups', () => { messageId="msg-4" onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) @@ -213,7 +211,7 @@ describe('GenerationActionGroups', () => { messageId="msg-4" onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) @@ -237,7 +235,7 @@ describe('GenerationActionGroups', () => { messageId="msg-4" onFeedback={mockOnFeedback} onMoreLikeThis={mockOnMoreLikeThis} - onOpenLogModal={mockOnOpenLogModal} + onOpenLog={mockOnOpenLog} onRetry={mockOnRetry} />, ) diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx index 45f153f997e..be070bbb12a 100644 --- a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx @@ -1,12 +1,12 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { AppSourceType } from '@/service/share' import GenerationItem from '../index' const mockFetchMoreLikeThis = vi.fn() const mockFetchTextGenerationMessage = vi.fn() const mockUpdateFeedback = vi.fn() -const mockSetCurrentLogItem = vi.fn() -const mockSetShowPromptLogModal = vi.fn() +const mockOnOpenLog = vi.fn() const mockSubmitHumanInputForm = vi.fn() const mockSubmitHumanInputFormWorkflow = vi.fn() const mockToastWarning = vi.fn() @@ -34,14 +34,6 @@ vi.mock('@/service/debug', () => ({ fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args), })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - setCurrentLogItem: mockSetCurrentLogItem, - setShowPromptLogModal: mockSetShowPromptLogModal, - }), -})) - vi.mock('@/app/components/base/chat/chat/context', () => ({ useChatContext: () => ({ config: { @@ -146,7 +138,7 @@ describe('GenerationItem', () => { ) }) - it('should open the prompt log modal with normalized log data', async () => { + it('should send normalized log data to the owning surface', async () => { mockFetchTextGenerationMessage.mockResolvedValue({ answer: 'assistant answer', message: [{ role: 'user', text: 'hello' }], @@ -160,6 +152,7 @@ describe('GenerationItem', () => { isError={false} messageId="msg-1" onRetry={vi.fn()} + onOpenLog={mockOnOpenLog} siteInfo={null} />, ) @@ -172,7 +165,7 @@ describe('GenerationItem', () => { appId: 'app-1', messageId: 'msg-1', }) - expect(mockSetCurrentLogItem).toHaveBeenCalledWith( + expect(mockOnOpenLog).toHaveBeenCalledWith( expect.objectContaining({ log: [ { role: 'user', text: 'hello' }, @@ -184,7 +177,64 @@ describe('GenerationItem', () => { ], }), ) - expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true) + }) + + it('keeps the log action absent without an owning surface', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: /operation\.log/ })).not.toBeInTheDocument() + expect(mockFetchTextGenerationMessage).not.toHaveBeenCalled() + }) + + it('opens a more-like-this child log through the same owner using the child message', async () => { + const user = userEvent.setup() + mockFetchMoreLikeThis.mockResolvedValue({ answer: 'Child answer', id: 'child-message' }) + mockFetchTextGenerationMessage.mockResolvedValue({ + id: 'child-message', + answer: 'Child answer', + message: [{ role: 'user', text: 'Child prompt' }], + }) + render( + , + ) + + await user.click(screen.getByRole('button', { name: /feature\.moreLikeThis\.title/ })) + expect(await screen.findByText('markdown:Child answer')).toBeInTheDocument() + await user.click(screen.getAllByRole('button', { name: /operation\.log/ })[1]!) + + await waitFor(() => + expect(mockOnOpenLog).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + id: 'child-message', + log: [ + { role: 'user', text: 'Child prompt' }, + { role: 'assistant', text: 'Child answer', files: [] }, + ], + }), + ), + ) + expect(mockFetchTextGenerationMessage).toHaveBeenCalledExactlyOnceWith({ + appId: 'app-1', + messageId: 'child-message', + }) }) it('should route human input submissions to the workflow service for installed apps', async () => { diff --git a/web/app/components/app/text-generate/item/action-groups.tsx b/web/app/components/app/text-generate/item/action-groups.tsx index db74f2e9186..6cec1ff6a78 100644 --- a/web/app/components/app/text-generate/item/action-groups.tsx +++ b/web/app/components/app/text-generate/item/action-groups.tsx @@ -23,7 +23,6 @@ type GenerationActionGroupsProps = { currentTab: string depth: number feedback?: FeedbackType - hideLogAction?: boolean isError: boolean isInWebApp: boolean isResponding?: boolean @@ -33,7 +32,7 @@ type GenerationActionGroupsProps = { moreLikeThis?: boolean onFeedback?: (feedback: FeedbackType) => void onMoreLikeThis: () => void - onOpenLogModal: () => void + onOpenLog?: () => void onRetry: () => void onSave?: (messageId: string) => void supportFeedback?: boolean @@ -47,7 +46,6 @@ const GenerationActionGroups: FC = ({ currentTab, depth, feedback, - hideLogAction, isError, isInWebApp, isResponding, @@ -57,7 +55,7 @@ const GenerationActionGroups: FC = ({ moreLikeThis, onFeedback, onMoreLikeThis, - onOpenLogModal, + onOpenLog, onRetry, onSave, supportFeedback, @@ -70,7 +68,7 @@ const GenerationActionGroups: FC = ({ return ( <> - {!hideLogAction && + {onOpenLog && !isInWebApp && appSourceType !== AppSourceTypeEnum.installedApp && !isResponding && ( @@ -79,7 +77,7 @@ const GenerationActionGroups: FC = ({ aria-label={t(($) => $['operation.log'], { ns: 'common' })} disabled={isError || !messageId} title={t(($) => $['operation.log'], { ns: 'common' })} - onClick={onOpenLogModal} + onClick={onOpenLog} >
- {width} ) } @@ -150,7 +149,6 @@ describe('useChatLayout', () => { vi.runAllTimers() }) - expect(screen.getByTestId('layout-width')).toHaveTextContent('600') expect(screen.getByTestId('chat-footer').style.width).toBe('400px') expect(screen.getByTestId('chat-footer-inner').style.width).toBe('360px') expect((screen.getByTestId('chat-container') as HTMLDivElement).scrollTop).toBe(640) @@ -265,7 +263,6 @@ describe('useChatLayout', () => { vi.runAllTimers() }) - expect(screen.getByTestId('layout-width')).toHaveTextContent('0') expect(capturedResizeCallbacks).toHaveLength(0) expect(screen.getByTestId('chat-footer').style.width).toBe('') expect(screen.getByTestId('chat-footer-inner').style.width).toBe('') diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 431cc4fccd9..e5848ef6f36 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -107,14 +107,6 @@ vi.mock('@/app/components/base/new-audio-button', () => ({ ), })) -vi.mock('@/app/components/base/chat/chat/log', () => ({ - default: () => ( - - ), -})) - vi.mock('@/next/navigation', () => ({ useParams: vi.fn(() => ({ appId: 'test-app' })), usePathname: vi.fn(() => '/apps/test-app'), @@ -177,7 +169,6 @@ type OperationProps = { item: ChatItem question: string index: number - showPromptLog?: boolean maxSize: number contentWidth: number hasWorkflowProcess: boolean @@ -241,6 +232,7 @@ describe('Operation', () => { mockContextValue.onAnnotationRemoved = vi.fn() mockContextValue.readonly = false mockContextValue.showRegenerate = false + mockContextValue.onOpenLog = undefined mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } }) }) @@ -343,26 +335,29 @@ describe('Operation', () => { expect(screen.queryByTestId('annotation-ctrl')).not.toBeInTheDocument() }) - it('should show prompt log when showPromptLog is true', () => { - renderOperation({ ...baseProps, showPromptLog: true }) - expect(screen.getByTestId('log-btn'))!.toBeInTheDocument() + it('should show the log action when the owner provides it', () => { + mockContextValue.onOpenLog = vi.fn() + renderOperation() + expect(screen.getByRole('button', { name: 'operation.log' }))!.toBeInTheDocument() }) it('should keep hover-only controls visible when a descendant popup is open', () => { - renderOperation({ ...baseProps, showPromptLog: true }) + mockContextValue.onOpenLog = vi.fn() + renderOperation() expect(screen.getByTestId('operation-actions')).toHaveClass( 'group-has-[[data-popup-open]]:flex', ) - expect(screen.getByTestId('log-btn').parentElement).toHaveClass( - 'group-has-[[data-popup-open]]:block', - ) + expect( + screen.getByRole('button', { name: 'operation.log' }).parentElement?.parentElement, + ).toHaveClass('group-has-[[data-popup-open]]:block') }) it('should not show prompt log for opening statements', () => { const item = { ...baseItem, isOpeningStatement: true } - renderOperation({ ...baseProps, item, showPromptLog: true }) - expect(screen.queryByTestId('log-btn')).not.toBeInTheDocument() + mockContextValue.onOpenLog = vi.fn() + renderOperation({ ...baseProps, item }) + expect(screen.queryByRole('button', { name: 'operation.log' })).not.toBeInTheDocument() }) }) @@ -897,7 +892,8 @@ describe('Operation', () => { feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const }, } - renderOperation({ ...baseProps, item, showPromptLog: true }) + mockContextValue.onOpenLog = vi.fn() + renderOperation({ ...baseProps, item }) const bar = screen.getByTestId('operation-bar') expect(bar)!.toBeInTheDocument() }) diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index 77c9513141a..944892131c0 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -19,15 +19,10 @@ const meta = { 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.', }, responding: { control: 'boolean', description: 'Indicates if the answer is being generated.' }, - showPromptLog: { - control: 'boolean', - description: 'If set to true, the prompt log button will be shown on hover.', - }, }, args: { noChatInput: false, responding: false, - showPromptLog: false, }, } satisfies Meta diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 60b4f536878..0a73362a2e5 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -31,7 +31,6 @@ type AnswerProps = { config?: ChatConfig answerIcon?: ReactNode responding?: boolean - showPromptLog?: boolean chatAnswerContainerInner?: string hideProcessDetail?: boolean appData?: AppData @@ -53,7 +52,6 @@ const Answer: FC = ({ config, answerIcon, responding, - showPromptLog, chatAnswerContainerInner, hideProcessDetail, appData, @@ -195,7 +193,6 @@ const Answer: FC = ({ item={item} question={question} index={index} - showPromptLog={showPromptLog} noChatInput={noChatInput} /> )} @@ -256,7 +253,6 @@ const Answer: FC = ({ item={item} question={question} index={index} - showPromptLog={showPromptLog} noChatInput={noChatInput} /> )} @@ -333,7 +329,6 @@ const Answer: FC = ({ item={item} question={question} index={index} - showPromptLog={showPromptLog} noChatInput={noChatInput} /> )} diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index f7199071222..8a3a3f76f9a 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -29,7 +29,6 @@ type OperationProps = { item: ChatItem question: string index: number - showPromptLog?: boolean maxSize: number contentWidth: number hasWorkflowProcess: boolean @@ -84,7 +83,6 @@ function Operation({ item, question, index, - showPromptLog, maxSize, contentWidth, hasWorkflowProcess, @@ -93,6 +91,8 @@ function Operation({ const { t } = useTranslation(['appLog', 'common']) const { config, + onOpenLog, + canOpenLog, onAnnotationAdded, onAnnotationEdited, onAnnotationRemoved, @@ -116,6 +116,7 @@ function Operation({ const content = getPublicResponseContent(item) const hasPublicContent = !!content.trim() + const availableLogAction = onOpenLog && (canOpenLog?.(item) ?? true) ? onOpenLog : undefined const displayUserFeedback = userFeedbackOverride ?? feedback const displayAdminFeedback = adminFeedbackOverride ?? adminFeedback @@ -200,7 +201,7 @@ function Operation({ const operationWidth = useMemo(() => { let width = 0 if (!isOpeningStatement) width += 26 - if (!isOpeningStatement && showPromptLog) width += 28 + 8 + if (!isOpeningStatement && availableLogAction) width += 28 + 8 if (!isOpeningStatement && config?.text_to_speech?.enabled && hasPublicContent) width += 26 if (!isOpeningStatement && shouldShowAnnotationAction) width += 26 if (shouldShowUserFeedbackBar) width += hasUserFeedback ? 28 + 8 : 60 + 8 @@ -214,10 +215,10 @@ function Operation({ hasPublicContent, hasUserFeedback, isOpeningStatement, + availableLogAction, shouldShowAdminFeedbackBar, shouldShowAnnotationAction, shouldShowUserFeedbackBar, - showPromptLog, ]) const positionRight = useMemo( @@ -459,9 +460,9 @@ function Operation({
- {showPromptLog && !isOpeningStatement && ( + {availableLogAction && !isOpeningStatement && (
- +
)} {!isOpeningStatement && ( diff --git a/web/app/components/base/chat/chat/chat-log-modals.tsx b/web/app/components/base/chat/chat/chat-log-modals.tsx deleted file mode 100644 index 74f715ec61a..00000000000 --- a/web/app/components/base/chat/chat/chat-log-modals.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { FC } from 'react' -import type { IChatItem } from './type' -import AgentLogModal from '@/app/components/base/agent-log-modal' -import PromptLogModal from '@/app/components/base/prompt-log-modal' - -type ChatLogModalsProps = { - width: number - currentLogItem?: IChatItem - showPromptLogModal: boolean - showAgentLogModal: boolean - hideLogModal?: boolean - setCurrentLogItem: (item?: IChatItem) => void - setShowPromptLogModal: (showPromptLogModal: boolean) => void - setShowAgentLogModal: (showAgentLogModal: boolean) => void -} - -const ChatLogModals: FC = ({ - width, - currentLogItem, - showPromptLogModal, - showAgentLogModal, - hideLogModal, - setCurrentLogItem, - setShowPromptLogModal, - setShowAgentLogModal, -}) => { - if (hideLogModal) return null - - return ( - <> - {showPromptLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} - {showAgentLogModal && ( - { - setCurrentLogItem() - setShowAgentLogModal(false) - }} - /> - )} - - ) -} - -export default ChatLogModals diff --git a/web/app/components/base/chat/chat/context-provider.tsx b/web/app/components/base/chat/chat/context-provider.tsx index c59399ca2b3..01ceeff8387 100644 --- a/web/app/components/base/chat/chat/context-provider.tsx +++ b/web/app/components/base/chat/chat/context-provider.tsx @@ -14,7 +14,8 @@ export const ChatContextProvider = ({ config, isResponding, chatList, - showPromptLog, + onOpenLog, + canOpenLog, questionIcon, answerIcon, onSend, @@ -34,7 +35,8 @@ export const ChatContextProvider = ({ readonly, isResponding, chatList: chatList || [], - showPromptLog, + onOpenLog, + canOpenLog, questionIcon, answerIcon, onSend, diff --git a/web/app/components/base/chat/chat/context.ts b/web/app/components/base/chat/chat/context.ts index c54935e3c5f..20ae574cc0f 100644 --- a/web/app/components/base/chat/chat/context.ts +++ b/web/app/components/base/chat/chat/context.ts @@ -8,7 +8,8 @@ export type ChatContextValue = Pick< | 'config' | 'isResponding' | 'chatList' - | 'showPromptLog' + | 'onOpenLog' + | 'canOpenLog' | 'questionIcon' | 'answerIcon' | 'onSend' diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index 799e8e61f73..9996d21a6a0 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -1,22 +1,19 @@ -import type { FC, ReactNode } from 'react' +import type { FC, ReactNode, Ref } from 'react' import type { Theme } from '../embedded-chatbot/theme/theme' import type { ChatConfig, ChatItem, OnFeedback, OnRegenerate, OnSend } from '../types' import type { HumanInputFormSubmitData } from './answer/human-input-content/type' import type { AnswerActionPosition } from './answer/operation' -import type { InputForm } from './type' +import type { IChatItem, InputForm } from './type' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' import type { Node } from '@/app/components/workflow/types' import type { AppData, ToolIcon } from '@/models/share' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { memo, useEffect, useRef } from 'react' +import { memo, useEffect, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { useStore as useAppStore } from '@/app/components/app/store' import Answer from './answer' import ChatInputArea from './chat-input-area' -import ChatLogModals from './chat-log-modals' import { ChatContextProvider } from './context-provider' import Question from './question' import TryToAsk from './try-to-ask' @@ -38,12 +35,14 @@ export type ChatProps = { inputs?: Record inputsForm?: InputForm[] onRegenerate?: OnRegenerate + chatContainerRef?: Ref chatContainerClassName?: string chatContainerInnerClassName?: string chatFooterClassName?: string chatFooterInnerClassName?: string suggestedQuestions?: string[] - showPromptLog?: boolean + onOpenLog?: (item: IChatItem) => void + canOpenLog?: (item: IChatItem) => boolean questionIcon?: ReactNode answerIcon?: ReactNode allToolIcons?: Record @@ -61,7 +60,6 @@ export type ChatProps = { onFeedback?: OnFeedback chatAnswerContainerInner?: string hideProcessDetail?: boolean - hideLogModal?: boolean theme?: Theme switchSibling?: (siblingMessageId: string) => void showFeatureBar?: boolean @@ -106,12 +104,14 @@ const Chat: FC = ({ onStopResponding, noChatInput, showRegenerate, + chatContainerRef: externalChatContainerRef, chatContainerClassName, chatContainerInnerClassName, chatFooterClassName, chatFooterInnerClassName, suggestedQuestions, - showPromptLog, + onOpenLog, + canOpenLog, questionIcon, answerIcon, onAnnotationAdded, @@ -122,7 +122,6 @@ const Chat: FC = ({ onFeedback, chatAnswerContainerInner, hideProcessDetail, - hideLogModal, theme, switchSibling, showFeatureBar, @@ -168,29 +167,14 @@ const Chat: FC = ({ if (announcement && status.textContent !== announcement) status.textContent = announcement wasRespondingRef.current = !!isResponding }, [hasAgentContent, isResponding, t]) - const { - currentLogItem, - setCurrentLogItem, - showPromptLogModal, - setShowPromptLogModal, - showAgentLogModal, - setShowAgentLogModal, - } = useAppStore( - useShallow((state) => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showPromptLogModal: state.showPromptLogModal, - setShowPromptLogModal: state.setShowPromptLogModal, - showAgentLogModal: state.showAgentLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - })), - ) - const { width, chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } = + const { chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } = useChatLayout({ chatList, sidebarCollapseState, }) + useImperativeHandle(externalChatContainerRef, () => chatContainerRef.current!, [chatContainerRef]) + const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend @@ -200,7 +184,8 @@ const Chat: FC = ({ config={config} chatList={chatList} isResponding={isResponding} - showPromptLog={showPromptLog} + onOpenLog={onOpenLog} + canOpenLog={canOpenLog} questionIcon={questionIcon} answerIcon={answerIcon} onSend={onSend} @@ -250,7 +235,6 @@ const Chat: FC = ({ config={config} answerIcon={answerIcon} responding={isLast && isResponding} - showPromptLog={showPromptLog} chatAnswerContainerInner={chatAnswerContainerInner} hideProcessDetail={hideProcessDetail} noChatInput={noChatInput} @@ -334,16 +318,6 @@ const Chat: FC = ({ )}
-
) diff --git a/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx index 13c7eb3693b..db445f8848a 100644 --- a/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/log/__tests__/index.spec.tsx @@ -1,126 +1,41 @@ -import type { IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type' +import type { IChatItem } from '../../type' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { vi } from 'vite-plus/test' -import { useStore as useAppStore } from '@/app/components/app/store' import Log from '../index' -vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn(), -})) +const logItem: IChatItem = { + id: 'message-1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'run-1', +} describe('Log', () => { - const mockSetCurrentLogItem = vi.fn() - const mockSetShowPromptLogModal = vi.fn() - const mockSetShowAgentLogModal = vi.fn() - const mockSetShowMessageLogModal = vi.fn() - - const createLogItem = (overrides?: Partial): IChatItem => ({ - id: '1', - content: 'test', - isAnswer: true, // Required per your IChatItem type - workflow_run_id: '', - agent_thoughts: [], - message_files: [], - ...overrides, - }) - - beforeEach(() => { - vi.mocked(useAppStore).mockImplementation((selector) => - selector({ - // State properties - currentLogModalActiveTab: 'question', - showPromptLogModal: false, - showAgentLogModal: false, - showMessageLogModal: false, - showAppConfigureFeaturesModal: false, // Fixed: Added missing required property - currentLogItem: null, - // Action functions - setCurrentLogItem: mockSetCurrentLogItem, - setShowPromptLogModal: mockSetShowPromptLogModal, - setShowAgentLogModal: mockSetShowAgentLogModal, - setShowMessageLogModal: mockSetShowMessageLogModal, - } as unknown as Parameters[0]), - ) // Fixed: Double cast to avoid overlap error - }) - - it('should render correctly', () => { - render() - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should show message log modal when workflow_run_id exists', async () => { + it('sends the selected item to its owner without activating the surrounding surface', async () => { const user = userEvent.setup() - const logItem = createLogItem({ workflow_run_id: 'run-123' }) + const onOpenLog = vi.fn() + const onParentClick = vi.fn() + render( +
+ +
, + ) - render() - const container = screen.getByRole('button').parentElement - if (container) await user.click(container) + await user.click(screen.getByRole('button', { name: /operation\.log/ })) - expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) - expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true) + expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(logItem) + expect(onParentClick).not.toHaveBeenCalled() }) - it('should show agent log modal when agent_thoughts exists and workflow_run_id is missing', async () => { + it.each(['{Enter}', ' '])('opens the selected log with %s', async (key) => { const user = userEvent.setup() - const thought: ThoughtItem = { - id: 't1', - tool: 'test', - thought: 'thinking', - tool_input: '', - message_id: 'm1', - conversation_id: 'c1', - observation: '', - position: 1, - } - const logItem = createLogItem({ - workflow_run_id: '', - agent_thoughts: [thought], - }) + const onOpenLog = vi.fn() + render() - render() - const container = screen.getByRole('button').parentElement - if (container) await user.click(container) + await user.tab() + expect(screen.getByRole('button', { name: /operation\.log/ })).toHaveFocus() + await user.keyboard(key) - expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) - expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true) - }) - - it('should show prompt log modal when both workflow_run_id and agent_thoughts are missing', async () => { - const user = userEvent.setup() - const logItem = createLogItem({ - workflow_run_id: '', - agent_thoughts: [], - }) - - render() - const container = screen.getByRole('button').parentElement - if (container) await user.click(container) - - expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem) - expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true) - }) - - it('should prevent event propagation on click', async () => { - const user = userEvent.setup() - - // 1. Spy on both the standard propagation and the immediate propagation - const stopPropagationSpy = vi.spyOn(Event.prototype, 'stopPropagation') - const stopImmediatePropagationSpy = vi.spyOn(Event.prototype, 'stopImmediatePropagation') - - render() - - // Find the container div that has the onClick handler - const container = screen.getByRole('button').parentElement - - if (container) await user.click(container) - - // 2. Assert that both were called - expect(stopPropagationSpy).toHaveBeenCalled() - expect(stopImmediatePropagationSpy).toHaveBeenCalled() - - // 3. Clean up spies (Good practice to avoid interfering with other tests) - stopPropagationSpy.mockRestore() - stopImmediatePropagationSpy.mockRestore() + expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(logItem) }) }) diff --git a/web/app/components/base/chat/chat/log/index.tsx b/web/app/components/base/chat/chat/log/index.tsx index 665deca1853..0c18c353222 100644 --- a/web/app/components/base/chat/chat/log/index.tsx +++ b/web/app/components/base/chat/chat/log/index.tsx @@ -3,33 +3,24 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import { IconButton } from '@langgenius/dify-ui/icon-button' import { RiFileList3Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' type LogProps = { logItem: IChatItem + onOpenLog: (item: IChatItem) => void } -const Log: FC = ({ logItem }) => { +const Log: FC = ({ logItem, onOpenLog }) => { const { t } = useTranslation(['common']) - const setCurrentLogItem = useAppStore((s) => s.setCurrentLogItem) - const setShowPromptLogModal = useAppStore((s) => s.setShowPromptLogModal) - const setShowAgentLogModal = useAppStore((s) => s.setShowAgentLogModal) - const setShowMessageLogModal = useAppStore((s) => s.setShowMessageLogModal) - const { workflow_run_id: runID, agent_thoughts } = logItem - const isAgent = agent_thoughts && agent_thoughts.length > 0 return ( -
{ - e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - setCurrentLogItem(logItem) - if (runID) setShowMessageLogModal(true) - else if (isAgent) setShowAgentLogModal(true) - else setShowPromptLogModal(true) - }} - > - $['operation.log'], { ns: 'common' })}> +
+ $['operation.log'], { ns: 'common' })} + onClick={(e) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + onOpenLog(logItem) + }} + >
diff --git a/web/app/components/base/chat/chat/use-chat-layout.ts b/web/app/components/base/chat/chat/use-chat-layout.ts index 0bea24a3495..dc9fe7238c5 100644 --- a/web/app/components/base/chat/chat/use-chat-layout.ts +++ b/web/app/components/base/chat/chat/use-chat-layout.ts @@ -1,6 +1,6 @@ import type { ChatItem } from '../types' import { debounce } from 'es-toolkit/compat' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef } from 'react' type UseChatLayoutOptions = { chatList: ChatItem[] @@ -16,7 +16,6 @@ const setStyleValue = ( } export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { - const [width, setWidth] = useState(0) const chatContainerRef = useRef(null) const chatContainerInnerRef = useRef(null) const chatFooterRef = useRef(null) @@ -40,11 +39,6 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO }, [chatList.length]) const handleWindowResize = useCallback(() => { - if (chatContainerRef.current) { - const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8 - setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)) - } - if (chatContainerRef.current && chatFooterRef.current) setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`) @@ -172,7 +166,6 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO }, [handleWindowResize, sidebarCollapseState]) return { - width, chatContainerRef, chatContainerInnerRef, chatFooterRef, diff --git a/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx index fa8262e3c6f..79067e2ac09 100644 --- a/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/think-block.spec.tsx @@ -12,7 +12,6 @@ const renderWithContext = (children: React.ReactNode, isResponding: boolean = tr config={undefined} isResponding={isResponding} chatList={[]} - showPromptLog={false} questionIcon={undefined} answerIcon={undefined} onSend={undefined} @@ -99,7 +98,6 @@ describe('ThinkBlock', () => { config={undefined} isResponding={true} chatList={[]} - showPromptLog={false} questionIcon={undefined} answerIcon={undefined} onSend={undefined} @@ -129,7 +127,6 @@ describe('ThinkBlock', () => { config={undefined} isResponding={false} chatList={[]} - showPromptLog={false} questionIcon={undefined} answerIcon={undefined} onSend={undefined} diff --git a/web/app/components/base/markdown-blocks/think-block.stories.tsx b/web/app/components/base/markdown-blocks/think-block.stories.tsx index b8572de4dc7..afbb6cad499 100644 --- a/web/app/components/base/markdown-blocks/think-block.stories.tsx +++ b/web/app/components/base/markdown-blocks/think-block.stories.tsx @@ -17,7 +17,6 @@ const ThinkBlockDemo = ({ responding = false }: { responding?: boolean }) => { config={undefined} isResponding={isResponding} chatList={[]} - showPromptLog={false} questionIcon={undefined} answerIcon={undefined} onSend={undefined} diff --git a/web/app/components/base/prompt-log-modal/index.stories.tsx b/web/app/components/base/prompt-log-modal/index.stories.tsx index a57e50cd736..efd4f67176d 100644 --- a/web/app/components/base/prompt-log-modal/index.stories.tsx +++ b/web/app/components/base/prompt-log-modal/index.stories.tsx @@ -1,7 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { IChatItem } from '@/app/components/base/chat/chat/type' -import { useEffect } from 'react' -import { useStore } from '@/app/components/app/store' import PromptLogModal from '.' type PromptLogModalProps = React.ComponentProps @@ -26,18 +24,7 @@ const mockLogItem: IChatItem = { ], } -const usePromptLogMocks = () => { - useEffect(() => { - useStore.getState().setCurrentLogItem(mockLogItem) - return () => { - useStore.getState().setCurrentLogItem(undefined) - } - }, []) -} - const PromptLogPreview = (props: PromptLogModalProps) => { - usePromptLogMocks() - return (
diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index a4656289ef6..57a4b5f90d0 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -1,7 +1,9 @@ import type { ReactNode } from 'react' -import { render, screen } from '@testing-library/react' +import type { Shape } from '@/app/components/workflow/store/workflow' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { AppModeEnum } from '@/types/app' import WorkflowPanel from '../workflow-panel' @@ -13,23 +15,11 @@ type AppStoreState = { id?: string } } - currentLogItem?: { id: string } - setCurrentLogItem: (item?: { id: string }) => void - showMessageLogModal: boolean - setShowMessageLogModal: (show: boolean) => void - currentLogModalActiveTab?: string } -type WorkflowStoreState = { - historyWorkflowData?: Record - showDebugAndPreviewPanel: boolean - showChatVariablePanel: boolean - showGlobalVariablePanel: boolean -} +type WorkflowStoreState = Partial const mockUseIsChatMode = vi.fn() -const mockSetCurrentLogItem = vi.fn() -const mockSetShowMessageLogModal = vi.fn() let appStoreState: AppStoreState let workflowStoreState: WorkflowStoreState @@ -38,10 +28,6 @@ vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: AppStoreState) => T) => selector(appStoreState), })) -vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState), -})) - vi.mock('@/app/components/workflow/panel', () => ({ default: ({ components, @@ -88,26 +74,9 @@ vi.mock('next/dynamic', () => ({ }, })) -vi.mock('@/app/components/base/message-log-modal', () => ({ - default: ({ - currentLogItem, - defaultTab, - onCancel, - }: { - currentLogItem?: { id: string } - defaultTab?: string - onCancel: () => void - }) => ( -
- -
- ), +vi.mock('@/service/log', () => ({ + fetchRunDetail: vi.fn().mockResolvedValue(undefined), + fetchTracingList: vi.fn().mockResolvedValue({ data: [] }), })) vi.mock('@/app/components/workflow/panel/record', () => ({ @@ -149,11 +118,6 @@ describe('WorkflowPanel', () => { id: 'workflow-version-id', }, }, - currentLogItem: { id: 'log-1' }, - setCurrentLogItem: mockSetCurrentLogItem, - showMessageLogModal: false, - setShowMessageLogModal: mockSetShowMessageLogModal, - currentLogModalActiveTab: 'detail', } workflowStoreState = { historyWorkflowData: undefined, @@ -165,7 +129,7 @@ describe('WorkflowPanel', () => { }) it('should configure workflow version history urls and latest version id for the panel shell', async () => { - render() + renderWorkflowComponent(, { initialStoreState: workflowStoreState }) const panel = await screen.findByTestId('panel') expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows') @@ -179,37 +143,50 @@ describe('WorkflowPanel', () => { expect(panel).toHaveAttribute('data-app-mode', AppModeEnum.WORKFLOW) }) - it('should render and close the message log modal from the left panel slot', async () => { + it('should close the selected log and reopen on the default detail tab', async () => { + await import('@/app/components/base/message-log-modal') const user = userEvent.setup() - appStoreState = { - ...appStoreState, - showMessageLogModal: true, + const messageLogItem = { + id: 'log-1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'run-1', } - - render() - - expect(await screen.findByTestId('message-log-modal')).toHaveAttribute( - 'data-current-log-id', - 'log-1', + const { store } = renderWorkflowComponent(, { + initialStoreState: { messageLogItem }, + }) + expect(await screen.findByRole('tab', { name: 'runLog.detail' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await user.click(screen.getByRole('tab', { name: 'runLog.tracing' })) + expect(screen.getByRole('tab', { name: 'runLog.tracing' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => + expect(screen.queryByRole('tab', { name: 'runLog.detail' })).not.toBeInTheDocument(), + ) + act(() => store.getState().setMessageLogItem(messageLogItem)) + expect(await screen.findByRole('tab', { name: 'runLog.detail' })).toHaveAttribute( + 'aria-selected', + 'true', ) - expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail') - - await user.click(screen.getByRole('button', { name: /close-message-log/i })) - - expect(mockSetCurrentLogItem).toHaveBeenCalledWith() - expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) }) it('should switch right-side workflow panels based on chat mode and workflow state', async () => { workflowStoreState = { - historyWorkflowData: { id: 'history-1' }, + historyWorkflowData: { id: 'history-1', status: 'succeeded' }, showDebugAndPreviewPanel: true, showChatVariablePanel: true, showGlobalVariablePanel: true, } mockUseIsChatMode.mockReturnValue(true) - const { unmount } = render() + const { unmount } = renderWorkflowComponent(, { + initialStoreState: workflowStoreState, + }) expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument() expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument() @@ -220,7 +197,7 @@ describe('WorkflowPanel', () => { unmount() mockUseIsChatMode.mockReturnValue(false) - render() + renderWorkflowComponent(, { initialStoreState: workflowStoreState }) expect(await screen.findByTestId('record-panel')).toBeInTheDocument() expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument() diff --git a/web/app/components/workflow-app/components/workflow-header/index.tsx b/web/app/components/workflow-app/components/workflow-header/index.tsx index 2fe6b6325cf..79201e74d2a 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.tsx @@ -1,28 +1,22 @@ import type { HeaderProps } from '@/app/components/workflow/header' import { memo, useCallback, useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Header from '@/app/components/workflow/header' +import { useStore } from '@/app/components/workflow/store' import { useResetWorkflowVersionHistory } from '@/service/use-workflow' import { useIsChatMode } from '../../hooks/use-is-chat-mode' import ChatVariableTrigger from './chat-variable-trigger' import FeaturesTrigger from './features-trigger' const WorkflowHeader = () => { - const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore( - useShallow((state) => ({ - appDetail: state.appDetail, - setCurrentLogItem: state.setCurrentLogItem, - setShowMessageLogModal: state.setShowMessageLogModal, - })), - ) + const appDetail = useAppStore((state) => state.appDetail) + const setMessageLogItem = useStore((state) => state.setMessageLogItem) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const isChatMode = useIsChatMode() const handleClearLogAndMessageModal = useCallback(() => { - setCurrentLogItem() - setShowMessageLogModal(false) - }, [setCurrentLogItem, setShowMessageLogModal]) + setMessageLogItem(undefined) + }, [setMessageLogItem]) const viewHistoryProps = useMemo(() => { return { diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index 711feb3dda4..f12c0ebb9e4 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -1,7 +1,6 @@ import type { PanelProps } from '@/app/components/workflow/panel' import dynamic from 'next/dynamic' import { memo, useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Panel from '@/app/components/workflow/panel' import CommentsPanel from '@/app/components/workflow/panel/comments-panel' @@ -37,33 +36,16 @@ const GlobalVariablePanel = dynamic( ) const WorkflowPanelOnLeft = () => { - const { - currentLogItem, - setCurrentLogItem, - showMessageLogModal, - setShowMessageLogModal, - currentLogModalActiveTab, - } = useAppStore( - useShallow((state) => ({ - currentLogItem: state.currentLogItem, - setCurrentLogItem: state.setCurrentLogItem, - showMessageLogModal: state.showMessageLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - currentLogModalActiveTab: state.currentLogModalActiveTab, - })), - ) + const messageLogItem = useStore((state) => state.messageLogItem) + const setMessageLogItem = useStore((state) => state.setMessageLogItem) return ( <> - {showMessageLogModal && ( + {messageLogItem && ( { - setCurrentLogItem() - setShowMessageLogModal(false) - }} - defaultTab={currentLogModalActiveTab} + currentLogItem={messageLogItem} + onCancel={() => setMessageLogItem(undefined)} /> )} diff --git a/web/app/components/workflow/header/__tests__/view-workflow-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-workflow-history.spec.tsx new file mode 100644 index 00000000000..391ce77ef64 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/view-workflow-history.spec.tsx @@ -0,0 +1,27 @@ +import { act, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import ViewWorkflowHistory from '../view-workflow-history' + +describe('ViewWorkflowHistory log session', () => { + it('clears the selected message when history opens and closes with Escape', async () => { + const user = userEvent.setup() + const messageLogItem = { + id: 'message-1', + content: 'Answer', + isAnswer: true, + workflow_run_id: 'run-1', + } + const { store } = renderWorkflowFlowComponent(, { + nodes: [], + edges: [], + initialStoreState: { messageLogItem }, + }) + + await user.click(screen.getByRole('button', { name: 'workflowHistory.changeHistory.title' })) + expect(store.getState().messageLogItem).toBeUndefined() + act(() => store.getState().setMessageLogItem(messageLogItem)) + await user.keyboard('{Escape}') + expect(store.getState().messageLogItem).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index f80711d72b6..e66680368b2 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -5,13 +5,12 @@ import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgeni import { Separator } from '@langgenius/dify-ui/separator' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { useStore as useAppStore } from '@/app/components/app/store' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { useCollaborativeWorkflow } from '../hooks/use-collaborative-workflow' import { useNodesReadOnly } from '../hooks/use-workflow' import { useWorkflowHistory } from '../hooks/use-workflow-history' import TipPopup from '../operator/tip-popup' +import { useStore } from '../store' type ChangeHistoryEntry = { label: string @@ -30,13 +29,7 @@ const ViewWorkflowHistory = () => { const [open, setOpen] = useState(false) const { nodesReadOnly } = useNodesReadOnly() - const { setCurrentLogItem, setShowMessageLogModal } = useAppStore( - useShallow((state) => ({ - appDetail: state.appDetail, - setCurrentLogItem: state.setCurrentLogItem, - setShowMessageLogModal: state.setShowMessageLogModal, - })), - ) + const setMessageLogItem = useStore((state) => state.setMessageLogItem) const collaborativeWorkflow = useCollaborativeWorkflow() const { store, getHistoryLabel } = useWorkflowHistory() @@ -143,6 +136,7 @@ const ViewWorkflowHistory = () => { open={open} onOpenChange={(nextOpen) => { if (nodesReadOnly) return + setMessageLogItem(undefined) setOpen(nextOpen) }} > @@ -162,11 +156,6 @@ const ViewWorkflowHistory = () => { focusableWhenDisabled aria-label={t(($) => $['changeHistory.title'], { ns: 'workflowHistory' })} className="rounded-md" - onClick={() => { - if (nodesReadOnly) return - setCurrentLogItem() - setShowMessageLogModal(false) - }} > @@ -190,10 +179,6 @@ const ViewWorkflowHistory = () => { } - onClick={() => { - setCurrentLogItem() - setShowMessageLogModal(false) - }} />
({ - useStore: ( - selector: (state: { showMessageLogModal: boolean; appDetail: { id: string } }) => unknown, - ) => + useStore: (selector: (state: { appDetail: { id: string } }) => unknown) => selector({ - showMessageLogModal: mockShowMessageLogModal, appDetail: { id: 'app-1' }, }), })) @@ -386,7 +382,6 @@ const createData = (overrides: Record = {}) => ({ describe('workflow-panel index', () => { beforeEach(() => { vi.clearAllMocks() - mockShowMessageLogModal = false mockNodesReadOnly = false mockCanRun = true mockBuiltInTools = [ @@ -744,9 +739,7 @@ describe('workflow-panel index', () => { }) it('should stop a running node and offset when the log modal is visible', () => { - mockShowMessageLogModal = true - - const { container } = renderWorkflowComponent( + const { container, store } = renderWorkflowComponent( { initialStoreState: { nodePanelWidth: 480, otherPanelWidth: 240, + messageLogItem: { + id: 'log-1', + isAnswer: true, + content: 'answer', + workflow_run_id: 'run-1', + }, }, }, ) @@ -770,6 +769,8 @@ describe('workflow-panel index', () => { ) expect(mockHandleStop).toHaveBeenCalledTimes(1) + act(() => store.getState().setMessageLogItem(undefined)) + expect(root.style.right).toBe('0px') }) it('should resize the node panel with the keyboard, persist its width, and allow focus to leave', async () => { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 72ee7bef450..c7a6bafd4f5 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -12,7 +12,6 @@ import { useQueryState } from 'nuqs' import * as React from 'react' import { cloneElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import ResizeHandle from '@/app/components/base/resize-handle' import { UserAvatarList } from '@/app/components/base/user-avatar-list' @@ -104,11 +103,7 @@ const BasePanel: FC = ({ id, data, children }) => { }) const canEdit = useHooksStore((s) => s.accessControl.canEdit) const { isConnected, nodePanelPresence } = useCollaboration(appId as string, canEdit) - const { showMessageLogModal } = useAppStore( - useShallow((state) => ({ - showMessageLogModal: state.showMessageLogModal, - })), - ) + const messageLogItem = useStore((state) => state.messageLogItem) const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const currentUserPresence = useMemo(() => { @@ -519,7 +514,7 @@ const BasePanel: FC = ({ id, data, children }) => { const singleRunActionLabel = isSingleRunning ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflowDebug' }) : runThisStepLabel - const nodePanelRightOffset = !showMessageLogModal ? '4px' : `${otherPanelWidth + 8}px` + const nodePanelRightOffset = !messageLogItem ? '4px' : `${otherPanelWidth + 8}px` const isStartPlaceholderPanel = data.type === BlockEnum.StartPlaceholder const panelChildren = cloneElement(children as any, { id, @@ -549,12 +544,12 @@ const BasePanel: FC = ({ id, data, children }) => {
{ expect(screen.getByText('Question 3')).toBeInTheDocument() expect(screen.getByText('Answer 3')).toBeInTheDocument() expect(screen.queryByText('Question 2')).not.toBeInTheDocument() + expect(screen.queryAllByRole('button', { name: 'common.operation.log' })).toHaveLength(0) await user.click(screen.getByRole('button', { name: 'Previous' })) @@ -91,7 +92,14 @@ describe('ChatRecord', () => { mockFetchConversationMessages.mockResolvedValue({ data: [ - { id: 'msg-1', query: 'Question 1', answer: 'Answer 1', metadata: {}, message_files: [] }, + { + id: 'msg-1', + query: 'Question 1', + answer: 'Answer 1', + metadata: {}, + message_files: [], + workflow_run_id: 'run-1', + }, ], } as never) @@ -101,11 +109,14 @@ describe('ChatRecord', () => { }) await screen.findByText('Question 1') + await user.click(screen.getByRole('button', { name: 'common.operation.log' })) + expect(store.getState().messageLogItem?.workflow_run_id).toBe('run-1') await user.click(container.querySelector('.size-6.cursor-pointer') as HTMLElement) expect(handleLoadBackupDraft).toHaveBeenCalledTimes(1) expect(store.getState().historyWorkflowData).toBeUndefined() + expect(store.getState().messageLogItem).toBeUndefined() }) it('stops loading when conversation fetching fails', async () => { diff --git a/web/app/components/workflow/panel/chat-record/index.tsx b/web/app/components/workflow/panel/chat-record/index.tsx index ce39d4d9643..b25745ff848 100644 --- a/web/app/components/workflow/panel/chat-record/index.tsx +++ b/web/app/components/workflow/panel/chat-record/index.tsx @@ -53,6 +53,7 @@ const ChatRecord = () => { const [threadChatItems, setThreadChatItems] = useState([]) const appDetail = useAppStore((s) => s.appDetail) const workflowStore = useWorkflowStore() + const setMessageLogItem = useStore((state) => state.setMessageLogItem) const { handleLoadBackupDraft } = useWorkflowRun() const historyWorkflowData = useStore((s) => s.historyWorkflowData) const currentConversationID = historyWorkflowData?.conversation_id @@ -106,7 +107,10 @@ const ChatRecord = () => { className="flex size-6 cursor-pointer items-center justify-center" onClick={() => { handleLoadBackupDraft() - workflowStore.setState({ historyWorkflowData: undefined }) + workflowStore.setState({ + historyWorkflowData: undefined, + messageLogItem: undefined, + }) }} > @@ -128,7 +132,8 @@ const ChatRecord = () => { chatNode={} noChatInput allToolIcons={{}} - showPromptLog + onOpenLog={setMessageLogItem} + canOpenLog={(item) => !!item.workflow_run_id} switchSibling={switchSibling} noSpacing chatAnswerContainerInner="pr-2!" diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx b/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx index 880854d7e5c..b7febabf7ea 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/chat-wrapper.spec.tsx @@ -1,5 +1,6 @@ import type { ChatWrapperRefType } from '../index' import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -29,6 +30,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ onHumanInputFormSubmit, onFeatureBarClick, speechToTextTarget, + onOpenLog, }: { chatNode: React.ReactNode inputDisabled?: boolean @@ -46,6 +48,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ ) => Promise onFeatureBarClick?: (state: boolean) => void speechToTextTarget?: SpeechToTextTarget + onOpenLog?: (item: IChatItem) => void }) => (
({ + {chatNode}
), @@ -208,11 +218,15 @@ describe('ChatWrapper', () => { }) }) + await userEvent.click(screen.getByRole('button', { name: 'open-log' })) + expect(store.getState().messageLogItem?.workflow_run_id).toBe('run-1') + act(() => { chatRef.current?.handleRestart() }) expect(chatState.handleRestart).toHaveBeenCalledTimes(1) + expect(store.getState().messageLogItem).toBeUndefined() expect(store.getState().inputs).toEqual({ name: 'Ada', }) diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index cc4535d2de9..b6f6237c6e6 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -40,6 +40,7 @@ const ChatWrapper = ({ const startVariables = startNode?.data.variables const appDetail = useAppStore((s) => s.appDetail) const workflowStore = useWorkflowStore() + const setMessageLogItem = useStore((state) => state.setMessageLogItem) const inputs = useStore((s) => s.inputs) const setInputs = useStore((s) => s.setInputs) @@ -101,8 +102,9 @@ const ChatWrapper = ({ const handleRestartChat = useCallback(() => { handleRestart() + setMessageLogItem(undefined) setInputs(initialInputs) - }, [handleRestart, setInputs, initialInputs]) + }, [handleRestart, setInputs, initialInputs, setMessageLogItem]) const doSend: OnSend = useCallback( (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { @@ -223,7 +225,8 @@ const ChatWrapper = ({ } noSpacing suggestedQuestions={suggestedQuestions} - showPromptLog + onOpenLog={setMessageLogItem} + canOpenLog={(item) => !!item.workflow_run_id} chatAnswerContainerInner="pr-2!" switchSibling={doSwitchSibling} inputDisabled={inputDisabled} diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 668b04c5ff5..b334a86be6e 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import type { IChatItem } from '@/app/components/base/chat/chat/type' export type WorkflowContextMenuTarget = | { type: 'panel' } @@ -7,6 +8,8 @@ export type WorkflowContextMenuTarget = | { type: 'edge'; edgeId: string } export type PanelSliceShape = { + messageLogItem?: IChatItem & { workflow_run_id: string } + setMessageLogItem: (item?: IChatItem) => void panelWidth: number setPanelWidth: (width: number) => void showFeaturesPanel: boolean @@ -34,6 +37,16 @@ export type PanelSliceShape = { } export const createPanelSlice: StateCreator = (set) => ({ + messageLogItem: undefined, + setMessageLogItem: (item) => { + if (!item) { + set({ messageLogItem: undefined }) + return + } + if (item.workflow_run_id) { + set({ messageLogItem: { ...item, workflow_run_id: item.workflow_run_id } }) + } + }, panelWidth: 420, setPanelWidth: (width) => set((state) => (state.panelWidth === width ? state : { panelWidth: width })), diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index 0b91fc71118..c8498459dc2 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -36,6 +36,7 @@ import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } type IDebugConfiguration = { readonly?: boolean canTestAndRun?: boolean + onOpenFeatures: () => void appId: string isTrailFinished: boolean mode: AppModeEnum @@ -119,6 +120,7 @@ const DebugConfigurationContext = createContext({ readonly: false, canTestAndRun: false, appId: '', + onOpenFeatures: noop, isTrailFinished: false, mode: AppModeEnum.CHAT, modelModeType: ModelModeType.chat, diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx index aee2352d642..13cc127a44b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from 'react' import type { AgentPreviewChatController } from '../chat-conversation' import type { AgentChatRuntimeEmptyStateProps } from '../chat-runtime' +import type { ChatProps } from '@/app/components/base/chat/chat' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' @@ -62,7 +63,7 @@ vi.mock('next/dynamic', async () => { onStopResponding: () => void sendButtonLabel?: string sendButtonLoading?: boolean - showPromptLog?: boolean + onOpenLog?: ChatProps['onOpenLog'] footerNotice?: string chatNode?: ReactNode chatList?: Array<{ @@ -89,7 +90,7 @@ vi.mock('next/dynamic', async () => { data-send-button-label={props.sendButtonLabel ?? ''} data-send-button-loading={String(!!props.sendButtonLoading)} data-answer-action-position={props.answerActionPosition ?? 'auto'} - data-show-prompt-log={String(!!props.showPromptLog)} + data-can-open-log={String(!!props.onOpenLog)} data-footer-notice={props.footerNotice ?? ''} data-no-chat-input={String(!!props.noChatInput)} data-show-regenerate={String(!!props.showRegenerate)} @@ -1320,7 +1321,7 @@ describe('AgentPreviewChat', () => { await waitFor(() => expect(screen.getByRole('region', { name: 'chat' })).toBeInTheDocument()) expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute( - 'data-show-prompt-log', + 'data-can-open-log', 'false', ) }) diff --git a/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx index 5a4a3d981a9..a35347618c2 100644 --- a/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx @@ -147,7 +147,6 @@ export function AgentLogDetailPanel({ chatList={chatList} noChatInput hideProcessDetail - hideLogModal chatContainerInnerClassName="px-3" onFeedback={handleFeedback} />