Skip to content

Improve loading spinner response #153

Open
exactlyallan wants to merge 4 commits intoNVIDIA-AI-Blueprints:developfrom
exactlyallan:aiq_UI-hangfix-report-tab-load
Open

Improve loading spinner response #153
exactlyallan wants to merge 4 commits intoNVIDIA-AI-Blueprints:developfrom
exactlyallan:aiq_UI-hangfix-report-tab-load

Conversation

@exactlyallan
Copy link
Collaborator

Summary

  • Fix research panel loading states so users see immediate feedback when a new deep research report starts (spinner)
  • Backfill the final report on deep research completion when live report content has not yet reached the store, so an already-open report tab updates automatically.
  • Load archived report content when users open the research panel directly to the Report tab for an already completed job, without requiring the View report button.

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
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
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
@exactlyallan exactlyallan self-assigned this Mar 16, 2026
@exactlyallan exactlyallan added the UI/UX UI and UX related fixes label Mar 16, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR improves the deep research panel's loading UX by adding per-tab pending spinners, auto-loading the report when the panel is opened directly to the Report tab, and backfilling the final report content when the SSE stream ends before the report has been written to the store.

Key changes:

  • ResearchPanel.tsx: new showPendingTabSpinner / shouldLoadReport conditions drive a spinner for every tab while its data is arriving; loadDataForTab helper consolidates the existing stream-import and new report-load triggers; a useEffect + requestReportLoad mechanism auto-fetches the report for completed jobs opened directly to the Report tab.
  • use-deep-research.ts: onJobStatus made async to call getJobReport and backfill reportContent when it is empty at job completion.
  • use-load-job-data.ts: new preserveExistingResearchState flag prevents clearDeepResearch from wiping already-loaded stream artifacts when only the report text is missing.
  • Two notable issues found: (1) loadReport carries navigation side effects (openRightPanel + setResearchPanelTab) that will fire on completion even if the user has closed the panel during the in-flight fetch, potentially re-opening the panel unexpectedly; (2) the isOwnerActive() ownership guard in the now-async onJobStatus is not re-checked after the await getJobReport call, leaving a window for stale state mutations if the user navigates away mid-fetch.

Confidence Score: 3/5

  • Mostly safe but two logic issues could cause unexpected panel re-opens and stale state mutations in edge cases
  • The feature logic is well-structured and test coverage is solid, but the loadReport navigation side effect can re-open a panel the user intentionally closed, and the missing isOwnerActive() re-check after the async getJobReport call can write stale data to a conversation the user is no longer viewing.
  • ResearchPanel.tsx (auto-load triggers unwanted navigation) and use-deep-research.ts (async ownership guard gap)

Important Files Changed

Filename Overview
frontends/ui/src/features/layout/components/ResearchPanel.tsx Adds per-tab pending spinners, shouldLoadReport auto-trigger useEffect, and loadDataForTab helper. The main concern is that loadReport carries navigation side effects (openRightPanel + setResearchPanelTab) that will fire on completion even if the user has closed the panel during the fetch.
frontends/ui/src/features/chat/hooks/use-deep-research.ts Makes onJobStatus async to support backfilling a missing report via getJobReport. The ownership guard (isOwnerActive()) is not re-checked after the await, leaving a window where stale state snapshots can trigger mutations against a different conversation.
frontends/ui/src/features/chat/hooks/use-load-job-data.ts Adds preserveExistingResearchState flag so clearDeepResearch is skipped when loading a missing report for a job whose stream data is already in the store. Logic is correct and well-tested.
frontends/ui/src/features/layout/components/ResearchPanel.spec.tsx Good coverage added for the new loading behaviors. Tests correctly use waitFor for async effects and cover the panel-toggle, tab-click, and auto-load paths.
frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts New test verifies the backfill logic when report content is empty on onJobStatus: 'success'. Mock updated to pass jobId and authToken to getJobReport, which is correct.
frontends/ui/src/features/chat/hooks/use-load-job-data.spec.ts Adds a focused test confirming clearDeepResearch is not called when stream data is already loaded for the same job. Straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant ResearchPanel
    participant useLoadJobData
    participant useDeepResearch
    participant API

    Note over ResearchPanel: Panel open, Report tab, no content
    ResearchPanel->>ResearchPanel: shouldLoadReport = true (useEffect)
    ResearchPanel->>useLoadJobData: requestReportLoad() → loadReport(jobId)
    useLoadJobData->>API: getJobStatus(jobId)
    API-->>useLoadJobData: { status: 'success' }
    useLoadJobData->>API: getJobReport(jobId) + getJobState(jobId)
    User->>ResearchPanel: Closes panel (closeRightPanel)
    API-->>useLoadJobData: { has_report: true, report: '...' }
    useLoadJobData->>useLoadJobData: setReportContent(report)
    useLoadJobData->>ResearchPanel: setResearchPanelTab('report') ⚠️
    useLoadJobData->>ResearchPanel: openRightPanel('research') ⚠️ re-opens panel

    Note over useDeepResearch: Concurrent: SSE job completion
    useDeepResearch->>useDeepResearch: onJobStatus('success') — isOwnerActive() ✅
    useDeepResearch->>API: await getJobReport(jobId)
    User->>ResearchPanel: Navigates to different conversation
    API-->>useDeepResearch: report content
    useDeepResearch->>useDeepResearch: patchConversationMessage(stale ownerConvId) ⚠️
    useDeepResearch->>useDeepResearch: completeDeepResearch() / setStreaming(false)
Loading

Last reviewed commit: 825cfdd

Comment on lines +90 to +95
const shouldLoadReport =
researchPanelTab === 'report' &&
Boolean(deepResearchJobId) &&
!hasReportContent &&
!isDeepResearchStreaming &&
!isStreamLoading
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldLoadReport shows spinner but never triggers load

shouldLoadReport is used only in the JSX (line 307) to conditionally render a spinner, but there is no useEffect or other mechanism that actually calls loadReport() when this condition becomes true. This means if the component renders (e.g. on mount or a re-render from state change) while the panel is already open on the 'report' tab with a completed job and no report content, the user will see the "Drafting report..." spinner indefinitely — the report data will never be fetched.

Consider adding a useEffect to trigger the load:

useEffect(() => {
  if (shouldLoadReport && deepResearchJobId) {
    void loadReport(deepResearchJobId)
  }
}, [shouldLoadReport, deepResearchJobId, loadReport])

Additionally, when shouldLoadReport is true the job is complete (!isDeepResearchStreaming), so the message "Drafting report..." is misleading — something like "Loading report..." would be more accurate for the archived-report case.

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
Comment on lines +101 to +109
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
}
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-load triggers openRightPanel when panel is closed

requestReportLoad delegates to loadReport, which ultimately calls loadJobData. On success, loadJobData unconditionally calls setResearchPanelTab('report') and openRightPanel('research') (lines 541–542 in use-load-job-data.ts). This means if the user closes the panel while an auto-triggered report fetch is in-flight (e.g., the 200–500 ms API round-trip), the completion will forcefully re-open the panel the user just closed.

The navigation side effects are correct for the explicit "View Report" button flow, but are a regression here where loadReport is being used as a pure data-fetch. Consider either adding a noNavigate option to loadJobData / loadReport, or having requestReportLoad check panel state before invoking the underlying hook:

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])

The .finally cleanup exists but there is no guard inside loadJobData against the navigation after the panel has been closed while the fetch was in-flight.

Comment on lines +271 to +281
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)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ownership not re-validated after await getJobReport

isOwnerActive() is called once at the top of the callback (line 254), but the code makes multiple store mutations after the await getJobReport(...) call. If the user switches conversations during the async fetch (a few hundred ms window), ownerConvId and messageId were captured from the pre-await state snapshot and are now stale. The subsequent patchConversationMessage, stopAllDeepResearchSpinners, completeDeepResearch, and setStreaming(false) calls will mutate state for the old session, potentially corrupting the current view.

Adding a guard after the await prevents this:

const response = await getJobReport(jobId, idToken || undefined)
if (!isOwnerActive()) return  // re-check after async gap
if (response.has_report && response.report?.trim()) {
  resolvedReport = response.report
  setReportContent(response.report)
}

Comment on lines +283 to 285
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State snapshot read before await is stale after

deepResearchLLMSteps and deepResearchToolCalls (used for totalTokens and toolCallCount in the success banner) are read from state – a snapshot captured before the await getJobReport. If the SSE stream delivers any final LLM or tool-call events while the fetch is in-flight, the token/call counts in the banner will be incorrect. Consider re-reading those values from useChatStore.getState() after the await:

const freshState = useChatStore.getState()
const { deepResearchLLMSteps, deepResearchToolCalls } = freshState

Comment on lines +75 to +82
const showPendingTabSpinner =
Boolean(deepResearchJobId) &&
isDeepResearchStreaming &&
!isStreamLoading &&
((researchPanelTab === 'tasks' && deepResearchTodosCount === 0) ||
(researchPanelTab === 'thinking' && !hasThinkingContent) ||
(researchPanelTab === 'citations' && deepResearchCitationsCount === 0) ||
(researchPanelTab === 'report' && !hasReportContent))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldLoadReport spinner shown even when panel is closed

The shouldLoadReport condition correctly guards on isOpen, but the JSX at line 328 uses showPendingTabSpinner || shouldLoadReport as the condition for the spinner block. showPendingTabSpinner does not require isOpen, so the spinner markup is rendered even when the panel is hidden (aria-hidden="true"). While it does not affect visual output (the panel is invisible), it creates unnecessary DOM nodes and could cause unexpected aria-label matches in tests. Consider gating showPendingTabSpinner on isOpen as well:

const showPendingTabSpinner =
  isOpen &&
  Boolean(deepResearchJobId) &&
  isDeepResearchStreaming &&
  ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

UI/UX UI and UX related fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant