From 35fe0a4b813fad4ea81a757a486373921c6dfbd3 Mon Sep 17 00:00:00 2001 From: Allan Enemark Date: Mon, 16 Mar 2026 12:41:38 -0700 Subject: [PATCH 1/4] Fix research panel loading state for new reports. Show an immediate spinner in research tabs while deep research is active but the first task, thinking, citation, or report data has not arrived yet. Made-with: Cursor --- .../layout/components/ResearchPanel.spec.tsx | 42 +++++++++++++++++++ .../layout/components/ResearchPanel.tsx | 37 ++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx index 37b53a7e..9676dcb0 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx @@ -34,6 +34,14 @@ vi.mock('@/adapters/api', () => ({ let mockIsDeepResearchStreaming = false let mockDeepResearchJobId: string | null = null let mockDeepResearchStreamLoaded = false +let mockCurrentStatus: string | null = null +let mockReportContent = '' +let mockDeepResearchTodosCount = 0 +let mockDeepResearchCitationsCount = 0 +let mockDeepResearchLLMStepsCount = 0 +let mockDeepResearchAgentsCount = 0 +let mockDeepResearchToolCallsCount = 0 +let mockDeepResearchFilesCount = 0 const mockImportJobStream = vi.fn() const mockCancelCurrentJob = vi.fn() @@ -43,11 +51,27 @@ vi.mock('@/features/chat', () => ({ isDeepResearchStreaming: boolean deepResearchJobId: string | null deepResearchStreamLoaded: boolean + currentStatus: string | null + reportContent: string + deepResearchTodos: Array + deepResearchCitations: Array + deepResearchLLMSteps: Array + deepResearchAgents: Array + deepResearchToolCalls: Array + deepResearchFiles: Array }) => unknown) => selector({ isDeepResearchStreaming: mockIsDeepResearchStreaming, deepResearchJobId: mockDeepResearchJobId, deepResearchStreamLoaded: mockDeepResearchStreamLoaded, + currentStatus: mockCurrentStatus, + reportContent: mockReportContent, + deepResearchTodos: Array.from({ length: mockDeepResearchTodosCount }), + deepResearchCitations: Array.from({ length: mockDeepResearchCitationsCount }), + deepResearchLLMSteps: Array.from({ length: mockDeepResearchLLMStepsCount }), + deepResearchAgents: Array.from({ length: mockDeepResearchAgentsCount }), + deepResearchToolCalls: Array.from({ length: mockDeepResearchToolCallsCount }), + deepResearchFiles: Array.from({ length: mockDeepResearchFilesCount }), }), useLoadJobData: () => ({ importStreamOnly: mockImportJobStream, @@ -89,6 +113,14 @@ describe('ResearchPanel', () => { mockIsDeepResearchStreaming = false mockDeepResearchJobId = null mockDeepResearchStreamLoaded = false + mockCurrentStatus = null + mockReportContent = '' + mockDeepResearchTodosCount = 0 + mockDeepResearchCitationsCount = 0 + mockDeepResearchLLMStepsCount = 0 + mockDeepResearchAgentsCount = 0 + mockDeepResearchToolCallsCount = 0 + mockDeepResearchFilesCount = 0 mockImportJobStream.mockClear() }) @@ -225,6 +257,16 @@ describe('ResearchPanel', () => { // When not streaming, the generate icon is shown instead of spinner expect(screen.queryByLabelText('Researching')).not.toBeInTheDocument() }) + + test('shows a tab loading spinner while a new deep research job is waiting for first task data', () => { + mockIsDeepResearchStreaming = true + mockDeepResearchJobId = 'job-123' + mockResearchPanelTab = 'tasks' + + render() + + expect(screen.getByLabelText('Preparing research tasks...')).toBeInTheDocument() + }) }) describe('children rendering', () => { diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.tsx index cb7d8e10..8451f84f 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.tsx @@ -50,6 +50,14 @@ export const ResearchPanel: FC = ({ children, isAuthenticate const isDeepResearchStreaming = useChatStore((state) => state.isDeepResearchStreaming) const deepResearchJobId = useChatStore((state) => state.deepResearchJobId) const deepResearchStreamLoaded = useChatStore((state) => state.deepResearchStreamLoaded) + const currentStatus = useChatStore((state) => state.currentStatus) + const reportContent = useChatStore((state) => state.reportContent) + const deepResearchTodosCount = useChatStore((state) => state.deepResearchTodos.length) + const deepResearchCitationsCount = useChatStore((state) => state.deepResearchCitations.length) + const deepResearchLLMStepsCount = useChatStore((state) => state.deepResearchLLMSteps.length) + const deepResearchAgentsCount = useChatStore((state) => state.deepResearchAgents.length) + const deepResearchToolCallsCount = useChatStore((state) => state.deepResearchToolCalls.length) + const deepResearchFilesCount = useChatStore((state) => state.deepResearchFiles.length) const { importStreamOnly, isLoading: isStreamLoading } = useLoadJobData() const { idToken } = useAuth() @@ -57,6 +65,28 @@ export const ResearchPanel: FC = ({ children, isAuthenticate const isOpen = rightPanel === 'research' const cancelFallbackRef = useRef(null) + const hasThinkingContent = + deepResearchLLMStepsCount > 0 || + deepResearchAgentsCount > 0 || + deepResearchToolCallsCount > 0 || + deepResearchFilesCount > 0 + const hasReportContent = typeof reportContent === 'string' && reportContent.trim().length > 0 + const showPendingTabSpinner = + Boolean(deepResearchJobId) && + isDeepResearchStreaming && + !isStreamLoading && + ((researchPanelTab === 'tasks' && deepResearchTodosCount === 0) || + (researchPanelTab === 'thinking' && !hasThinkingContent) || + (researchPanelTab === 'citations' && deepResearchCitationsCount === 0) || + (researchPanelTab === 'report' && !hasReportContent)) + const pendingTabMessage = + researchPanelTab === 'report' || currentStatus === 'writing' + ? 'Drafting report...' + : researchPanelTab === 'citations' + ? 'Gathering sources...' + : researchPanelTab === 'thinking' + ? 'Collecting research activity...' + : 'Preparing research tasks...' // Clean up cancel fallback timer on unmount useEffect(() => { @@ -272,6 +302,13 @@ export const ResearchPanel: FC = ({ children, isAuthenticate : 'Loading report...'} + ) : showPendingTabSpinner ? ( + + + + {pendingTabMessage} + + ) : ( <> {researchPanelTab === 'plan' && } From dd5f2e5ffd610ab33927a7e9f73514e5a4354702 Mon Sep 17 00:00:00 2001 From: Allan Enemark Date: Mon, 16 Mar 2026 13:10:18 -0700 Subject: [PATCH 2/4] Fix report tab completion loading. Backfill the final report on deep research success when live report content has not reached the store yet, and cover the completion flow with hook and panel regressions. Made-with: Cursor --- .../chat/hooks/use-deep-research.spec.ts | 43 ++++++++++++++++++- .../features/chat/hooks/use-deep-research.ts | 20 +++++++-- .../layout/components/ResearchPanel.spec.tsx | 23 +++++++++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts b/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts index d532f1f1..0354e7b5 100644 --- a/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts +++ b/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts @@ -164,7 +164,7 @@ vi.mock('@/adapters/api', () => ({ mockCreateDeepResearchClient(options), cancelJob: (...args: unknown[]) => mockCancelJob(...args), getJobStatus: () => mockGetJobStatus(), - getJobReport: () => mockGetJobReport(), + getJobReport: (...args: unknown[]) => mockGetJobReport(...args), })) import { useChatStore } from '../store' @@ -595,6 +595,47 @@ describe('useDeepResearch', () => { ) }) + test('onJobStatus success backfills missing report content before finalizing', async () => { + mockGetJobReport.mockResolvedValueOnce({ + has_report: true, + report: 'Recovered final report', + }) + + await setupConnectedHook({ + reportContent: '', + activeDeepResearchMessageId: 'msg-123', + }) + + vi.mocked(useChatStore).getState = vi.fn(() => ({ + ...mockStoreState, + reportContent: '', + deepResearchLLMSteps: [], + deepResearchToolCalls: [], + deepResearchCitations: [], + addErrorCard: mockAddErrorCard, + deepResearchOwnerConversationId: 'test-conv-123', + activeDeepResearchMessageId: 'msg-123', + })) as unknown as typeof useChatStore.getState + + await act(async () => { + await mockClient?.callbacks.onJobStatus?.('success', undefined) + }) + + expect(mockGetJobReport).toHaveBeenCalledWith('job-456', 'mock-id-token') + expect(mockSetReportContent).toHaveBeenCalledWith('Recovered final report') + expect(mockPatchConversationMessage).toHaveBeenCalledWith( + 'test-conv-123', + 'msg-123', + expect.objectContaining({ + deepResearchJobStatus: 'success', + isDeepResearchActive: false, + showViewReport: true, + }) + ) + expect(mockSetStreamLoaded).toHaveBeenCalledWith(true) + expect(mockCompleteDeepResearch).toHaveBeenCalled() + }) + test('onJobStatus failure stops todos and shows error', async () => { await setupConnectedHook() diff --git a/frontends/ui/src/features/chat/hooks/use-deep-research.ts b/frontends/ui/src/features/chat/hooks/use-deep-research.ts index 1b3b8ec6..2d86462e 100644 --- a/frontends/ui/src/features/chat/hooks/use-deep-research.ts +++ b/frontends/ui/src/features/chat/hooks/use-deep-research.ts @@ -16,6 +16,7 @@ import { useEffect, useRef, useCallback, useState } from 'react' import { createDeepResearchClient, cancelJob, + getJobReport, type DeepResearchClient, type DeepResearchJobStatus, type TodoItem, @@ -248,7 +249,7 @@ export const useDeepResearch = (): UseDeepResearchReturn => { } }, - onJobStatus: (status, error) => { + onJobStatus: async (status, error) => { if (buf.active) flushBuffer() if (!isOwnerActive()) return resetTimeout() @@ -266,10 +267,23 @@ export const useDeepResearch = (): UseDeepResearchReturn => { if (status === 'success') { setCurrentStatus('complete') - const { reportContent: currentReport, deepResearchLLMSteps, deepResearchToolCalls } = state + let resolvedReport = state.reportContent + if (!resolvedReport?.trim()) { + try { + const response = await getJobReport(jobId, idToken || undefined) + if (response.has_report && response.report?.trim()) { + resolvedReport = response.report + setReportContent(response.report) + } + } catch (reportError) { + console.warn('Failed to backfill final report after deep research success:', reportError) + } + } + + const { deepResearchLLMSteps, deepResearchToolCalls } = state const totalTokens = deepResearchLLMSteps.reduce((sum, step) => sum + (step.usage?.input_tokens || 0) + (step.usage?.output_tokens || 0), 0) const toolCallCount = deepResearchToolCalls.length - const hasReport = Boolean(currentReport?.trim()) + const hasReport = Boolean(resolvedReport?.trim()) if (ownerConvId && messageId) { patchConversationMessage(ownerConvId, messageId, { diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx index 9676dcb0..8f2c2c7d 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx @@ -101,7 +101,9 @@ vi.mock('./CitationsTab', () => ({ vi.mock('./ReportTab', () => ({ ReportTab: ({ children }: { children?: React.ReactNode }) => ( -
Report Tab Content {children}
+
+ {mockReportContent || 'Report Tab Content'} {children} +
), })) @@ -281,6 +283,25 @@ describe('ResearchPanel', () => { expect(screen.getByTestId('custom-content')).toBeInTheDocument() }) + + test('shows report content automatically when it arrives while report tab is open', () => { + mockResearchPanelTab = 'report' + mockIsDeepResearchStreaming = true + mockDeepResearchJobId = 'job-123' + mockCurrentStatus = 'writing' + mockReportContent = '' + + const { rerender } = render() + + expect(screen.getByLabelText('Drafting report...')).toBeInTheDocument() + expect(screen.queryByText('Recovered final report')).not.toBeInTheDocument() + + mockReportContent = 'Recovered final report' + rerender() + + expect(screen.queryByLabelText('Drafting report...')).not.toBeInTheDocument() + expect(screen.getByText('Recovered final report')).toBeInTheDocument() + }) }) describe('segmented control groups', () => { From cea4249d13e2cc637a0cee08be9f3abf68830328 Mon Sep 17 00:00:00 2001 From: Allan Enemark Date: Mon, 16 Mar 2026 13:52:18 -0700 Subject: [PATCH 3/4] Fix report tab loading for completed research jobs. Load archived report content when users open the research panel directly to the report tab for an already completed job, and cover the side-panel flow with regressions. Made-with: Cursor --- .../layout/components/ResearchPanel.spec.tsx | 30 +++++++++++ .../layout/components/ResearchPanel.tsx | 54 ++++++++++--------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx index 8f2c2c7d..842e8616 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx @@ -43,6 +43,7 @@ let mockDeepResearchAgentsCount = 0 let mockDeepResearchToolCallsCount = 0 let mockDeepResearchFilesCount = 0 const mockImportJobStream = vi.fn() +const mockLoadReport = vi.fn() const mockCancelCurrentJob = vi.fn() @@ -74,6 +75,7 @@ vi.mock('@/features/chat', () => ({ deepResearchFiles: Array.from({ length: mockDeepResearchFilesCount }), }), useLoadJobData: () => ({ + loadReport: mockLoadReport, importStreamOnly: mockImportJobStream, isLoading: false, }), @@ -124,6 +126,7 @@ describe('ResearchPanel', () => { mockDeepResearchToolCallsCount = 0 mockDeepResearchFilesCount = 0 mockImportJobStream.mockClear() + mockLoadReport.mockClear() }) describe('panel visibility', () => { @@ -174,6 +177,19 @@ describe('ResearchPanel', () => { expect(mockSetResearchPanelTab).toHaveBeenCalledWith('citations') }) + test('loads report data when report tab is clicked for a completed job without report content', async () => { + const user = userEvent.setup() + mockDeepResearchJobId = 'job-123' + mockResearchPanelTab = 'tasks' + + render() + + await user.click(screen.getByText('Report')) + + expect(mockSetResearchPanelTab).toHaveBeenCalledWith('report') + expect(mockLoadReport).toHaveBeenCalledWith('job-123') + }) + test('displays correct tab content based on researchPanelTab', () => { mockResearchPanelTab = 'tasks' const { rerender } = render() @@ -354,5 +370,19 @@ describe('ResearchPanel', () => { expect(mockOpenRightPanel).not.toHaveBeenCalled() }) + + test('loads report data when opening panel directly on report tab for a completed job', async () => { + const user = userEvent.setup() + mockRightPanel = null + mockResearchPanelTab = 'report' + mockDeepResearchJobId = 'job-123' + + render() + + await user.click(screen.getByTestId('research-panel-toggle')) + + expect(mockOpenRightPanel).toHaveBeenCalledWith('research') + expect(mockLoadReport).toHaveBeenCalledWith('job-123') + }) }) }) diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.tsx index 8451f84f..f90ba27f 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.tsx @@ -58,7 +58,7 @@ export const ResearchPanel: FC = ({ children, isAuthenticate const deepResearchAgentsCount = useChatStore((state) => state.deepResearchAgents.length) const deepResearchToolCallsCount = useChatStore((state) => state.deepResearchToolCalls.length) const deepResearchFilesCount = useChatStore((state) => state.deepResearchFiles.length) - const { importStreamOnly, isLoading: isStreamLoading } = useLoadJobData() + const { loadReport, importStreamOnly, isLoading: isStreamLoading } = useLoadJobData() const { idToken } = useAuth() const prefersReducedMotion = useReducedMotion() @@ -87,6 +87,28 @@ export const ResearchPanel: FC = ({ children, isAuthenticate : researchPanelTab === 'thinking' ? 'Collecting research activity...' : 'Preparing research tasks...' + const shouldLoadReport = + researchPanelTab === 'report' && + Boolean(deepResearchJobId) && + !hasReportContent && + !isDeepResearchStreaming && + !isStreamLoading + + const loadDataForTab = useCallback( + (tab: ResearchPanelTab) => { + if (!deepResearchJobId || isDeepResearchStreaming || isStreamLoading) return + + if (TABS_REQUIRING_STREAM.includes(tab) && !deepResearchStreamLoaded) { + void importStreamOnly(deepResearchJobId) + return + } + + if (tab === 'report' && !hasReportContent) { + void loadReport(deepResearchJobId) + } + }, + [deepResearchJobId, isDeepResearchStreaming, isStreamLoading, deepResearchStreamLoaded, importStreamOnly, hasReportContent, loadReport] + ) // Clean up cancel fallback timer on unmount useEffect(() => { @@ -151,37 +173,17 @@ export const ResearchPanel: FC = ({ children, isAuthenticate } else { openRightPanel('research') - // Trigger stream import when opening panel if current tab requires it and data not loaded - if ( - TABS_REQUIRING_STREAM.includes(researchPanelTab) && - deepResearchJobId && - !deepResearchStreamLoaded && - !isDeepResearchStreaming && - !isStreamLoading - ) { - void importStreamOnly(deepResearchJobId) - } + loadDataForTab(researchPanelTab) } - }, [isAuthenticated, isOpen, closeRightPanel, openRightPanel, researchPanelTab, deepResearchJobId, deepResearchStreamLoaded, isDeepResearchStreaming, isStreamLoading, importStreamOnly]) + }, [isAuthenticated, isOpen, closeRightPanel, openRightPanel, researchPanelTab, loadDataForTab]) const handleTabChange = useCallback( (value: string) => { const tab = value as ResearchPanelTab setResearchPanelTab(tab) - - // Trigger stream import when clicking Tasks/Thinking/Citations for a loaded (non-streaming) job - if ( - TABS_REQUIRING_STREAM.includes(tab) && - deepResearchJobId && - !deepResearchStreamLoaded && - !isDeepResearchStreaming && - !isStreamLoading - ) { - // Fire and forget - don't block the tab change - void importStreamOnly(deepResearchJobId) - } + loadDataForTab(tab) }, - [setResearchPanelTab, deepResearchJobId, deepResearchStreamLoaded, isDeepResearchStreaming, isStreamLoading, importStreamOnly] + [setResearchPanelTab, loadDataForTab] ) return ( @@ -302,7 +304,7 @@ export const ResearchPanel: FC = ({ children, isAuthenticate : 'Loading report...'} - ) : showPendingTabSpinner ? ( + ) : showPendingTabSpinner || shouldLoadReport ? ( From 825cfdd56d51dad72cdc40e008f0a80a68d0bce4 Mon Sep 17 00:00:00 2001 From: Allan Enemark Date: Mon, 16 Mar 2026 14:40:52 -0700 Subject: [PATCH 4/4] Fix report loading edge cases in research panel. Trigger archived report loads for already-open report tabs, preserve loaded research data when backfilling a missing report, and tighten test coverage so type-check and CI stay clean. Made-with: Cursor --- .../chat/hooks/use-deep-research.spec.ts | 6 +++-- .../chat/hooks/use-load-job-data.spec.ts | 18 +++++++++++++ .../features/chat/hooks/use-load-job-data.ts | 8 +++++- .../layout/components/ResearchPanel.spec.tsx | 14 +++++++++- .../layout/components/ResearchPanel.tsx | 27 ++++++++++++++++--- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts b/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts index 0354e7b5..6c442064 100644 --- a/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts +++ b/frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts @@ -157,14 +157,16 @@ const mockCreateDeepResearchClient = vi.fn((options: { callbacks: Record Promise<{ status: string }>>().mockResolvedValue({ status: 'running' }) -const mockGetJobReport = vi.fn<() => Promise<{ has_report: boolean; report?: string }>>().mockResolvedValue({ has_report: false }) +const mockGetJobReport = vi + .fn<(jobId: string, authToken?: string) => Promise<{ has_report: boolean; report?: string }>>() + .mockResolvedValue({ has_report: false }) vi.mock('@/adapters/api', () => ({ createDeepResearchClient: (options: { callbacks: Record void> }) => mockCreateDeepResearchClient(options), cancelJob: (...args: unknown[]) => mockCancelJob(...args), getJobStatus: () => mockGetJobStatus(), - getJobReport: (...args: unknown[]) => mockGetJobReport(...args), + getJobReport: (jobId: string, authToken?: string) => mockGetJobReport(jobId, authToken), })) import { useChatStore } from '../store' diff --git a/frontends/ui/src/features/chat/hooks/use-load-job-data.spec.ts b/frontends/ui/src/features/chat/hooks/use-load-job-data.spec.ts index 8d2ae5d5..96a011e1 100644 --- a/frontends/ui/src/features/chat/hooks/use-load-job-data.spec.ts +++ b/frontends/ui/src/features/chat/hooks/use-load-job-data.spec.ts @@ -151,4 +151,22 @@ describe('useLoadJobData', () => { 'Failed to get job status: 404' ) }) + + test('preserves loaded stream data when fetching missing report for same completed job', async () => { + mockStoreState.deepResearchJobId = 'job-123' + mockStoreState.deepResearchStreamLoaded = true + mockGetJobStatus.mockResolvedValue({ status: 'success' }) + mockGetJobReport.mockResolvedValue({ has_report: true, report: 'Recovered report' }) + mockGetJobState.mockResolvedValue({ has_state: false, artifacts: undefined }) + + const { result } = renderHook(() => useLoadJobData()) + + await act(async () => { + await result.current.loadReport('job-123') + }) + + expect(mockClearDeepResearch).not.toHaveBeenCalled() + expect(mockSetReportContent).toHaveBeenCalledWith('Recovered report') + expect(mockSetLoadedJobId).toHaveBeenCalledWith('job-123') + }) }) diff --git a/frontends/ui/src/features/chat/hooks/use-load-job-data.ts b/frontends/ui/src/features/chat/hooks/use-load-job-data.ts index e70ba8a4..f5d70474 100644 --- a/frontends/ui/src/features/chat/hooks/use-load-job-data.ts +++ b/frontends/ui/src/features/chat/hooks/use-load-job-data.ts @@ -491,6 +491,10 @@ export const useLoadJobData = (): UseLoadJobDataReturn => { const hasStreamData = currentState.deepResearchJobId === jobId && currentState.deepResearchStreamLoaded + const preserveExistingResearchState = + !shouldStreamFull && + currentState.deepResearchJobId === jobId && + currentState.deepResearchStreamLoaded // If we have what we need, just open the panel if (hasReportData && (!shouldStreamFull || hasStreamData)) { @@ -514,7 +518,9 @@ export const useLoadJobData = (): UseLoadJobDataReturn => { throw new Error(`Job is still ${jobStatus}. Cannot load data from incomplete job.`) } - clearDeepResearch() + if (!preserveExistingResearchState) { + clearDeepResearch() + } if (shouldStreamFull) { await streamFullJob(jobId) diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx index 842e8616..2fc8aaab 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { render, screen } from '@/test-utils' +import { waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi, describe, test, expect, beforeEach } from 'vitest' import { ResearchPanel } from './ResearchPanel' @@ -43,7 +44,7 @@ let mockDeepResearchAgentsCount = 0 let mockDeepResearchToolCallsCount = 0 let mockDeepResearchFilesCount = 0 const mockImportJobStream = vi.fn() -const mockLoadReport = vi.fn() +const mockLoadReport = vi.fn().mockResolvedValue(undefined) const mockCancelCurrentJob = vi.fn() @@ -318,6 +319,17 @@ describe('ResearchPanel', () => { expect(screen.queryByLabelText('Drafting report...')).not.toBeInTheDocument() expect(screen.getByText('Recovered final report')).toBeInTheDocument() }) + + test('loads report data automatically when panel is already open on report tab', async () => { + mockResearchPanelTab = 'report' + mockDeepResearchJobId = 'job-123' + + render() + + await waitFor(() => { + expect(mockLoadReport).toHaveBeenCalledWith('job-123') + }) + }) }) describe('segmented control groups', () => { diff --git a/frontends/ui/src/features/layout/components/ResearchPanel.tsx b/frontends/ui/src/features/layout/components/ResearchPanel.tsx index f90ba27f..9aab9858 100644 --- a/frontends/ui/src/features/layout/components/ResearchPanel.tsx +++ b/frontends/ui/src/features/layout/components/ResearchPanel.tsx @@ -65,6 +65,7 @@ export const ResearchPanel: FC = ({ children, isAuthenticate const isOpen = rightPanel === 'research' const cancelFallbackRef = useRef(null) + const pendingReportLoadJobIdRef = useRef(null) const hasThinkingContent = deepResearchLLMStepsCount > 0 || deepResearchAgentsCount > 0 || @@ -80,7 +81,9 @@ export const ResearchPanel: FC = ({ children, isAuthenticate (researchPanelTab === 'citations' && deepResearchCitationsCount === 0) || (researchPanelTab === 'report' && !hasReportContent)) const pendingTabMessage = - researchPanelTab === 'report' || currentStatus === 'writing' + researchPanelTab === 'report' && !isDeepResearchStreaming + ? 'Loading report...' + : currentStatus === 'writing' ? 'Drafting report...' : researchPanelTab === 'citations' ? 'Gathering sources...' @@ -88,12 +91,24 @@ export const ResearchPanel: FC = ({ children, isAuthenticate ? 'Collecting research activity...' : 'Preparing research tasks...' const shouldLoadReport = + isOpen && researchPanelTab === 'report' && Boolean(deepResearchJobId) && !hasReportContent && !isDeepResearchStreaming && !isStreamLoading + const requestReportLoad = useCallback(() => { + if (!deepResearchJobId || pendingReportLoadJobIdRef.current === deepResearchJobId) return + + pendingReportLoadJobIdRef.current = deepResearchJobId + void Promise.resolve(loadReport(deepResearchJobId)).finally(() => { + if (pendingReportLoadJobIdRef.current === deepResearchJobId) { + pendingReportLoadJobIdRef.current = null + } + }) + }, [deepResearchJobId, loadReport]) + const loadDataForTab = useCallback( (tab: ResearchPanelTab) => { if (!deepResearchJobId || isDeepResearchStreaming || isStreamLoading) return @@ -104,10 +119,10 @@ export const ResearchPanel: FC = ({ children, isAuthenticate } if (tab === 'report' && !hasReportContent) { - void loadReport(deepResearchJobId) + requestReportLoad() } }, - [deepResearchJobId, isDeepResearchStreaming, isStreamLoading, deepResearchStreamLoaded, importStreamOnly, hasReportContent, loadReport] + [deepResearchJobId, isDeepResearchStreaming, isStreamLoading, deepResearchStreamLoaded, importStreamOnly, hasReportContent, requestReportLoad] ) // Clean up cancel fallback timer on unmount @@ -120,6 +135,12 @@ export const ResearchPanel: FC = ({ children, isAuthenticate } }, []) + useEffect(() => { + if (shouldLoadReport) { + requestReportLoad() + } + }, [shouldLoadReport, requestReportLoad]) + const handleClose = useCallback(() => { closeRightPanel() }, [closeRightPanel])