refactor(web): scope app UI sessions to their owners (#42984)

This commit is contained in:
yyh authored and GitHub committed 2026-09-28 06:39:19 +00:00
1 parent a4a18e0652
commit 52dcf83de6
65 files changed
+1102 -1363

No files matched your search

@@ -1,8 +1,8 @@
import * as React from 'react'
import Configuration from '@/app/components/app/configuration'
const IConfiguration = async () => {
return <Configuration />
const IConfiguration = async ({ params }: { params: Promise<{ appId: string }> }) => {
const { appId } = await params
return <Configuration key={appId} />
}
export default IConfiguration
@@ -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')
})
})
@@ -98,6 +98,7 @@ const createDeletedAgentTool = (providerId: string): AgentTool => ({
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
appId: 'app-1',
onOpenFeatures: vi.fn(),
isTrailFinished: false,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
@@ -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) => (
<section key={item.id} aria-label={item.content}>
<Log logItem={item} onOpenLog={onOpenLog} />
</section>
))}
</>
)
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
}) => (
<div
@@ -153,13 +176,15 @@ vi.mock('@/app/components/app/text-generate/item', () => ({
data-message-id={messageId || ''}
>
{content}
<LogActions onOpenLog={onOpenLog} />
</div>
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="agent-log-modal">
default: ({ onCancel, currentLogItem }: { onCancel: () => void; currentLogItem: IChatItem }) => (
<div data-testid="agent-log-modal" role="dialog" aria-label="Agent log">
{currentLogItem.content}
<button type="button" data-testid="agent-log-cancel" onClick={onCancel}>
Cancel
</button>
@@ -191,8 +216,9 @@ vi.mock('@/app/components/base/features/hooks', () => ({
}))
vi.mock('@/app/components/base/prompt-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="prompt-log-modal">
default: ({ onCancel, currentLogItem }: { onCancel: () => void; currentLogItem: IChatItem }) => (
<div data-testid="prompt-log-modal" role="dialog" aria-label="Prompt log">
{currentLogItem.log?.map((item) => item.text).join(' ')}
<button type="button" data-testid="prompt-log-cancel" onClick={onCancel}>
Cancel
</button>
@@ -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
}) => (
<div data-testid="debug-with-multiple-model">
<LogActions onOpenLog={onOpenLog} />
<button type="button" data-testid="multiple-check-can-send" onClick={() => checkCanSend()}>
Check
</button>
@@ -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<HTMLDivElement>
ref?: React.Ref<{ handleRestart: () => void }>
}) {
React.useImperativeHandle(ref, () => ({
@@ -294,7 +327,8 @@ vi.mock('../debug-with-single-model', () => {
}))
return (
<div data-testid="debug-with-single-model">
<div ref={chatContainerRef} data-testid="debug-with-single-model">
<LogActions onOpenLog={onOpenLog} />
<button type="button" data-testid="single-check-can-send" onClick={() => checkCanSend()}>
Check
</button>
@@ -309,6 +343,7 @@ const createContextValue = (overrides: Partial<DebugContextValue> = {}): 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.
<ConfigContext.Provider value={createContextValue(options.contextValue)}>
<Debug {...props} />
</ConfigContext.Provider>,
)
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()
},
)
})
@@ -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<typeof import('@tanstack/react-query')>()),
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<HTMLDivElement>
}) => (
<Chat
chatList={[
{
id: 'answer',
isAnswer: true,
content: 'Answer',
log: [{ role: 'user', text: 'Measured prompt' }],
},
]}
onOpenLog={onOpenLog}
chatContainerRef={chatContainerRef}
chatContainerClassName="debug-log-scrollbar"
noChatInput
/>
),
}))
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 }) => (
<div className="group">
<div style={{ height: 900 }}>{item.content}</div>
<Operation
item={item}
question=""
index={0}
maxSize={500}
contentWidth={100}
hasWorkflowProcess={false}
/>
</div>
),
}
})
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.
<ConfigContext.Provider
value={{
...defaults,
canTestAndRun: true,
modelConfig: {
...defaults.modelConfig,
configs: { prompt_template: '', prompt_variables: [] },
},
}}
>
<FeaturesProvider>
<style>{`.debug-log-scrollbar { scrollbar-gutter: stable; } .debug-log-scrollbar::-webkit-scrollbar { width: 16px; } .debug-double-gutter .debug-log-scrollbar { scrollbar-gutter: stable both-edges; }`}</style>
<section
aria-label="Debug pane"
className={doubleGutter ? 'debug-double-gutter' : undefined}
style={{
position: 'fixed',
top: 64,
right: 8,
bottom: 16,
width,
display: 'flex',
flexDirection: 'column',
}}
>
<Debug
isPreview
onSetting={vi.fn()}
inputs={{}}
modelParameterParams={{ setModel: vi.fn(), onCompletionParamsChange: vi.fn() }}
debugWithMultipleModel={false}
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
/>
</section>
</FeaturesProvider>
</ConfigContext.Provider>
)
}
it('keeps the log beside the actual scrolling chat after resizing its pane', async () => {
await page.viewport(1440, 900)
const view = await render(<DebugSession width={540} />)
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(<DebugSession width={540} doubleGutter />)
await expect.poll(() => chat.clientWidth).toBeLessThan(initialChatWidth)
await expect
.poll(() => title.getBoundingClientRect().left - initialTitleLeft)
.toBe(initialChatWidth - chat.clientWidth)
await view.rerender(<DebugSession width={420} doubleGutter />)
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()
})
@@ -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 (
<div data-testid="chat-component">
{props?.chatList[0] && <Log logItem={props.chatList[0]} onOpenLog={props.onOpenLog} />}
<span data-testid="chat-list-length">{props?.chatList?.length || 0}</span>
<span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span>
<button
@@ -209,6 +215,25 @@ describe('ChatItem', () => {
createDefaultMocks()
})
it('opens this model result through the owning debug session', async () => {
const user = userEvent.setup()
const onOpenLog = vi.fn()
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
onDebugWithMultipleModelChange={vi.fn()}
onOpenLog={onOpenLog}
>
<ChatItem modelAndParameter={createModelAndParameter()} />
</DebugWithMultipleModelContextProvider>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 'msg-1', isAnswer: true }),
)
})
describe('rendering', () => {
it('should render Chat component when chatList is not empty', () => {
renderComponent()
@@ -7,7 +7,6 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Inputs, ModelConfig } from '@/models/debug'
import type { PromptVariable } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
DEFAULT_AGENT_SETTING,
DEFAULT_CHAT_PROMPT_CONFIG,
@@ -161,6 +160,7 @@ const createModelConfig = (promptVariables: PromptVariableWithMeta[] = []): Mode
})
type DebugConfiguration = {
onOpenFeatures: () => void
mode: AppModeEnum
inputs: Inputs
modelConfig: ModelConfig
@@ -171,6 +171,7 @@ type DebugConfiguration = {
const createDebugConfiguration = (
overrides: Partial<DebugConfiguration> = {},
): DebugConfiguration => ({
onOpenFeatures: vi.fn(),
mode: AppModeEnum.CHAT,
inputs: {},
modelConfig: createModelConfig(),
@@ -193,6 +194,7 @@ const createProps = (
overrides: Partial<DebugWithMultipleModelContextType> = {},
): DebugWithMultipleModelContextType => ({
multipleModelConfigs: [createModelAndParameter()],
onOpenLog: vi.fn(),
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
...overrides,
@@ -489,7 +491,7 @@ describe('DebugWithMultipleModel', () => {
expect(capturedChatInputProps?.showFileUpload).toBe(false)
expect(capturedChatInputProps?.speechToTextConfig).toEqual(featureState.features.speech2text)
expect(capturedChatInputProps?.visionConfig).toEqual(featureState.features.file)
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
expect(debugConfiguration.onOpenFeatures).toHaveBeenCalledTimes(1)
})
it('should allow sending but disable feature configuration when configuration is readonly and test/run is allowed', () => {
@@ -1,7 +1,11 @@
import type { ModelAndParameter } from '../../types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { render, 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 { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types'
import { DebugWithMultipleModelContextProvider } from '../context-provider'
import TextGenerationItem from '../text-generation-item'
const mockUseDebugConfigurationContext = vi.fn()
@@ -15,6 +19,7 @@ const { mockToastError } = vi.hoisted(() => ({
}))
let capturedTextGenerationProps: {
onOpenLog: (item: IChatItem) => void
content: string
isLoading: boolean
isResponding: boolean
@@ -63,6 +68,12 @@ vi.mock('@/app/components/app/text-generate/item', () => ({
capturedTextGenerationProps = props
return (
<div data-testid="text-generation">
{props && (
<Log
logItem={{ id: props.messageId!, isAnswer: true, content: props.content }}
onOpenLog={props.onOpenLog}
/>
)}
<span data-testid="content">{props?.content}</span>
<span data-testid="is-loading">{props?.isLoading ? 'yes' : 'no'}</span>
<span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span>
@@ -169,6 +180,25 @@ describe('TextGenerationItem', () => {
createDefaultMocks()
})
it('opens this model result through the owning debug session', async () => {
const user = userEvent.setup()
const onOpenLog = vi.fn()
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
onDebugWithMultipleModelChange={vi.fn()}
onOpenLog={onOpenLog}
>
<TextGenerationItem modelAndParameter={createModelAndParameter()} />
</DebugWithMultipleModelContextProvider>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 'msg-123', isAnswer: true }),
)
})
describe('rendering', () => {
it('should render TextGeneration component', () => {
renderComponent()
@@ -26,11 +26,13 @@ import {
import { matchesProviderReference } from '@/utils/provider-reference'
import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../hooks'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
import { useDebugWithMultipleModelContext } from './context'
type ChatItemProps = {
modelAndParameter: ModelAndParameter
}
const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => {
const { onOpenLog } = useDebugWithMultipleModelContext()
const { data: userProfile } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile,
@@ -164,10 +166,9 @@ const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => {
chatFooterClassName="p-4 pb-0"
suggestedQuestions={suggestedQuestions}
onSend={doSend}
showPromptLog
onOpenLog={onOpenLog}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
hideLogModal
noSpacing
/>
)
@@ -13,6 +13,7 @@ export const DebugWithMultipleModelContextProvider = ({
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
onOpenLog,
}: DebugWithMultipleModelContextProviderProps) => {
return (
<DebugWithMultipleModelContext.Provider
@@ -21,6 +22,7 @@ export const DebugWithMultipleModelContextProvider = ({
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
onOpenLog,
}}
>
{children}
@@ -1,6 +1,7 @@
'use client'
import type { ModelAndParameter } from '../types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector'
@@ -8,11 +9,13 @@ export type DebugWithMultipleModelContextType = {
multipleModelConfigs: ModelAndParameter[]
onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void
onDebugWithMultipleModelChange: (singleModelConfig: ModelAndParameter) => void
onOpenLog: (item: IChatItem) => void
checkCanSend?: () => boolean
}
export const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContextType>({
multipleModelConfigs: [],
onOpenLog: noop,
onMultipleModelConfigsChange: noop,
onDebugWithMultipleModelChange: noop,
})
@@ -3,7 +3,6 @@ import type { DebugWithMultipleModelContextType } from './context'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { memo, useCallback, useMemo } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
import { useFeatures } from '@/app/components/base/features/hooks'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
@@ -22,6 +21,7 @@ const DebugWithMultipleModel = () => {
appId,
readonly,
canTestAndRun = false,
onOpenFeatures,
} = useDebugConfigurationContext()
const speech2text = useFeatures((s) => s.features.speech2text)
const file = useFeatures((s) => s.features.file)
@@ -94,7 +94,6 @@ const DebugWithMultipleModel = () => {
[twoLine, threeLine, fourLine],
)
const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal)
const inputsForm = modelConfig.configs.prompt_variables
.filter((item) => item.type !== 'api')
.map((item) => ({
@@ -133,7 +132,7 @@ const DebugWithMultipleModel = () => {
showFeatureBar
featureBarReadonly={readonly}
showFileUpload={false}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
onFeatureBarClick={onOpenFeatures}
onSend={handleSend}
speechToTextConfig={speech2text as any}
speechToTextTarget={{ type: 'consoleApp', appId }}
@@ -154,6 +153,7 @@ const DebugWithMultipleModelWrapper: FC<DebugWithMultipleModelContextType> = ({
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
onOpenLog,
}) => {
return (
<DebugWithMultipleModelContextProvider
@@ -161,6 +161,7 @@ const DebugWithMultipleModelWrapper: FC<DebugWithMultipleModelContextType> = ({
multipleModelConfigs={multipleModelConfigs}
onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
checkCanSend={checkCanSend}
onOpenLog={onOpenLog}
>
<DebugWithMultipleModelMemoed />
</DebugWithMultipleModelContextProvider>
@@ -18,11 +18,13 @@ import { consoleQuery } from '@/service/console'
import { AppSourceType } from '@/service/share'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import { useDebugWithMultipleModelContext } from './context'
type TextGenerationItemProps = {
modelAndParameter: ModelAndParameter
}
const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter }) => {
const { onOpenLog } = useDebugWithMultipleModelContext()
const {
isAdvancedMode,
modelConfig,
@@ -141,6 +143,7 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter })
messageId={messageId}
isError={false}
onRetry={noop}
onOpenLog={onOpenLog}
inSidePanel
/>
)
@@ -6,7 +6,6 @@ import type { Collection } from '@/app/components/tools/types'
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { createRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { CollectionType } from '@/app/components/tools/types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { PromptMode } from '@/models/debug'
@@ -134,6 +133,7 @@ const mockDebugConfigContext = {
readonly: false,
canTestAndRun: true,
appId: 'test-app-id',
onOpenFeatures: vi.fn(),
isTrailFinished: false,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
@@ -550,6 +550,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
// Tests
// ============================================================================
const logProps = { onOpenLog: vi.fn(), chatContainerRef: createRef<HTMLDivElement>() }
describe('DebugWithSingleModel', () => {
let ref: RefObject<DebugWithSingleModelRefType | null>
@@ -581,6 +583,7 @@ describe('DebugWithSingleModel', () => {
render(
<DebugWithSingleModel
{...logProps}
ref={ref as RefObject<DebugWithSingleModelRefType>}
checkCanSend={checkCanSend}
/>,
@@ -597,6 +600,7 @@ describe('DebugWithSingleModel', () => {
render(
<DebugWithSingleModel
{...logProps}
ref={ref as RefObject<DebugWithSingleModelRefType>}
checkCanSend={checkCanSend}
/>,
@@ -629,6 +633,7 @@ describe('DebugWithSingleModel', () => {
render(
<DebugWithSingleModel
{...logProps}
ref={ref as RefObject<DebugWithSingleModelRefType>}
checkCanSend={checkCanSend}
/>,
@@ -648,11 +653,13 @@ describe('DebugWithSingleModel', () => {
// User Interactions
describe('User Interactions', () => {
it('should open feature configuration when feature bar is clicked', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
fireEvent.click(screen.getByTestId('feature-bar-button'))
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
expect(mockDebugConfigContext.onOpenFeatures).toHaveBeenCalledTimes(1)
})
it('should allow sending but disable feature configuration when configuration is readonly and test/run is allowed', async () => {
@@ -662,7 +669,9 @@ describe('DebugWithSingleModel', () => {
canTestAndRun: true,
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-input')).not.toHaveAttribute('readonly')
expect(screen.getByTestId('feature-bar-button')).toBeDisabled()
@@ -684,7 +693,9 @@ describe('DebugWithSingleModel', () => {
canTestAndRun: false,
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-input')).toHaveAttribute('readonly')
expect(screen.getByTestId('chat-input')).toBeDisabled()
@@ -704,7 +715,9 @@ describe('DebugWithSingleModel', () => {
opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
fireEvent.click(screen.getByTestId('send-button'))
@@ -727,7 +740,9 @@ describe('DebugWithSingleModel', () => {
},
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
fireEvent.click(screen.getByTestId('send-button'))
@@ -741,13 +756,17 @@ describe('DebugWithSingleModel', () => {
})
it('should handle model without vision support', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
it('should handle missing model in provider list', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -770,7 +789,9 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
// Component should render successfully with filtered variables
// Component should render successfully with filtered variables
@@ -788,7 +809,9 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -797,7 +820,9 @@ describe('DebugWithSingleModel', () => {
// Tool Icons Tests
describe('Tool Icons', () => {
it('should map tool icons from collection list', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -815,7 +840,9 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -844,7 +871,9 @@ describe('DebugWithSingleModel', () => {
collectionList: [],
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -858,7 +887,9 @@ describe('DebugWithSingleModel', () => {
inputs: {} as any,
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -874,7 +905,9 @@ describe('DebugWithSingleModel', () => {
},
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -885,7 +918,9 @@ describe('DebugWithSingleModel', () => {
completionParams: {} as any,
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-component'))!.toBeInTheDocument()
})
@@ -894,7 +929,9 @@ describe('DebugWithSingleModel', () => {
// Imperative Handle Tests
describe('Imperative Handle', () => {
it('should expose handleRestart method via ref', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(ref.current).not.toBeNull()
expect(ref.current?.handleRestart).toBeDefined()
@@ -902,7 +939,9 @@ describe('DebugWithSingleModel', () => {
})
it('should call handleRestart when invoked via ref', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
act(() => {
ref.current?.handleRestart()
@@ -925,7 +964,9 @@ describe('DebugWithSingleModel', () => {
file: { enabled: true },
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
fireEvent.click(screen.getByTestId('send-with-document'))
@@ -957,7 +998,9 @@ describe('DebugWithSingleModel', () => {
file: { enabled: true },
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
fireEvent.click(screen.getByTestId('send-with-files'))
@@ -1,11 +1,11 @@
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { Ref } from 'react'
import type { IChatItem, InputForm } from '@/app/components/base/chat/chat/type'
import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { toast } from '@/app/components/app/configuration/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
@@ -22,6 +22,8 @@ import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../
type DebugWithSingleModelProps = {
checkCanSend?: () => boolean
onOpenLog: (item: IChatItem) => void
chatContainerRef: Ref<HTMLDivElement>
}
export type DebugWithSingleModelRefType = {
handleRestart: () => void
@@ -29,6 +31,8 @@ export type DebugWithSingleModelRefType = {
const DebugWithSingleModel = ({
ref,
checkCanSend,
onOpenLog,
chatContainerRef,
}: DebugWithSingleModelProps & {
ref: React.RefObject<DebugWithSingleModelRefType>
}) => {
@@ -39,6 +43,7 @@ const DebugWithSingleModel = ({
const {
readonly,
canTestAndRun = false,
onOpenFeatures,
modelConfig,
appId,
inputs,
@@ -173,8 +178,6 @@ const DebugWithSingleModel = ({
}
}, [handleRestart])
const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal)
return (
<Chat
readonly={debugInputReadonly}
@@ -187,7 +190,7 @@ const DebugWithSingleModel = ({
showFeatureBar
featureBarReadonly={readonly}
showFileUpload={false}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
onFeatureBarClick={onOpenFeatures}
inputDisabled={!canTestAndRun}
suggestedQuestions={suggestedQuestions}
onSend={doSend}
@@ -196,7 +199,8 @@ const DebugWithSingleModel = ({
onRegenerate={doRegenerate}
switchSibling={(siblingMessageId) => setTargetMessageId(siblingMessageId)}
onStopResponding={handleStop}
showPromptLog
onOpenLog={onOpenLog}
chatContainerRef={chatContainerRef}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
onAnnotationEdited={canManageAnnotation ? handleAnnotationEdited : undefined}
@@ -3,6 +3,7 @@ import type { AppModelConfigPayload } from '@dify/contracts/api/console/apps/typ
import type { FC } from 'react'
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
import type { ModelAndParameter } from './types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Inputs } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
@@ -25,11 +26,9 @@ import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
import { toast } from '@/app/components/app/configuration/toast'
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 { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
@@ -407,33 +406,40 @@ const Debug: FC<IDebug> = ({
handleVisionConfigInMultipleModel()
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
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, setWidth] = useState(0)
const [selectedLogItem, setSelectedLogItem] = useState<IChatItem>()
const [logModalWidth, setLogModalWidth] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const singleChatContainerRef = useRef<HTMLDivElement>(null)
const adjustModalWidth = () => {
if (ref.current) setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
const handleOpenLog = (item: IChatItem) => {
const container =
debugWithMultipleModel || mode === AppModeEnum.COMPLETION
? ref.current
: singleChatContainerRef.current
if (container) setLogModalWidth(document.body.clientWidth - (container.clientWidth + 16) - 8)
setSelectedLogItem(item)
}
useEffect(() => {
adjustModalWidth()
}, [])
if (!selectedLogItem) return
const container =
debugWithMultipleModel || mode === AppModeEnum.COMPLETION
? ref.current
: singleChatContainerRef.current
if (!container) return
const adjustModalWidth = () => {
setLogModalWidth(document.body.clientWidth - (container.clientWidth + 16) - 8)
}
const observer = new ResizeObserver(adjustModalWidth)
observer.observe(container)
window.addEventListener('resize', adjustModalWidth)
return () => {
observer.disconnect()
window.removeEventListener('resize', adjustModalWidth)
}
}, [debugWithMultipleModel, mode, selectedLogItem])
const [expanded, setExpanded] = useState(true)
@@ -545,27 +551,8 @@ const Debug: FC<IDebug> = ({
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={handleChangeToSingleModel}
checkCanSend={checkCanSend}
onOpenLog={handleOpenLog}
/>
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</div>
)}
{!debugWithMultipleModel && (
@@ -597,7 +584,12 @@ const Debug: FC<IDebug> = ({
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
<DebugWithSingleModel ref={debugWithSingleModelRef} checkCanSend={checkCanSend} />
<DebugWithSingleModel
ref={debugWithSingleModelRef}
checkCanSend={checkCanSend}
onOpenLog={handleOpenLog}
chatContainerRef={singleChatContainerRef}
/>
</div>
)}
{/* Text Generation */}
@@ -620,6 +612,7 @@ const Debug: FC<IDebug> = ({
isError={false}
onRetry={noop}
siteInfo={null}
onOpenLog={handleOpenLog}
/>
</div>
</>
@@ -637,21 +630,25 @@ const Debug: FC<IDebug> = ({
)}
</>
)}
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{isShowCannotQueryDataset && (
<CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} />
)}
</div>
)}
{selectedLogItem &&
(selectedLogItem.agent_thoughts?.length ? (
<AgentLogModal
width={logModalWidth}
currentLogItem={selectedLogItem}
onCancel={() => setSelectedLogItem(undefined)}
/>
) : (
<PromptLogModal
width={logModalWidth}
currentLogItem={selectedLogItem}
onCancel={() => setSelectedLogItem(undefined)}
/>
))}
{isShowFormattingChangeConfirm && (
<FormattingChanged onConfirm={handleConfirm} onCancel={handleCancel} />
)}
@@ -25,7 +25,6 @@ const renderHook = (callback: () => ReturnType<typeof useConfiguration>) => {
}
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)
})
})
@@ -12,6 +12,7 @@ type ModelConfiguration = ReturnType<typeof useModelConfigurationState>
type ContextBase = Pick<
DebugConfigurationValue,
| 'appId'
| 'onOpenFeatures'
| 'canReturnToSimpleMode'
| 'canTestAndRun'
| 'collectionList'
@@ -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,
}
}
@@ -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,
@@ -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', () => {
@@ -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<IPromptValuePanelProps> = ({
const {
readonly,
canTestAndRun = false,
onOpenFeatures,
modelModeType,
modelConfig,
setInputs,
@@ -131,8 +131,6 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
setInputs(newInputs)
}
const setShowAppConfigureFeaturesModal = useAppStore((s) => s.setShowAppConfigureFeaturesModal)
return (
<>
<div className="relative z-1 mx-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-md">
@@ -323,7 +321,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
<FeatureBar
showFileUpload={false}
isChatMode={appType !== AppModeEnum.COMPLETION}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
onFeatureBarClick={onOpenFeatures}
disabled={readonly}
hideEditEntrance={readonly}
/>
+156 -154
View File
@@ -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<string, unknown> | undefined
let mockCompletionConversationDetail: Record<string, unknown> | undefined
let mockShowMessageLogModal = false
let mockShowPromptLogModal = false
let mockShowAgentLogModal = false
let mockCurrentLogItem: Record<string, unknown> | 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<string, unknown>) => 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: () => <div>loading</div>,
}))
@@ -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<boolean>
}) => (
<div data-testid="text-generation" data-hide-log-action={String(hideLogAction)}>
<div data-testid="text-generation" data-log-enabled={String(!!onOpenLog)}>
<div>{content}</div>
<button onClick={() => void onFeedback({ rating: 'like', content: 'great' })}>
completion-feedback
@@ -121,8 +101,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited,
onAnnotationRemoved,
switchSibling,
hideLogModal,
showPromptLog,
onOpenLog,
}: {
chatList: Array<{ id: string }>
onFeedback: (mid: string, value: { rating: string; content?: string }) => Promise<boolean>
@@ -136,15 +115,78 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited: (query: string, answer: string, index: number) => void
onAnnotationRemoved: (index: number) => Promise<boolean>
switchSibling: (siblingMessageId: string) => void
hideLogModal?: boolean
showPromptLog?: boolean
onOpenLog?: (item: IChatItem) => void
}) => (
<div
data-testid="chat-panel"
data-hide-log-modal={String(hideLogModal)}
data-show-prompt-log={String(showPromptLog)}
>
<div data-testid="chat-panel" data-log-enabled={String(!!onOpenLog)}>
<div>{chatList.length}</div>
{onOpenLog && (
<>
<button
onClick={() =>
onOpenLog({
id: 'log-1',
content: 'answer',
isAnswer: true,
workflow_run_id: 'run-1',
agent_thoughts: [
{
id: 'thought',
tool: '',
thought: 'thinking',
tool_input: '',
message_id: 'log',
conversation_id: 'conversation-1',
observation: '',
position: 0,
},
],
log: [],
})
}
>
open-workflow-log
</button>
<button
onClick={() =>
onOpenLog({
id: 'log-2',
content: 'answer',
isAnswer: true,
agent_thoughts: [
{
id: 'thought',
tool: '',
thought: 'thinking',
tool_input: '',
message_id: 'log',
conversation_id: 'conversation-1',
observation: '',
position: 0,
},
],
log: [],
})
}
>
open-agent-log
</button>
<button
onClick={() =>
onOpenLog({
id: 'log-3',
content: 'answer',
isAnswer: true,
log: [{ role: 'user', text: 'prompt' }],
})
}
>
open-prompt-log
</button>
<button onClick={() => onOpenLog({ id: 'log-4', content: 'answer', isAnswer: true })}>
open-no-log
</button>
</>
)}
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>
chat-feedback
</button>
@@ -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) => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<QueryClientTestProvider queryClient={queryClient}>
<ConversationList
appDetail={appDetail}
logs={{ data: [], has_more: false, limit: 20, page: 1, total: 0 }}
onRefresh={mockOnRefresh}
/>
</QueryClientTestProvider>
</NuqsTestingAdapter>
)
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()
})
})
+22 -70
View File
@@ -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<typeof useAppStore.getState>
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<IChatItem>()
const { t } = useTranslation(['appLog', 'common'])
const [hasMore, setHasMore] = useState(true)
const [varValues, setVarValues] = useState<Record<string, string>>({})
@@ -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) {
</div>
)}
</div>
{showMessageLogModal && (
{logKind === 'workflow' && (
<WorkflowContextProvider>
<MessageLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
currentLogItem={selectedLogItem}
onCancel={() => setSelectedLogItem(undefined)}
/>
</WorkflowContextProvider>
)}
{showAgentLogModal && (
{logKind === 'agent' && (
<AgentLogModal
floating
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
currentLogItem={selectedLogItem}
onCancel={() => setSelectedLogItem(undefined)}
/>
)}
{shouldShowPromptLogModal && (
{logKind === 'prompt' && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
currentLogItem={selectedLogItem}
onCancel={() => setSelectedLogItem(undefined)}
/>
)}
</div>
@@ -707,6 +675,7 @@ const CompletionConversationDetailComp: FC<ConversationDetailProps> = ({
return (
<DetailPanel
key={`${appDetail.id}:${conversationId}`}
appDetail={appDetail}
detail={conversationDetail}
onClose={onClose}
@@ -768,6 +737,7 @@ const ChatConversationDetailComp: FC<ConversationDetailProps> = ({
return (
<DetailPanel
key={`${appDetail.id}:${conversationId}`}
appDetail={appDetail}
detail={conversationDetail}
onClose={onClose}
@@ -800,14 +770,6 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
const pendingConversationCacheRef = useRef<ConversationSelection | undefined>(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<IConversationList> = ({ 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 = (
-37
View File
@@ -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<State & Action>((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 })),
}))
@@ -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(
<GenerationActionGroups
appSourceType={AppSourceType.webApp}
content="hello world"
currentTab="DETAIL"
depth={1}
hideLogAction
isError={false}
isInWebApp={false}
messageId="msg-1"
onMoreLikeThis={mockOnMoreLikeThis}
onOpenLogModal={mockOnOpenLogModal}
onRetry={mockOnRetry}
/>,
)
@@ -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}
/>,
)
@@ -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<string, unknown>) => 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(
<GenerationItem
appSourceType={AppSourceType.webApp}
content="Answer"
isError={false}
messageId="msg-1"
onRetry={vi.fn()}
siteInfo={null}
/>,
)
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(
<GenerationItem
appSourceType={AppSourceType.webApp}
content="Parent answer"
isError={false}
messageId="parent-message"
moreLikeThis
onOpenLog={mockOnOpenLog}
onRetry={vi.fn()}
siteInfo={null}
/>,
)
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 () => {
@@ -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<GenerationActionGroupsProps> = ({
currentTab,
depth,
feedback,
hideLogAction,
isError,
isInWebApp,
isResponding,
@@ -57,7 +55,7 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({
moreLikeThis,
onFeedback,
onMoreLikeThis,
onOpenLogModal,
onOpenLog,
onRetry,
onSave,
supportFeedback,
@@ -70,7 +68,7 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({
return (
<>
{!hideLogAction &&
{onOpenLog &&
!isInWebApp &&
appSourceType !== AppSourceTypeEnum.installedApp &&
!isResponding && (
@@ -79,7 +77,7 @@ const GenerationActionGroups: FC<GenerationActionGroupsProps> = ({
aria-label={t(($) => $['operation.log'], { ns: 'common' })}
disabled={isError || !messageId}
title={t(($) => $['operation.log'], { ns: 'common' })}
onClick={onOpenLogModal}
onClick={onOpenLog}
>
<span aria-hidden="true" className="i-ri-file-list-3-line size-4" />
</IconButton>
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
@@ -10,7 +10,6 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { Markdown } from '@/app/components/base/markdown'
@@ -58,7 +57,7 @@ type IGenerationItemProps = {
controlClearMoreLikeThis?: number
supportFeedback?: boolean
isShowTextToSpeech?: boolean
hideLogAction?: boolean
onOpenLog?: (item: IChatItem) => void
hideProcessDetail?: boolean
siteInfo: SiteInfo | null
inSidePanel?: boolean
@@ -87,7 +86,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
controlClearMoreLikeThis,
supportFeedback,
isShowTextToSpeech,
hideLogAction,
onOpenLog,
hideProcessDetail,
siteInfo,
inSidePanel,
@@ -102,9 +101,6 @@ const GenerationItem: FC<IGenerationItemProps> = ({
})
const { config } = useChatContext()
const setCurrentLogItem = useAppStore((s) => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore((s) => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => {
await updateFeedback(
{ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } },
@@ -132,7 +128,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
installedAppId,
controlClearMoreLikeThis,
isWorkflow,
hideLogAction,
onOpenLog,
siteInfo,
taskId,
}
@@ -168,13 +164,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
setChildMessageId(null)
}, [isLoading])
const handleOpenLogModal = async () => {
const handleOpenLog = async () => {
if (!onOpenLog) return
const data = await fetchTextGenerationMessage({
appId: params.appId as string,
messageId: messageId!,
})
setCurrentLogItem(buildPromptLogItem(data))
setShowPromptLogModal(true)
onOpenLog(buildPromptLogItem(data))
}
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
@@ -277,7 +273,6 @@ const GenerationItem: FC<IGenerationItemProps> = ({
currentTab={currentTab}
depth={depth}
feedback={feedback}
hideLogAction={hideLogAction}
isError={isError}
isInWebApp={isInWebApp}
isResponding={isResponding}
@@ -287,7 +282,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
moreLikeThis={moreLikeThis}
onFeedback={onFeedback}
onMoreLikeThis={handleMoreLikeThis}
onOpenLogModal={handleOpenLogModal}
onOpenLog={onOpenLog ? handleOpenLog : undefined}
onRetry={onRetry}
onSave={onSave}
supportFeedback={supportFeedback}
@@ -1,163 +0,0 @@
import type { IChatItem } from '../type'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchAgentLogDetail } from '@/service/log'
import ChatLogModals from '../chat-log-modals'
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
describe('ChatLogModals', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'],
})
})
// Modal visibility should follow the two booleans unless log modals are globally hidden.
describe('Rendering', () => {
it('should render real prompt and agent log modals when enabled', async () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ChatLogModals
width={480}
currentLogItem={
{
id: 'log-1',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-1',
} as IChatItem
}
showPromptLogModal={true}
showAgentLogModal={true}
setCurrentLogItem={vi.fn()}
setShowPromptLogModal={vi.fn()}
setShowAgentLogModal={vi.fn()}
/>,
)
expect(screen.getByText('PROMPT LOG')).toBeInTheDocument()
expect(screen.getByText('Prompt body')).toBeInTheDocument()
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }),
).toBeInTheDocument()
})
})
it('should render nothing when hideLogModal is true', () => {
render(
<ChatLogModals
width={320}
currentLogItem={
{
id: 'log-2',
isAnswer: true,
content: 'reply',
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-2',
} as IChatItem
}
showPromptLogModal={true}
showAgentLogModal={true}
hideLogModal={true}
setCurrentLogItem={vi.fn()}
setShowPromptLogModal={vi.fn()}
setShowAgentLogModal={vi.fn()}
/>,
)
expect(screen.queryByText('PROMPT LOG')).not.toBeInTheDocument()
expect(
screen.queryByRole('heading', { name: /appLog.runDetail.workflowTitle/i }),
).not.toBeInTheDocument()
})
})
// Cancel actions should clear the current item and close only the targeted modal.
describe('User Interactions', () => {
it('should close the prompt log modal through the real close action', async () => {
const user = userEvent.setup()
const setCurrentLogItem = vi.fn()
const setShowPromptLogModal = vi.fn()
const setShowAgentLogModal = vi.fn()
render(
<ChatLogModals
width={480}
currentLogItem={
{
id: 'log-3',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
} as IChatItem
}
showPromptLogModal={true}
showAgentLogModal={false}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(setCurrentLogItem).toHaveBeenCalled()
expect(setShowPromptLogModal).toHaveBeenCalledWith(false)
expect(setShowAgentLogModal).not.toHaveBeenCalled()
})
it('should close the agent log modal through the real close action', async () => {
const user = userEvent.setup()
const setCurrentLogItem = vi.fn()
const setShowPromptLogModal = vi.fn()
const setShowAgentLogModal = vi.fn()
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ChatLogModals
width={480}
currentLogItem={
{
id: 'log-4',
isAnswer: true,
content: 'reply',
input: { question: 'hello' },
log: [{ role: 'user', text: 'Prompt body' }],
conversationId: 'conversation-4',
} as IChatItem
}
showPromptLogModal={false}
showAgentLogModal={true}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>,
)
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }),
).toBeInTheDocument()
})
await user.click(
screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i })
.nextElementSibling as HTMLElement,
)
expect(setCurrentLogItem).toHaveBeenCalled()
expect(setShowAgentLogModal).toHaveBeenCalledWith(false)
expect(setShowPromptLogModal).not.toHaveBeenCalled()
})
})
})
@@ -31,7 +31,6 @@ describe('ChatContextProvider', () => {
config: {} as ChatContextValue['config'],
isResponding: false,
chatList: [{ id: '1', content: 'hello' } as ChatItem],
showPromptLog: false,
questionIcon: <span data-testid="custom-icon">Icon</span>,
answerIcon: null,
onSend: mockOnSend,
@@ -3,7 +3,6 @@ import type { ChatProps } from '../index'
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { createTheme } from '../../embedded-chatbot/theme/theme'
import Chat from '../index'
@@ -14,9 +13,6 @@ import Chat from '../index'
// Question – pulls Markdown, copy-to-clipboard, react-textarea-autosize.
// ChatInputArea – pulls browser audio APIs unavailable in the test DOM runtime
// and the VoiceInput / FileContextProvider chains.
// PromptLogModal– pulls CopyFeedback and deep modal dep chain.
// AgentLogModal – pulls @remixicon/react (causes lint push error), useClickAway
// from ahooks, and AgentLogDetail (workflow graph renderer).
// es-toolkit/compat – debounce must return a fn with .cancel() or the cleanup
// effect throws on unmount.
//
@@ -80,26 +76,6 @@ vi.mock('../chat-input-area', () => ({
),
}))
vi.mock('@/app/components/base/prompt-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="prompt-log-modal">
<button data-testid="prompt-log-cancel" onClick={onCancel}>
cancel
</button>
</div>
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="agent-log-modal">
<button data-testid="agent-log-cancel" onClick={onCancel}>
cancel
</button>
</div>
),
}))
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: (...args: unknown[]) => void) => {
const debounced = (...args: unknown[]) => fn(...args)
@@ -130,19 +106,6 @@ const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
...overrides,
})
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
const mockSetShowAgentLogModal = vi.fn()
const baseStoreState = {
currentLogItem: undefined,
setCurrentLogItem: mockSetCurrentLogItem,
showPromptLogModal: false,
setShowPromptLogModal: mockSetShowPromptLogModal,
showAgentLogModal: false,
setShowAgentLogModal: mockSetShowAgentLogModal,
}
const renderChat = (props: Partial<ChatProps> = {}) => render(<Chat chatList={[]} {...props} />)
// ─── Suite ────────────────────────────────────────────────────────────────────
@@ -171,8 +134,6 @@ describe('Chat', () => {
disconnect() {}
},
)
useAppStore.setState(baseStoreState)
})
afterEach(() => {
@@ -476,68 +437,6 @@ describe('Chat', () => {
})
})
describe('PromptLogModal', () => {
it('should render when showPromptLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
})
it('should not render when showPromptLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false })
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showPromptLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowPromptLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('prompt-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
})
})
describe('AgentLogModal', () => {
it('should render when showAgentLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
it('should not render when showAgentLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: false })
renderChat()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showAgentLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowAgentLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('agent-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
})
describe('Window Resize', () => {
it('should register a resize listener on mount', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
@@ -677,26 +576,6 @@ describe('Chat', () => {
})
})
describe('Edge Cases', () => {
it('should render no modals when both modal flags are false', () => {
useAppStore.setState({
...baseStoreState,
showPromptLogModal: false,
showAgentLogModal: false,
})
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should render both modals when both flags are true and hideLogModal is false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
})
describe('Question Rendering with Config', () => {
it('should pass questionEditEnable from config to Question component', () => {
renderChat({
@@ -778,17 +657,6 @@ describe('Chat', () => {
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should pass showPromptLog to Answer component', () => {
renderChat({
showPromptLog: true,
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
],
})
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should pass hideProcessDetail to Answer component', () => {
renderChat({
hideProcessDetail: true,
@@ -1202,50 +1070,4 @@ describe('Chat', () => {
expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow()
})
})
describe('Modal Interaction Paths', () => {
it('should handle prompt log cancel and subsequent reopen', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
const { rerender } = renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('prompt-log-cancel'))
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
// Reopen modal
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
rerender(<Chat chatList={[]} hideLogModal={false} />)
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
})
it('should handle agent log cancel and subsequent reopen', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
const { rerender } = renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('agent-log-cancel'))
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
// Reopen modal
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
rerender(<Chat chatList={[]} hideLogModal={false} />)
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
it('should handle hideLogModal preventing both modals from showing', () => {
useAppStore.setState({
...baseStoreState,
showPromptLogModal: true,
showAgentLogModal: true,
})
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
})
})
@@ -0,0 +1,95 @@
import type { ChatItem } from '../../types'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Chat from '../index'
// Keep the Chat/context/Operation/Log capability path real while omitting rich answer rendering.
vi.mock('../answer', async () => {
const { default: Operation } = await import('../answer/operation')
return {
default: ({ item }: { item: ChatItem }) => (
<Operation
item={item}
question=""
index={0}
maxSize={500}
contentWidth={100}
hasWorkflowProcess={false}
/>
),
}
})
vi.mock('../question', () => ({ default: () => null }))
vi.mock('../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 }),
)
const firstAnswer: ChatItem = { id: 'first-answer', content: 'First answer', isAnswer: true }
const secondAnswer: ChatItem = { id: 'second-answer', content: 'Second answer', isAnswer: true }
describe('Chat log actions', () => {
it('keeps two mounted chat surfaces connected to their own log owner', async () => {
const user = userEvent.setup()
const onFirstLog = vi.fn()
const onSecondLog = vi.fn()
render(
<>
<section aria-label="First chat">
<Chat chatList={[firstAnswer]} noChatInput onOpenLog={onFirstLog} />
</section>
<section aria-label="Second chat">
<Chat chatList={[secondAnswer]} noChatInput onOpenLog={onSecondLog} />
</section>
</>,
)
await user.click(
within(screen.getByRole('region', { name: 'Second chat' })).getByRole('button', {
name: /operation\.log/,
}),
)
expect(onSecondLog).toHaveBeenCalledExactlyOnceWith(secondAnswer)
expect(onFirstLog).not.toHaveBeenCalled()
await user.click(
within(screen.getByRole('region', { name: 'First chat' })).getByRole('button', {
name: /operation\.log/,
}),
)
expect(onFirstLog).toHaveBeenCalledExactlyOnceWith(firstAnswer)
expect(onSecondLog).toHaveBeenCalledTimes(1)
})
it('removes the log action when its owner withdraws the capability', () => {
const { rerender } = render(<Chat chatList={[firstAnswer]} noChatInput onOpenLog={vi.fn()} />)
expect(screen.getByRole('button', { name: /operation\.log/ })).toBeInTheDocument()
rerender(<Chat chatList={[firstAnswer]} noChatInput />)
expect(screen.queryByRole('button', { name: /operation\.log/ })).not.toBeInTheDocument()
})
it('shows log actions only for items accepted by the owner', async () => {
const user = userEvent.setup()
const onOpenLog = vi.fn()
const answerWithRun = { ...secondAnswer, workflow_run_id: 'run-1' }
render(
<Chat
chatList={[firstAnswer, answerWithRun]}
noChatInput
onOpenLog={onOpenLog}
canOpenLog={(item) => !!item.workflow_run_id}
/>,
)
expect(screen.getAllByRole('button', { name: /operation\.log/ })).toHaveLength(1)
await user.click(screen.getByRole('button', { name: /operation\.log/ }))
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(answerWithRun)
})
})
@@ -105,7 +105,6 @@ const renderWithProvider = (
config={{} as unknown as ChatConfig | undefined}
isResponding={false}
chatList={[]}
showPromptLog={false}
questionIcon={props.questionIcon}
answerIcon={props.answerIcon}
onSend={vi.fn()}
@@ -961,7 +960,6 @@ describe('Question component', () => {
config={{} as unknown as ChatConfig}
isResponding={false}
chatList={[]}
showPromptLog={false}
onSend={vi.fn()}
onRegenerate={undefined as unknown as OnRegenerate}
onAnnotationEdited={vi.fn()}
@@ -44,7 +44,7 @@ const LayoutHarness = ({
sidebarCollapseState?: boolean
attachRefs?: boolean
}) => {
const { width, chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } =
const { chatContainerRef, chatContainerInnerRef, chatFooterRef, chatFooterInnerRef } =
useChatLayout({ chatList, sidebarCollapseState })
return (
@@ -85,7 +85,6 @@ const LayoutHarness = ({
}}
/>
</div>
<output data-testid="layout-width">{width}</output>
</>
)
}
@@ -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('')
@@ -107,14 +107,6 @@ vi.mock('@/app/components/base/new-audio-button', () => ({
),
}))
vi.mock('@/app/components/base/chat/chat/log', () => ({
default: () => (
<button data-testid="log-btn">
<div className="i-ri-file-list-3-line" />
</button>
),
}))
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()
})
@@ -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<typeof Answer>
@@ -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<AnswerProps> = ({
config,
answerIcon,
responding,
showPromptLog,
chatAnswerContainerInner,
hideProcessDetail,
appData,
@@ -195,7 +193,6 @@ const Answer: FC<AnswerProps> = ({
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)}
@@ -256,7 +253,6 @@ const Answer: FC<AnswerProps> = ({
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)}
@@ -333,7 +329,6 @@ const Answer: FC<AnswerProps> = ({
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)}
@@ -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({
</div>
</DialogContent>
</Dialog>
{showPromptLog && !isOpeningStatement && (
{availableLogAction && !isOpeningStatement && (
<div className={cn('hidden', answerActiveBlockClassName)}>
<Log logItem={item} />
<Log logItem={item} onOpenLog={availableLogAction} />
</div>
)}
{!isOpeningStatement && (
@@ -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<ChatLogModalsProps> = ({
width,
currentLogItem,
showPromptLogModal,
showAgentLogModal,
hideLogModal,
setCurrentLogItem,
setShowPromptLogModal,
setShowAgentLogModal,
}) => {
if (hideLogModal) return null
return (
<>
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</>
)
}
export default ChatLogModals
@@ -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,
+2 -1
View File
@@ -8,7 +8,8 @@ export type ChatContextValue = Pick<
| 'config'
| 'isResponding'
| 'chatList'
| 'showPromptLog'
| 'onOpenLog'
| 'canOpenLog'
| 'questionIcon'
| 'answerIcon'
| 'onSend'
+14 -40
View File
@@ -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<string, unknown>
inputsForm?: InputForm[]
onRegenerate?: OnRegenerate
chatContainerRef?: Ref<HTMLDivElement>
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<string, ToolIcon>
@@ -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<ChatProps> = ({
onStopResponding,
noChatInput,
showRegenerate,
chatContainerRef: externalChatContainerRef,
chatContainerClassName,
chatContainerInnerClassName,
chatFooterClassName,
chatFooterInnerClassName,
suggestedQuestions,
showPromptLog,
onOpenLog,
canOpenLog,
questionIcon,
answerIcon,
onAnnotationAdded,
@@ -122,7 +122,6 @@ const Chat: FC<ChatProps> = ({
onFeedback,
chatAnswerContainerInner,
hideProcessDetail,
hideLogModal,
theme,
switchSibling,
showFeatureBar,
@@ -168,29 +167,14 @@ const Chat: FC<ChatProps> = ({
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<ChatProps> = ({
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<ChatProps> = ({
config={config}
answerIcon={answerIcon}
responding={isLast && isResponding}
showPromptLog={showPromptLog}
chatAnswerContainerInner={chatAnswerContainerInner}
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
@@ -334,16 +318,6 @@ const Chat: FC<ChatProps> = ({
)}
</div>
</div>
<ChatLogModals
width={width}
currentLogItem={currentLogItem}
showPromptLogModal={showPromptLogModal}
showAgentLogModal={showAgentLogModal}
hideLogModal={hideLogModal}
setCurrentLogItem={setCurrentLogItem}
setShowPromptLogModal={setShowPromptLogModal}
setShowAgentLogModal={setShowAgentLogModal}
/>
</div>
</ChatContextProvider>
)
@@ -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>): 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<typeof selector>[0]),
) // Fixed: Double cast to avoid overlap error
})
it('should render correctly', () => {
render(<Log logItem={createLogItem()} />)
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(
<div role="presentation" onClick={onParentClick}>
<Log logItem={logItem} onOpenLog={onOpenLog} />
</div>,
)
render(<Log logItem={logItem} />)
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(<Log logItem={logItem} onOpenLog={onOpenLog} />)
render(<Log logItem={logItem} />)
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(<Log logItem={logItem} />)
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(<Log logItem={createLogItem()} />)
// 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)
})
})
+11 -20
View File
@@ -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<LogProps> = ({ logItem }) => {
const Log: FC<LogProps> = ({ 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 (
<div
className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs"
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setCurrentLogItem(logItem)
if (runID) setShowMessageLogModal(true)
else if (isAgent) setShowAgentLogModal(true)
else setShowPromptLogModal(true)
}}
>
<IconButton aria-label={t(($) => $['operation.log'], { ns: 'common' })}>
<div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs">
<IconButton
aria-label={t(($) => $['operation.log'], { ns: 'common' })}
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onOpenLog(logItem)
}}
>
<RiFileList3Line aria-hidden="true" className="size-4" />
</IconButton>
</div>
@@ -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<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(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,
@@ -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}
@@ -17,7 +17,6 @@ const ThinkBlockDemo = ({ responding = false }: { responding?: boolean }) => {
config={undefined}
isResponding={isResponding}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
@@ -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<typeof PromptLogModal>
@@ -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 (
<div className="relative min-h-135 w-full bg-background-default-subtle p-6">
<PromptLogModal {...props} currentLogItem={mockLogItem} />
@@ -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<string, unknown>
showDebugAndPreviewPanel: boolean
showChatVariablePanel: boolean
showGlobalVariablePanel: boolean
}
type WorkflowStoreState = Partial<Shape>
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: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(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
}) => (
<div
data-testid="message-log-modal"
data-current-log-id={currentLogItem?.id ?? ''}
data-default-tab={defaultTab ?? ''}
>
<button type="button" onClick={onCancel}>
close-message-log
</button>
</div>
),
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(<WorkflowPanel />)
renderWorkflowComponent(<WorkflowPanel />, { 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(<WorkflowPanel />)
expect(await screen.findByTestId('message-log-modal')).toHaveAttribute(
'data-current-log-id',
'log-1',
const { store } = renderWorkflowComponent(<WorkflowPanel />, {
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(<WorkflowPanel />)
const { unmount } = renderWorkflowComponent(<WorkflowPanel />, {
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(<WorkflowPanel />)
renderWorkflowComponent(<WorkflowPanel />, { initialStoreState: workflowStoreState })
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
@@ -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 {
@@ -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 && (
<MessageLogModal
fixedWidth
width={400}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
currentLogItem={messageLogItem}
onCancel={() => setMessageLogItem(undefined)}
/>
)}
</>
@@ -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(<ViewWorkflowHistory />, {
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()
})
})
@@ -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)
}}
>
<span aria-hidden className="i-ri-history-line size-4 shrink-0" />
</IconButton>
@@ -190,10 +179,6 @@ const ViewWorkflowHistory = () => {
<span aria-hidden className="i-ri-close-line size-4 text-text-secondary" />
</IconButton>
}
onClick={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
/>
</div>
<div
@@ -16,7 +16,6 @@ const mockSetSettingsDestination = vi.fn()
const mockHandleSingleRun = vi.fn()
const mockHandleStop = vi.fn()
const mockHandleRunWithParams = vi.fn()
let mockShowMessageLogModal = false
let mockNodesReadOnly = false
let mockCanRun = true
let mockBuiltInTools = [
@@ -67,11 +66,8 @@ const mockLastRunState = {
}
vi.mock('@/app/components/app/store', () => ({
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<string, unknown> = {}) => ({
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(
<BasePanel
id="node-1"
data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}
@@ -757,6 +750,12 @@ describe('workflow-panel index', () => {
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 () => {
@@ -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<BasePanelProps> = ({ 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<BasePanelProps> = ({ 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<BasePanelProps> = ({ id, data, children }) => {
<div
className={cn(
'relative mr-1 h-full',
showMessageLogModal &&
messageLogItem &&
'absolute z-0 mr-2 w-100 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border shadow-lg transition-all',
)}
style={
{
right: !showMessageLogModal ? '0' : `${otherPanelWidth}px`,
right: !messageLogItem ? '0' : `${otherPanelWidth}px`,
'--workflow-node-panel-right': nodePanelRightOffset,
} as CSSProperties
}
@@ -75,6 +75,7 @@ describe('ChatRecord', () => {
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 () => {
@@ -53,6 +53,7 @@ const ChatRecord = () => {
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
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,
})
}}
>
<RiCloseLine className="size-4 text-text-tertiary" />
@@ -128,7 +132,8 @@ const ChatRecord = () => {
chatNode={<UserInput />}
noChatInput
allToolIcons={{}}
showPromptLog
onOpenLog={setMessageLogItem}
canOpenLog={(item) => !!item.workflow_run_id}
switchSibling={switchSibling}
noSpacing
chatAnswerContainerInner="pr-2!"
@@ -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<void>
onFeatureBarClick?: (state: boolean) => void
speechToTextTarget?: SpeechToTextTarget
onOpenLog?: (item: IChatItem) => void
}) => (
<div
data-testid="chat-shell"
@@ -85,6 +88,13 @@ vi.mock('@/app/components/base/chat/chat', () => ({
<button type="button" onClick={() => onFeatureBarClick?.(true)}>
open-feature-panel
</button>
<button
onClick={() =>
onOpenLog?.({ id: 'log-1', isAnswer: true, content: 'answer', workflow_run_id: 'run-1' })
}
>
open-log
</button>
{chatNode}
</div>
),
@@ -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',
})
@@ -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}
@@ -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<PanelSliceShape> = (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 })),
+2
View File
@@ -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<IDebugConfiguration>({
readonly: false,
canTestAndRun: false,
appId: '',
onOpenFeatures: noop,
isTrailFinished: false,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
@@ -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',
)
})
@@ -147,7 +147,6 @@ export function AgentLogDetailPanel({
chatList={chatList}
noChatInput
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
onFeedback={handleFeedback}
/>