diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx
index f4c8ef0c45..5d62fd5f6e 100644
--- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx
+++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx
@@ -1836,6 +1836,82 @@ describe('useChatWithHistory', () => {
expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1)
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
})
+
+ it('should pass through workflow_run_id from item and created_at', async () => {
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({
+ data: [
+ {
+ id: 'msg-running',
+ query: 'Running query',
+ answer: 'Running answer',
+ message_files: [],
+ feedback: null,
+ retriever_resources: [],
+ agent_thoughts: null,
+ parent_message_id: null,
+ inputs: {},
+ status: 'normal',
+ workflow_run_id: 'wf-direct-id',
+ created_at: 1700000000,
+ },
+ ],
+ })
+
+ const { result } = await renderWithClient(() => useChatWithHistory())
+
+ await waitFor(() => {
+ expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
+ })
+
+ const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
+ expect(answerNode?.workflow_run_id).toBe('wf-direct-id')
+ expect(answerNode?.created_at).toBe(1700000000)
+ })
+
+ it('should prefer item.workflow_run_id over extra_contents workflow_run_id', async () => {
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({
+ data: [
+ {
+ id: 'msg-both',
+ query: 'Both query',
+ answer: 'Both answer',
+ message_files: [],
+ feedback: null,
+ retriever_resources: [],
+ agent_thoughts: null,
+ parent_message_id: null,
+ inputs: {},
+ status: 'paused',
+ workflow_run_id: 'wf-item-level',
+ extra_contents: [
+ {
+ type: 'human_input',
+ submitted: false,
+ form_definition: { fields: [] },
+ workflow_run_id: 'wf-extra-level',
+ },
+ ],
+ },
+ ],
+ })
+
+ const { result } = await renderWithClient(() => useChatWithHistory())
+
+ await waitFor(() => {
+ expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
+ })
+
+ const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
+ expect(answerNode?.workflow_run_id).toBe('wf-item-level')
+ })
})
// Scenario: newConversation merge replaces existing conversation item when id already exists.
diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx
index 9894c65dab..304ca99a9c 100644
--- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx
+++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx
@@ -1292,7 +1292,7 @@ describe('useChat', () => {
})
expect(sseGet).toHaveBeenCalledWith(
- '/workflow/wr-reconnect/events?include_state_snapshot=true',
+ '/workflow/wr-reconnect/events?include_state_snapshot=true&replay=true',
expect.any(Object),
expect.any(Object),
)
@@ -1540,6 +1540,89 @@ describe('useChat', () => {
})
})
+ describe('onWorkflowStarted re-enables responding in handleSend', () => {
+ it('should set isResponding back to true when onWorkflowStarted fires after stop', () => {
+ let callbacks: HookCallbacks
+ vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
+ callbacks = options as HookCallbacks
+ })
+
+ const stopChat = vi.fn()
+ const { result } = renderHook(() => useChat(undefined, undefined, undefined, stopChat))
+
+ act(() => {
+ result.current.handleSend('test-url', { query: 'workflow restart' }, {})
+ })
+ expect(result.current.isResponding).toBe(true)
+
+ act(() => {
+ callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
+ callbacks.onData('part', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
+ })
+
+ act(() => {
+ result.current.handleStop()
+ })
+ expect(result.current.isResponding).toBe(false)
+
+ act(() => {
+ callbacks.onWorkflowStarted({ workflow_run_id: 'wr-2', task_id: 't-2' })
+ })
+ expect(result.current.isResponding).toBe(true)
+ })
+ })
+
+ describe('abortInflightRequests on unmount', () => {
+ it('should abort all in-flight requests when the hook unmounts', () => {
+ let callbacks: HookCallbacks
+ vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
+ callbacks = options as HookCallbacks
+ })
+
+ const workflowAbort = createAbortControllerMock()
+ const conversationAbort = createAbortControllerMock()
+ const suggestedAbort = createAbortControllerMock()
+
+ const config = { suggested_questions_after_answer: { enabled: true } }
+ const onGetConversationMessages = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
+ setAbort(conversationAbort)
+ return {
+ data: [{
+ id: 'm-1',
+ answer: 'a',
+ message: [{ role: 'assistant', text: 'a' }],
+ created_at: Date.now(),
+ answer_tokens: 1,
+ message_tokens: 1,
+ provider_response_latency: 0.1,
+ inputs: {},
+ query: 'q',
+ }],
+ }
+ })
+ const onGetSuggestedQuestions = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
+ setAbort(suggestedAbort)
+ return { data: [] }
+ })
+
+ const { result, unmount } = renderHook(() => useChat(config as ChatConfig))
+
+ act(() => {
+ result.current.handleSend('test-url', { query: 'unmount' }, {
+ onGetConversationMessages,
+ onGetSuggestedQuestions,
+ })
+ })
+ act(() => {
+ callbacks.getAbortController(workflowAbort)
+ })
+
+ unmount()
+
+ expect(workflowAbort.abort).toHaveBeenCalled()
+ })
+ })
+
describe('annotations and siblings', () => {
const prevChatTree = [{
id: 'q-1',
diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx
index 3a9ddf4d5a..f5993f2797 100644
--- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx
+++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx
@@ -143,6 +143,67 @@ describe('Answer Component', () => {
})
})
+ describe('ContentSwitch visibility while responding', () => {
+ it('should hide ContentSwitch when responding in non-human-inputs layout', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
+ })
+
+ it('should hide ContentSwitch when responding in human-inputs layout with content', () => {
+ render(
+ ,
+ )
+ expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
+ })
+
+ it('should show ContentSwitch when not responding with siblings', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('button', { name: 'Previous' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument()
+ })
+ })
+
describe('Interactions', () => {
it('should handle switch sibling', () => {
const mockSwitchSibling = vi.fn()
diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx
index 7ba8df7fa1..1881e1ff26 100644
--- a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx
@@ -834,6 +834,34 @@ describe('useEmbeddedChatbot', () => {
const question = chatList.find((m: unknown) => (m as Record).id === 'question-msg-no-files')
expect(question).toBeDefined()
})
+
+ it('should pass through workflow_run_id and created_at from message items', async () => {
+ localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } }))
+ mockFetchConversations.mockResolvedValue(
+ createConversationData({ data: [createConversationItem({ id: 'conversation-1' })] }),
+ )
+ mockFetchChatList.mockResolvedValue({
+ data: [{
+ id: 'msg-wf',
+ query: 'Running workflow',
+ answer: 'Partial',
+ workflow_run_id: 'wf-embedded-1',
+ created_at: 1700000000,
+ }],
+ })
+
+ const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
+ await waitFor(() => expect(result.current.appPrevChatList.length).toBeGreaterThan(0), { timeout: 3000 })
+
+ const questionNode = result.current.appPrevChatList.find(
+ (m: unknown) => (m as Record).id === 'question-msg-wf',
+ ) as Record | undefined
+ expect(questionNode).toBeDefined()
+ const answerNode = (questionNode!.children as Record[])?.[0]
+ expect(answerNode).toBeDefined()
+ expect(answerNode!.workflow_run_id).toBe('wf-embedded-1')
+ expect(answerNode!.created_at).toBe(1700000000)
+ })
})
describe('currentConversationItem from pinned list', () => {