diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx new file mode 100644 index 0000000000..7610b3eff8 --- /dev/null +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, type RenderOptions, screen } from '@testing-library/react'; +import ModelsBottomBar from './ModelsBottomBar'; +import { IntlTestWrapper } from '../../../../i18n/test-utils'; + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); + +const createDropdownRef = (): React.RefObject => + ({ current: document.createElement('div') }) as React.RefObject; + +let mockCurrentModel: string | null = 'config-model'; +let mockCurrentProvider: string | null = 'config-provider'; +const mockGetProviders = vi.fn(); +const mockOnModelChanged = vi.fn(); + +vi.mock('../../../ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + currentModel: mockCurrentModel, + currentProvider: mockCurrentProvider, + }), +})); + +vi.mock('../../../ConfigContext', () => ({ + useConfig: () => ({ + getProviders: mockGetProviders, + }), +})); + +vi.mock('../modelInterface', () => ({ + getProviderMetadata: vi.fn().mockResolvedValue({ display_name: 'Config Provider' }), +})); + +vi.mock('../predefinedModelsUtils', () => ({ + getModelDisplayName: (model: string) => `Display ${model}`, +})); + +vi.mock('../../../bottom_menu/BottomMenuAlertPopover', () => ({ + default: () => null, +})); + +vi.mock('../../../ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../../localInference/ModelSettingsPanel', () => ({ + ModelSettingsPanel: () => null, +})); + +vi.mock('../../../ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe('ModelsBottomBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCurrentModel = 'config-model'; + mockCurrentProvider = 'config-provider'; + mockGetProviders.mockResolvedValue([]); + }); + + it('shows a loading placeholder while the active session model is still loading', async () => { + renderWithIntl( + + ); + + expect(screen.getByTestId('model-loading-state')).toHaveTextContent('Loading model...'); + }); + + it('shows the active session model once the session has loaded', async () => { + renderWithIntl( + + ); + + expect(screen.getByText('session-model')).toBeInTheDocument(); + expect(screen.queryByTestId('model-loading-state')).not.toBeInTheDocument(); + }); + + it('shows the configured model when there is no active session', async () => { + renderWithIntl( + + ); + + expect(screen.getByText('config-model')).toBeInTheDocument(); + expect(screen.queryByTestId('model-loading-state')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index e818c64e50..40bbde729a 100644 --- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -1,4 +1,4 @@ -import { Sliders, Bot, Settings } from 'lucide-react'; +import { Sliders, Bot, LoaderCircle, Settings } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { SwitchModelModal } from '../subcomponents/SwitchModelModal'; @@ -26,6 +26,10 @@ const i18n = defineMessages({ id: 'modelsBottomBar.currentModel', defaultMessage: 'Current model', }, + loadingModel: { + id: 'modelsBottomBar.loadingModel', + defaultMessage: 'Loading model...', + }, changeModel: { id: 'modelsBottomBar.changeModel', defaultMessage: 'Change Model', @@ -73,10 +77,13 @@ export default function ModelsBottomBar({ const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false); const [providerDefaultModel, setProviderDefaultModel] = useState(null); - // Hide label while session data is still being fetched (avoids flashing - // the config default before the session's actual model arrives). - const isModelLoading = sessionId && !sessionLoaded; + // Show a visible loading placeholder while session metadata is still being fetched, + // rather than flashing the config default or leaving the footer blank. + const isModelLoading = Boolean(sessionId && !sessionLoaded); const displayModel = currentModel || providerDefaultModel || displayModelName; + const loadingModelLabel = intl.formatMessage(i18n.loadingModel); + const triggerLabel = isModelLoading ? loadingModelLabel : displayModel; + const menuModelLabel = isModelLoading ? loadingModelLabel : displayModelName; useEffect(() => { if (!currentProvider) return; @@ -121,16 +128,24 @@ export default function ModelsBottomBar({
- - {displayModel} - + {isModelLoading ? ( + + + {triggerLabel} + + ) : ( + {triggerLabel} + )}
{intl.formatMessage(i18n.currentModel)}

- {displayModelName} - {displayProvider && ` — ${displayProvider}`} + {menuModelLabel} + {!isModelLoading && displayProvider && ` — ${displayProvider}`}

setIsAddModelModalOpen(true)}> {intl.formatMessage(i18n.changeModel)} diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 93fa007760..8c6fcc592b 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -2315,6 +2315,9 @@ "modelsBottomBar.currentModel": { "defaultMessage": "Current model" }, + "modelsBottomBar.loadingModel": { + "defaultMessage": "Loading model..." + }, "modelsBottomBar.localModelSettings": { "defaultMessage": "Local Model Settings" }, diff --git a/ui/desktop/tests/e2e/fixtures.ts b/ui/desktop/tests/e2e/fixtures.ts index 6babe19cfe..566423614e 100644 --- a/ui/desktop/tests/e2e/fixtures.ts +++ b/ui/desktop/tests/e2e/fixtures.ts @@ -1,9 +1,9 @@ import { test as base, Page, Browser, chromium } from '@playwright/test'; -import { spawn, ChildProcess } from 'child_process'; +import { exec, spawn, ChildProcess } from 'child_process'; import { join } from 'path'; import { promisify } from 'util'; -const execAsync = promisify(require('child_process').exec); +const execAsync = promisify(exec); type GooseTestFixtures = { goosePage: Page; @@ -28,7 +28,8 @@ type GooseTestFixtures = { */ export const test = base.extend({ // Test-scoped fixture: launches a fresh Electron app for each test - goosePage: async ({}, use, testInfo) => { + goosePage: async ({ browserName }, providePage, testInfo) => { + void browserName; console.log(`Launching fresh Electron app for test: ${testInfo.title}`); let appProcess: ChildProcess | null = null; @@ -80,8 +81,9 @@ export const test = base.extend({ console.log(`Connected to Electron app on attempt ${attempt} (~${(attempt * retryDelay) / 1000}s)`); break; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); if (attempt === maxRetries) { - throw new Error(`Failed to connect to Electron app after ${maxRetries} attempts (${(maxRetries * retryDelay) / 1000}s). Last error: ${error.message}`); + throw new Error(`Failed to connect to Electron app after ${maxRetries} attempts (${(maxRetries * retryDelay) / 1000}s). Last error: ${errorMessage}`); } // Wait before next retry await new Promise(resolve => setTimeout(resolve, retryDelay)); @@ -92,26 +94,28 @@ export const test = base.extend({ throw new Error('Browser connection failed unexpectedly'); } - // Get the electron app context and first page - const contexts = browser.contexts(); - if (contexts.length === 0) { - throw new Error('No browser contexts found'); + // Wait for Electron to create its first window after the CDP endpoint is up. + let page: Page | null = null; + for (let attempt = 1; attempt <= 100; attempt++) { + const contexts = browser.contexts(); + page = contexts.flatMap((context) => context.pages())[0] ?? null; + if (page) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); } - const pages = contexts[0].pages(); - if (pages.length === 0) { + if (!page) { throw new Error('No windows/pages found'); } - const page = pages[0]; - // Wait for page to be ready await page.waitForLoadState('domcontentloaded'); // Try to wait for networkidle try { await page.waitForLoadState('networkidle', { timeout: 10000 }); - } catch (error) { + } catch { console.log('NetworkIdle timeout (likely due to MCP activity), continuing...'); } @@ -124,7 +128,7 @@ export const test = base.extend({ console.log('App ready, starting test...'); // Provide the page to the test - await use(page); + await providePage(page); } finally { console.log('Cleaning up Electron app for this test...'); @@ -146,19 +150,23 @@ export const test = base.extend({ // First try SIGTERM for graceful shutdown process.kill(-appProcess.pid, 'SIGTERM'); await new Promise(resolve => setTimeout(resolve, 2000)); - } catch (e) { + } catch { // Process might already be dead } // Then SIGKILL if still running try { process.kill(-appProcess.pid, 'SIGKILL'); - } catch (e) { + } catch { // Process already exited } } console.log('Cleaned up app process'); } catch (error) { - if (error.code !== 'ESRCH' && !error.message?.includes('No such process')) { + if ( + error instanceof Error && + !('code' in error && error.code === 'ESRCH') && + !error.message.includes('No such process') + ) { console.error('Error killing app process:', error); } } diff --git a/ui/desktop/tests/e2e/loading-state.spec.ts b/ui/desktop/tests/e2e/loading-state.spec.ts new file mode 100644 index 0000000000..67286d54e0 --- /dev/null +++ b/ui/desktop/tests/e2e/loading-state.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from './fixtures'; + +test.describe('Loading State', () => { + test('shows a model placeholder while creating a new chat session', async ({ goosePage }) => { + await goosePage.waitForSelector('[data-testid="chat-input"]', { timeout: 30000 }); + + const chatInput = await goosePage.waitForSelector('[data-testid="chat-input"]'); + await chatInput.fill('Respond with the single word hello.'); + await chatInput.press('Enter'); + + await goosePage.waitForSelector('[data-testid="loading-indicator"]', { + state: 'visible', + timeout: 10000, + }); + + const loadingModel = goosePage.locator('[data-testid="model-loading-state"]'); + await expect(loadingModel).toHaveText(/loading model/i, { timeout: 10000 }); + + await goosePage.screenshot({ + path: test.info().outputPath('loading-state-fresh-session.png'), + fullPage: true, + }); + }); +});