fix (desktop): fix chat input freeze after opening model picker (#11525)

This commit is contained in:
Lifei Zhou
2026-08-24 07:58:22 +00:00
committed by GitHub
parent bd16fbfbbe
commit 3d9200dd3b
2 changed files with 121 additions and 11 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, type RenderOptions, screen } from '@testing-library/react';
import { fireEvent, render, type RenderOptions, screen } from '@testing-library/react';
import ModelsBottomBar from './ModelsBottomBar';
import { IntlTestWrapper } from '../../../../i18n/test-utils';
@@ -13,6 +13,7 @@ let mockCurrentModel: string | null = 'config-model';
let mockCurrentProvider: string | null = 'config-provider';
const mockGetProviders = vi.fn();
const mockOnModelChanged = vi.fn();
const mockPreventCloseAutoFocus = vi.fn();
vi.mock('../../../ModelAndProviderContext', () => ({
useModelAndProvider: () => ({
@@ -41,10 +42,47 @@ vi.mock('../../../bottom_menu/BottomMenuAlertPopover', () => ({
}));
vi.mock('../../../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => (
<div data-testid="model-menu" data-open={open}>
<button onClick={() => onOpenChange(true)}>Open model menu</button>
{children}
</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({
children,
onCloseAutoFocus,
}: {
children: React.ReactNode;
onCloseAutoFocus?: (event: Pick<Event, 'preventDefault'>) => void;
}) => (
<div>
<button onClick={() => onCloseAutoFocus?.({ preventDefault: mockPreventCloseAutoFocus })}>
Complete model menu close
</button>
{children}
</div>
),
DropdownMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect?: () => void;
}) => <button onClick={onSelect}>{children}</button>,
DropdownMenuSeparator: () => null,
}));
vi.mock('../subcomponents/SwitchModelModal', () => ({
SwitchModelModal: () => <div data-testid="switch-model-modal" />,
}));
vi.mock('../../localInference/ModelSettingsPanel', () => ({
@@ -107,4 +145,43 @@ describe('ModelsBottomBar', () => {
expect(screen.getByText('config-model')).toBeInTheDocument();
expect(screen.queryByTestId('model-loading-state')).not.toBeInTheDocument();
});
it('opens model overlays after the menu closes with the appropriate focus behavior', () => {
renderWithIntl(
<ModelsBottomBar
sessionId="session-123"
dropdownRef={createDropdownRef()}
setView={vi.fn()}
sessionModel="local-model"
sessionProvider="local"
onModelChanged={mockOnModelChanged}
sessionLoaded={true}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Open model menu' }));
expect(screen.getByTestId('model-menu')).toHaveAttribute('data-open', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Local Model Settings' }));
expect(screen.getByTestId('model-menu')).toHaveAttribute('data-open', 'false');
expect(
screen.queryByRole('heading', { name: 'Local Model Settings — Display local-model' })
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Complete model menu close' }));
expect(
screen.getByRole('heading', { name: 'Local Model Settings — Display local-model' })
).toBeInTheDocument();
expect(mockPreventCloseAutoFocus).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '×' }));
fireEvent.click(screen.getByRole('button', { name: 'Open model menu' }));
fireEvent.click(screen.getByRole('button', { name: 'Change Model' }));
expect(screen.getByTestId('model-menu')).toHaveAttribute('data-open', 'false');
expect(screen.queryByTestId('switch-model-modal')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Complete model menu close' }));
expect(screen.getByTestId('switch-model-modal')).toBeInTheDocument();
expect(mockPreventCloseAutoFocus).toHaveBeenCalledOnce();
});
});
@@ -1,5 +1,5 @@
import { Sliders, Bot, LoaderCircle, Settings, History } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
import { View } from '../../../../utils/navigationUtils';
@@ -68,6 +68,8 @@ interface ModelsBottomBarProps {
sessionLoaded?: boolean;
}
type ModelMenuModal = 'switch-model' | 'local-model-settings';
export default function ModelsBottomBar({
sessionId,
dropdownRef,
@@ -80,7 +82,11 @@ export default function ModelsBottomBar({
}: ModelsBottomBarProps) {
// ChatInput owns the override state and passes effective model/provider as sessionModel/sessionProvider.
// Fall back to config defaults when no session-specific model is available.
const { currentModel: configModel, currentProvider: configProvider, changeModel } = useModelAndProvider();
const {
currentModel: configModel,
currentProvider: configProvider,
changeModel,
} = useModelAndProvider();
const currentModel = sessionModel ?? configModel;
const currentProvider = sessionProvider ?? configProvider;
@@ -89,6 +95,8 @@ export default function ModelsBottomBar({
const [displayModelName, setDisplayModelName] = useState<string>(
intl.formatMessage(i18n.selectModel)
);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const pendingModalRef = useRef<ModelMenuModal | null>(null);
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
@@ -162,6 +170,24 @@ export default function ModelsBottomBar({
onModelChanged({ model, provider });
};
const openModalAfterMenuCloses = (modal: ModelMenuModal) => {
pendingModalRef.current = modal;
setIsModelMenuOpen(false);
};
const handleModelMenuCloseAutoFocus = (event: Event) => {
const pendingModal = pendingModalRef.current;
if (!pendingModal) return;
pendingModalRef.current = null;
if (pendingModal === 'switch-model') {
event.preventDefault();
setIsAddModelModalOpen(true);
} else {
setIsLocalModelSettingsOpen(true);
}
};
const handleRecentModelClick = async (recent: RecentModel) => {
const previousModel = currentModel;
const previousProvider = currentProvider;
@@ -195,7 +221,7 @@ export default function ModelsBottomBar({
return (
<div className="relative flex items-center" ref={dropdownRef}>
<DropdownMenu>
<DropdownMenu open={isModelMenuOpen} onOpenChange={setIsModelMenuOpen}>
<DropdownMenuTrigger className="flex items-center hover:cursor-pointer max-w-[180px] md:max-w-[200px] lg:max-w-[380px] min-w-0 text-text-primary/70 hover:text-text-primary transition-colors">
<div className="flex items-center truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0">
<Bot className="mr-1 h-4 w-4 flex-shrink-0" />
@@ -212,7 +238,12 @@ export default function ModelsBottomBar({
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" className="w-64 text-sm">
<DropdownMenuContent
side="top"
align="center"
className="w-64 text-sm"
onCloseAutoFocus={handleModelMenuCloseAutoFocus}
>
<h6 className="text-xs text-text-primary mt-2 ml-2">
{intl.formatMessage(i18n.currentModel)}
</h6>
@@ -241,18 +272,20 @@ export default function ModelsBottomBar({
onClick={() => void handleRecentModelClick(recent)}
>
<History className="mr-2 h-3.5 w-3.5 flex-shrink-0 text-text-secondary" />
<span className="truncate">{getModelDisplayName(recent.model)} {recent.provider}</span>
<span className="truncate">
{getModelDisplayName(recent.model)} {recent.provider}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setIsAddModelModalOpen(true)}>
<DropdownMenuItem onSelect={() => openModalAfterMenuCloses('switch-model')}>
<span>{intl.formatMessage(i18n.changeModel)}</span>
<Sliders className="ml-auto h-4 w-4 rotate-90" />
</DropdownMenuItem>
{currentProvider === 'local' && currentModel && (
<DropdownMenuItem onClick={() => setIsLocalModelSettingsOpen(true)}>
<DropdownMenuItem onSelect={() => openModalAfterMenuCloses('local-model-settings')}>
<span>{intl.formatMessage(i18n.localModelSettings)}</span>
<Settings className="ml-auto h-4 w-4" />
</DropdownMenuItem>