Skip to content
Open
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
25 changes: 25 additions & 0 deletions frontends/ui/config/vitest/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ class MutationObserver {

Object.defineProperty(global, 'sessionStorage', { value: sessionStorageMock })
global.ResizeObserver = ResizeObserver

// Node can expose a partial `localStorage` (e.g. missing `removeItem`) when a path
// triggers web storage before happy-dom is ready. Chat/documents tests need full Storage.
if (typeof globalThis.localStorage?.removeItem !== 'function') {
const mem: Record<string, string> = {}
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key: string) => (key in mem ? mem[key] : null),
setItem: (key: string, value: string) => {
mem[key] = value
},
removeItem: (key: string) => {
delete mem[key]
},
clear: () => {
for (const k of Object.keys(mem)) delete mem[k]
},
key: (index: number) => Object.keys(mem)[index] ?? null,
get length() {
return Object.keys(mem).length
},
} as Storage,
configurable: true,
})
}
// @ts-expect-error - Partial mock
global.IntersectionObserver = IntersectionObserver
// @ts-expect-error - Partial mock
Expand Down
1 change: 1 addition & 0 deletions frontends/ui/src/adapters/ui/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const Help = createIcon('help-circle')
export const Lock = createIcon('lock-closed')
export const Plug = createIcon('plug-recepticle')
export const Wrench = createIcon('wrench')
export const ChatMessage = createIcon('chat-message')
export const Retry = createIcon('retry')
export const Cancel = createIcon('cancel')

Expand Down
27 changes: 27 additions & 0 deletions frontends/ui/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@
--header-height: 3.5rem;
}

/*
* KUI sets on .nv-side-panel-content:
* --heading-padding-block: spacing-density-xl + spacing*3 (~28px per block side)
* and .nv-side-panel-heading uses padding-block: var(--heading-padding-block),
* which makes the title row ~77px. Override the token on docked panels so padding
* matches the main header height. The bordered heading has a 1px bottom border;
* spacing*6 matches KUI’s default heading icon size used in the row height math.
* .nv-side-panel-close uses the same token for vertical position (inherits).
*/
.nv-side-panel-content.side-panel-dock-under-header {
--heading-padding-block: calc(
(var(--header-height) - 1px - calc(var(--spacing) * 6)) / 2
);
}

.nv-side-panel-content.side-panel-dock-under-header .nv-side-panel-heading {
box-sizing: border-box;
min-height: var(--header-height);
}

/* Bordered panels add border-top on .nv-side-panel-footer; remove for docked app panels. */
.nv-side-panel-content.side-panel-dock-under-header.nv-side-panel-content--bordered
.nv-side-panel-footer {
border-top-width: 0;
border-top-style: none;
}

/* =============================================================================
NVIDIA brand color overrides (unlayered — highest cascade priority)

Expand Down
21 changes: 20 additions & 1 deletion frontends/ui/src/features/chat/lib/session-activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
*/

import { describe, it, expect } from 'vitest'
import { hasActiveDeepResearchJob, getPersistedActivityFlags } from './session-activity'
import {
hasActiveDeepResearchJob,
getPersistedActivityFlags,
hasNoUserChatMessages,
} from './session-activity'
import type { ChatMessage } from '../types'

/**
Expand All @@ -22,6 +26,21 @@ const makeMessage = (
...overrides,
})

describe('hasNoUserChatMessages', () => {
it('returns true when there are no user messages', () => {
expect(hasNoUserChatMessages([])).toBe(true)
expect(
hasNoUserChatMessages([
makeMessage({ messageType: 'file_upload_status', fileUploadStatusData: { type: 'uploaded', fileCount: 1, jobId: 'j1' } }),
])
).toBe(true)
})

it('returns false when a user message exists', () => {
expect(hasNoUserChatMessages([makeMessage({ messageType: 'user', content: 'hi' })])).toBe(false)
})
})

describe('hasActiveDeepResearchJob', () => {
it('returns false for empty message array', () => {
expect(hasActiveDeepResearchJob([])).toBe(false)
Expand Down
7 changes: 7 additions & 0 deletions frontends/ui/src/features/chat/lib/session-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ const ACTIVE_JOB_STATUSES: readonly DeepResearchJobStatus[] = ['submitted', 'run
* @param messages - The conversation's message array (from persisted state)
* @returns true if the most recent deep research job is still running
*/
/**
* True when the user has never sent a typed chat message in this session.
* Upload banners (`file_upload_status`) and other non-user types do not count.
*/
export const hasNoUserChatMessages = (messages: ChatMessage[]): boolean =>
!messages.some((m) => m.messageType === 'user')

export const hasActiveDeepResearchJob = (messages: ChatMessage[]): boolean => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
Expand Down
181 changes: 175 additions & 6 deletions frontends/ui/src/features/chat/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,24 @@ vi.mock('@/adapters/api/deep-research-client', () => ({
cancelJob: mockDeepResearchApi.cancelJob,
}))

const mockDiscardSessionResources = vi.hoisted(() => vi.fn())

vi.mock('@/features/documents/discard-session-resources', () => ({
discardSessionDocumentsResources: mockDiscardSessionResources,
}))

describe('useChatStore', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.removeItem(STORAGE_KEY)
mockLayoutState.closeRightPanel.mockClear()
mockLayoutState.setEnabledDataSources.mockClear()
mockLayoutState.enabledDataSourceIds = ['web_search']
mockLayoutState.availableDataSources = [{ id: 'web_search' }, { id: 'knowledge_base', requires_auth: true }]
mockLayoutState.availableDataSources = [
{ id: 'web_search' },
{ id: 'knowledge_base', requires_auth: true },
]
mockDiscardSessionResources.mockClear()
mockDeepResearchApi.getJobStatus.mockReset()
mockDeepResearchApi.cancelJob.mockReset()
// Reset store to initial state before each test
Expand Down Expand Up @@ -223,7 +233,7 @@ describe('useChatStore', () => {
const conv = useChatStore.getState().createConversation()

expect(conv.userId).toBe('user-1')
expect(conv.title).toBe('New Session')
expect(conv.title).toBe('')
expect(conv.messages).toEqual([])
expect(useChatStore.getState().currentConversation).toEqual(conv)
expect(useChatStore.getState().conversations).toContainEqual(conv)
Expand Down Expand Up @@ -296,6 +306,136 @@ describe('useChatStore', () => {
})
})

describe('upload-only session cleanup', () => {
const uploadOnlyConv = (id: string): Conversation => ({
id,
userId: 'user-1',
title: 'New chat',
messages: [
{
id: 'banner-1',
role: 'assistant',
content: '',
timestamp: new Date(),
messageType: 'file_upload_status',
fileUploadStatusData: { type: 'uploaded', fileCount: 2, jobId: 'job-1' },
},
],
createdAt: new Date(),
updatedAt: new Date(),
})

test('startNewSessionDraft removes upload-only session and discards documents', () => {
const conv = uploadOnlyConv('upload-only-1')
useChatStore.setState({
currentUserId: 'user-1',
currentConversation: conv,
conversations: [conv],
})

useChatStore.getState().startNewSessionDraft()

expect(mockDiscardSessionResources).toHaveBeenCalledWith('upload-only-1')
expect(useChatStore.getState().conversations.some((c) => c.id === 'upload-only-1')).toBe(false)
expect(useChatStore.getState().currentConversation).toBeNull()
})

test('startNewSessionDraft keeps session after user has chatted', () => {
const conv: Conversation = {
...uploadOnlyConv('with-user'),
messages: [
{
id: 'u1',
role: 'user',
content: 'hello',
timestamp: new Date(),
messageType: 'user',
},
],
}
useChatStore.setState({
currentUserId: 'user-1',
currentConversation: conv,
conversations: [conv],
})

useChatStore.getState().startNewSessionDraft()

expect(mockDiscardSessionResources).not.toHaveBeenCalled()
expect(useChatStore.getState().conversations.some((c) => c.id === 'with-user')).toBe(true)
})

test('selectConversation removes prior upload-only session when switching away', () => {
const uploadOnly = uploadOnlyConv('u-only')
const other: Conversation = {
id: 'other',
userId: 'user-1',
title: 'Other',
messages: [
{
id: 'm1',
role: 'user',
content: 'hi',
timestamp: new Date(),
messageType: 'user',
},
],
createdAt: new Date(),
updatedAt: new Date(),
}
useChatStore.setState({
currentUserId: 'user-1',
currentConversation: uploadOnly,
conversations: [uploadOnly, other],
})

useChatStore.getState().selectConversation('other')

expect(mockDiscardSessionResources).toHaveBeenCalledWith('u-only')
expect(useChatStore.getState().conversations.some((c) => c.id === 'u-only')).toBe(false)
expect(useChatStore.getState().currentConversation?.id).toBe('other')
})

test('selectConversation does not remove upload-only session while files are uploading', async () => {
const { useDocumentsStore } = await import('@/features/documents/store')
const uploadOnly = uploadOnlyConv('u-busy')
const other: Conversation = {
id: 'other-2',
userId: 'user-1',
title: 'Other',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
}
useDocumentsStore.setState({
trackedFiles: [
{
id: 'tf-1',
file: new File(['x'], 'x.txt'),
fileName: 'x.txt',
fileSize: 1,
status: 'uploading',
progress: 0,
collectionName: 'u-busy',
uploadedAt: new Date().toISOString(),
},
],
})
useChatStore.setState({
currentUserId: 'user-1',
currentConversation: uploadOnly,
conversations: [uploadOnly, other],
})

useChatStore.getState().selectConversation('other-2')

expect(mockDiscardSessionResources).not.toHaveBeenCalled()
expect(useChatStore.getState().conversations.some((c) => c.id === 'u-busy')).toBe(true)

useDocumentsStore.setState({ trackedFiles: [] })
})
})

describe('selectConversation', () => {
test('selects conversation owned by current user', () => {
const conv: Conversation = {
Expand Down Expand Up @@ -376,7 +516,7 @@ describe('useChatStore', () => {
const conv: Conversation = {
id: 'conv-1',
userId: 'user-1',
title: 'New Session',
title: '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
Expand All @@ -398,7 +538,7 @@ describe('useChatStore', () => {
const conv: Conversation = {
id: 'conv-1',
userId: 'user-1',
title: 'New Session',
title: '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
Expand All @@ -416,11 +556,40 @@ describe('useChatStore', () => {
)
})

test('updates title on first user message when file upload status messages exist', () => {
const conv: Conversation = {
id: 'conv-1',
userId: 'user-1',
title: '',
messages: [
{
id: 'status-1',
role: 'assistant',
content: '',
timestamp: new Date(),
messageType: 'file_upload_status',
fileUploadStatusData: { type: 'uploaded', fileCount: 1, jobId: 'job-1' },
},
],
createdAt: new Date(),
updatedAt: new Date(),
}
useChatStore.setState({
currentUserId: 'user-1',
currentConversation: conv,
conversations: [conv],
})

useChatStore.getState().addUserMessage('Summarize my document')

expect(useChatStore.getState().currentConversation?.title).toBe('Summarize my document')
})

test('truncates long titles to 50 characters', () => {
const conv: Conversation = {
id: 'conv-1',
userId: 'user-1',
title: 'New Session',
title: '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
Expand Down Expand Up @@ -467,7 +636,7 @@ describe('useChatStore', () => {
const conv: Conversation = {
id: 'conv-1',
userId: 'user-1',
title: 'New Session',
title: '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
Loading
Loading