Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontends/ui/src/adapters/api/deep-research-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export interface DeepResearchCallbacks {
onToolStart?: (name: string, input?: Record<string, unknown>, workflow?: string, eventId?: string, agentId?: string) => void
onToolEnd?: (name: string, output?: string, eventId?: string, agentId?: string) => void
/** Called on artifact updates */
onTodoUpdate?: (todos: TodoItem[]) => void
onTodoUpdate?: (todos: TodoItem[], workflow?: string) => void
onCitationUpdate?: (url: string, content: string, isCited?: boolean) => void
onFileUpdate?: (filename: string, content: string) => void
onOutputUpdate?: (content: string, outputCategory?: string, workflow?: string) => void
Expand Down Expand Up @@ -498,7 +498,7 @@ export const createDeepResearchClient = (options: DeepResearchStreamOptions): De

switch (artifactData.type) {
case 'todo':
callbacks.onTodoUpdate?.(artifactData.content as TodoItem[])
callbacks.onTodoUpdate?.(artifactData.content as TodoItem[], artifactWorkflow)
break
case 'citation_source':
// citation_source = "Referenced" sources (discovered during search)
Expand Down
5 changes: 5 additions & 0 deletions frontends/ui/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ body {
color: #76b900 !important;
}

/* Constrain spinner SVG inside the research panel toggle tab */
.research-panel-toggle .nv-spinner svg {
width: 100%;
}

/* Global scrollbar styles */
*::-webkit-scrollbar {
width: 8px;
Expand Down
32 changes: 6 additions & 26 deletions frontends/ui/src/features/chat/components/ChatThinking.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,22 +223,17 @@ describe('ChatThinking', () => {
})

describe('data sources summary', () => {
test('displays data sources when provided', async () => {
const user = userEvent.setup()
test('data sources are visible without expanding the collapsible', () => {
const steps = [createStep()]
const enabledDataSources = ['web_search', 'knowledge_base']

render(<ChatThinking steps={steps} enabledDataSources={enabledDataSources} />)

// Expand to see content
await user.click(screen.getByText(/Show thinking/))

expect(screen.getByText('Selected Data Sources:')).toBeVisible()
expect(screen.getByText('Web Search, Knowledge Base')).toBeVisible()
})

test('displays files when provided', async () => {
const user = userEvent.setup()
test('displays files when provided', () => {
const steps = [createStep()]
const messageFiles = [
{ id: 'file-1', fileName: 'document.pdf' },
Expand All @@ -247,14 +242,11 @@ describe('ChatThinking', () => {

render(<ChatThinking steps={steps} messageFiles={messageFiles} />)

await user.click(screen.getByText(/Show thinking/))

expect(screen.getByText('Selected Data Sources:')).toBeVisible()
expect(screen.getByText('document.pdf, report.docx')).toBeVisible()
})

test('displays both data sources and files', async () => {
const user = userEvent.setup()
test('displays both data sources and files', () => {
const steps = [createStep()]
const enabledDataSources = ['web_search']
const messageFiles = [{ id: 'file-1', fileName: 'document.pdf' }]
Expand All @@ -267,47 +259,35 @@ describe('ChatThinking', () => {
/>
)

await user.click(screen.getByText(/Show thinking/))

expect(screen.getByText('Selected Data Sources:')).toBeVisible()
expect(screen.getByText('Web Search')).toBeVisible()
expect(screen.getByText('document.pdf')).toBeVisible()
})

test('excludes knowledge_layer from data sources display', async () => {
const user = userEvent.setup()
test('excludes knowledge_layer from data sources display', () => {
const steps = [createStep()]
const enabledDataSources = ['web_search', 'knowledge_layer']

render(<ChatThinking steps={steps} enabledDataSources={enabledDataSources} />)

await user.click(screen.getByText(/Show thinking/))

// knowledge_layer should be filtered out
expect(screen.getByText('Web Search')).toBeVisible()
expect(screen.queryByText(/Knowledge Layer/i)).not.toBeInTheDocument()
})

test('does not show data sources section when no sources or files', async () => {
const user = userEvent.setup()
test('does not show data sources section when no sources or files', () => {
const steps = [createStep()]

render(<ChatThinking steps={steps} />)

await user.click(screen.getByText(/Show thinking/))

expect(screen.queryByText('Selected Data Sources')).not.toBeInTheDocument()
})

test('formats data source names correctly', async () => {
const user = userEvent.setup()
test('formats data source names correctly', () => {
const steps = [createStep()]
const enabledDataSources = ['web_search', 'onedrive', 'google_drive']

render(<ChatThinking steps={steps} enabledDataSources={enabledDataSources} />)

await user.click(screen.getByText(/Show thinking/))

expect(screen.getByText('Web Search, Onedrive, Google Drive')).toBeVisible()
})
})
Expand Down
48 changes: 24 additions & 24 deletions frontends/ui/src/features/chat/components/ChatThinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const ChatThinking: FC<ChatThinkingProps> = ({
<div className="bg-surface-sunken border-base w-full rounded-lg border">
<Collapsible
slotTrigger={
<Flex align="center" justify="between" className="w-full cursor-pointer px-4 pt-3 pb-6">
<Flex align="center" justify="between" className="w-full cursor-pointer px-4 pt-3" style={{ paddingBottom: 'calc(var(--spacing) * 4)' }}>
{/* Left: status indicator */}
<Flex align="center" gap="2">
{isThinking ? (
Expand Down Expand Up @@ -136,29 +136,6 @@ export const ChatThinking: FC<ChatThinkingProps> = ({
role="list"
aria-label="Thinking steps"
>
{/* Data Sources Summary - First Item */}
{(hasDataSources || hasFiles) && (
<Flex
direction="col"
className="w-full pb-3 mb-2 border-b border-base"
role="listitem"
>
<Text kind="label/bold/md" className="text-primary mb-1">
Selected Data Sources:
</Text>
{hasDataSources && (
<Text kind="body/regular/sm" className="text-primary">
{dataSourcesDisplay}
</Text>
)}
{hasFiles && (
<Text kind="body/regular/sm" className="text-secondary">
{messageFiles.map((f) => f.fileName).join(', ')}
</Text>
)}
</Flex>
)}

{/* Thinking Steps: 3 levels — Workflow (0) | Function Start/Complete (1) | model/tool (2) */}
{steps.map((step) => {
const isWorkflowRoot = step.functionName === 'chat_deepresearcher_agent'
Expand Down Expand Up @@ -187,6 +164,29 @@ export const ChatThinking: FC<ChatThinkingProps> = ({
})}
</Flex>
</Collapsible>

{/* Data Sources Summary — always visible below the collapsible */}
{(hasDataSources || hasFiles) && (
<Flex
direction="col"
className="border-base border-t px-4 pt-3"
style={{ paddingBottom: 'calc(var(--spacing) * 5)' }}
>
<Text kind="label/bold/md" className="text-primary mb-1">
Selected Data Sources:
</Text>
{hasDataSources && (
<Text kind="body/regular/sm" className="text-primary">
{dataSourcesDisplay}
</Text>
)}
{hasFiles && (
<Text kind="body/regular/sm" className="text-secondary">
{messageFiles.map((f) => f.fileName).join(', ')}
</Text>
)}
</Flex>
)}
</div>
)
}
82 changes: 78 additions & 4 deletions frontends/ui/src/features/chat/hooks/use-deep-research.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,17 +846,26 @@ describe('useDeepResearch', () => {
})
})

test('onError logs error and shows error card', async () => {
await setupConnectedHook()
test('onError logs error and performs full cleanup when backend is unreachable', async () => {
await setupConnectedHook({
activeDeepResearchMessageId: 'msg-123',
reportContent: 'Partial report',
})

// Set up mocks AFTER setupConnectedHook (which calls vi.clearAllMocks)
mockCheckBackendHealthCached.mockResolvedValue(false)
// The onError handler reads isDeepResearchStreaming from getState()
vi.mocked(useChatStore).getState = vi.fn(() => ({
...mockStoreState,
isDeepResearchStreaming: true,
deepResearchOwnerConversationId: 'test-conv-123',
activeDeepResearchMessageId: 'msg-123',
reportContent: 'Partial report',
addErrorCard: mockAddErrorCard,
stopAllDeepResearchSpinners: mockStopAllDeepResearchSpinners,
patchConversationMessage: mockPatchConversationMessage,
addDeepResearchBanner: mockAddDeepResearchBanner,
completeDeepResearch: mockCompleteDeepResearch,
setStreaming: mockSetStreaming,
setStreamLoaded: mockSetStreamLoaded,
})) as unknown as typeof useChatStore.getState

const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Expand All @@ -870,16 +879,81 @@ describe('useDeepResearch', () => {

expect(consoleWarnSpy).toHaveBeenCalledWith('Deep research SSE error:', 'Connection lost')
expect(consoleErrorSpy).toHaveBeenCalledWith('Deep research SSE failed (backend unreachable):', testError)
expect(mockSetCurrentStatus).toHaveBeenCalledWith('error')
expect(mockAddErrorCard).toHaveBeenCalledWith(
'agent.deep_research_failed',
'Connection lost',
testError.stack
)

expect(mockPatchConversationMessage).toHaveBeenCalledWith(
'test-conv-123',
'msg-123',
expect.objectContaining({
deepResearchJobStatus: 'failure',
isDeepResearchActive: false,
showViewReport: true,
})
)
expect(mockAddDeepResearchBanner).toHaveBeenCalledWith('failure', 'job-456', 'test-conv-123')
expect(mockStopAllDeepResearchSpinners).toHaveBeenCalled()
expect(mockClient?.disconnect).toHaveBeenCalled()
expect(mockSetStreamLoaded).toHaveBeenCalledWith(true)
expect(mockCompleteDeepResearch).toHaveBeenCalled()
expect(mockSetStreaming).toHaveBeenCalledWith(false)

consoleWarnSpy.mockRestore()
consoleErrorSpy.mockRestore()
})

test('onError does nothing when backend is reachable (transient SSE error)', async () => {
await setupConnectedHook()

mockCheckBackendHealthCached.mockResolvedValue(true)
vi.mocked(useChatStore).getState = vi.fn(() => ({
...mockStoreState,
isDeepResearchStreaming: true,
addErrorCard: mockAddErrorCard,
stopAllDeepResearchSpinners: mockStopAllDeepResearchSpinners,
})) as unknown as typeof useChatStore.getState

const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

await act(async () => {
await mockClient?.callbacks.onError?.(new Error('Transient error'))
})

expect(mockAddErrorCard).not.toHaveBeenCalled()
expect(mockCompleteDeepResearch).not.toHaveBeenCalled()
expect(mockSetStreaming).not.toHaveBeenCalled()

consoleWarnSpy.mockRestore()
})

test('onError skips cleanup when research already in terminal state', async () => {
await setupConnectedHook()

mockCheckBackendHealthCached.mockResolvedValue(false)
vi.mocked(useChatStore).getState = vi.fn(() => ({
...mockStoreState,
isDeepResearchStreaming: false,
deepResearchStatus: 'failure',
addErrorCard: mockAddErrorCard,
stopAllDeepResearchSpinners: mockStopAllDeepResearchSpinners,
})) as unknown as typeof useChatStore.getState

const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

await act(async () => {
await mockClient?.callbacks.onError?.(new Error('Late error'))
})

expect(mockAddErrorCard).not.toHaveBeenCalled()
expect(mockCompleteDeepResearch).not.toHaveBeenCalled()

consoleWarnSpy.mockRestore()
})

test('onDisconnect does not throw in live mode', async () => {
await setupConnectedHook()

Expand Down
27 changes: 24 additions & 3 deletions frontends/ui/src/features/chat/hooks/use-deep-research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ export const useDeepResearch = (): UseDeepResearchReturn => {
setCurrentStatus('researching')
},

onTodoUpdate: (todos: TodoItem[]) => {
onTodoUpdate: (todos: TodoItem[], workflow?: string) => {
if (workflow) return
if (buf.active) { buf.todos = todos; return }
if (!isOwnerActive()) return
resetTimeout(); setDeepResearchTodos(todos)
Expand Down Expand Up @@ -469,9 +470,29 @@ export const useDeepResearch = (): UseDeepResearchReturn => {
const backendUp = await checkBackendHealthCached()
if (backendUp) return
console.error('Deep research SSE failed (backend unreachable):', error)
const { addErrorCard } = useChatStore.getState()
addErrorCard('agent.deep_research_failed', error.message, error.stack)
setCurrentStatus('error')

const state = useChatStore.getState()
const ownerConvId = state.deepResearchOwnerConversationId
const messageId = state.activeDeepResearchMessageId
const hasReport = Boolean(state.reportContent?.trim())

if (ownerConvId && messageId) {
patchConversationMessage(ownerConvId, messageId, {
content: '',
deepResearchJobStatus: 'failure',
isDeepResearchActive: false,
showViewReport: hasReport,
})
}

state.addErrorCard('agent.deep_research_failed', error.message, error.stack)
addDeepResearchBanner('failure', jobId, ownerConvId || undefined)
stopAllDeepResearchSpinners()
clientRef.current?.disconnect()
setStreamLoaded(true)
completeDeepResearch()
setStreaming(false)
}
},

Expand Down
3 changes: 2 additions & 1 deletion frontends/ui/src/features/chat/hooks/use-load-job-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ export const useLoadJobData = (): UseLoadJobDataReturn => {
}
},

onTodoUpdate: (todos: TodoItem[]) => {
onTodoUpdate: (todos: TodoItem[], workflow?: string) => {
if (workflow) return
buffer.todos = todos
},

Expand Down
Loading
Loading