mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The model selector dropdown was rendered inside the modal scroll container. When the task form sat near the top of the dialog, the upward-opening menu could be clipped by the container and become impossible to scroll into view. The selector now portals the menu to the document body and positions it against the trigger with viewport-aware fixed coordinates. Constraint: The same selector is used by chat/session controls and scheduled-task controls, so the fix must preserve existing runtime selection behavior. Rejected: Increase modal scroll padding | still leaves the dropdown coupled to a clipping parent and breaks in other overflow containers. Confidence: high Scope-risk: narrow Directive: Keep floating selector content outside scroll-clipping parents; update viewport positioning if the modal layout changes. Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx src/components/tasks/NewTaskModal.test.tsx Tested: bun run check:desktop Tested: git diff --check Not-tested: Manual packaged desktop app check.
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { ModelSelector } from './ModelSelector'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useProviderStore } from '../../stores/providerStore'
|
|
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import type { ModelInfo } from '../../types/settings'
|
|
|
|
const MODELS: ModelInfo[] = [
|
|
{ id: 'alpha', name: 'Alpha', description: 'Fast model', context: '128k' },
|
|
{ id: 'beta', name: 'Beta', description: 'Careful model', context: '200k' },
|
|
]
|
|
|
|
async function clickByRole(name: RegExp | string) {
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name }))
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
|
|
useProviderStore.setState(useProviderStore.getInitialState(), true)
|
|
useSessionRuntimeStore.setState(useSessionRuntimeStore.getInitialState(), true)
|
|
useChatStore.setState(useChatStore.getInitialState(), true)
|
|
})
|
|
|
|
describe('ModelSelector', () => {
|
|
it('uses controlled model selection without mutating settings directly', async () => {
|
|
const onChange = vi.fn()
|
|
useSettingsStore.setState({
|
|
locale: 'en',
|
|
availableModels: MODELS,
|
|
currentModel: MODELS[0],
|
|
})
|
|
|
|
render(<ModelSelector value="alpha" onChange={onChange} />)
|
|
|
|
await clickByRole(/alpha/i)
|
|
await clickByRole(/Beta/)
|
|
|
|
expect(onChange).toHaveBeenCalledWith('beta')
|
|
})
|
|
|
|
it('routes uncontrolled model and effort changes through settings actions', async () => {
|
|
const setModel = vi.fn(async () => {})
|
|
const setEffort = vi.fn(async () => {})
|
|
useSettingsStore.setState({
|
|
locale: 'en',
|
|
availableModels: MODELS,
|
|
currentModel: MODELS[0],
|
|
effortLevel: 'medium',
|
|
setModel,
|
|
setEffort,
|
|
})
|
|
|
|
render(<ModelSelector />)
|
|
|
|
await clickByRole(/alpha/i)
|
|
await clickByRole(/Beta/)
|
|
expect(setModel).toHaveBeenCalledWith('beta')
|
|
|
|
await clickByRole(/Alpha/)
|
|
await clickByRole(/^High$/)
|
|
expect(setEffort).toHaveBeenCalledWith('high')
|
|
})
|
|
|
|
it('selects provider-scoped runtime models and mirrors session selections', async () => {
|
|
const setSessionRuntime = vi.fn()
|
|
useSettingsStore.setState({
|
|
locale: 'en',
|
|
availableModels: MODELS,
|
|
currentModel: MODELS[0],
|
|
activeProviderName: 'Provider A',
|
|
})
|
|
useProviderStore.setState({
|
|
providers: [{
|
|
id: 'provider-a',
|
|
presetId: 'custom',
|
|
name: 'Provider A',
|
|
apiKey: '***',
|
|
baseUrl: 'https://api.example.com',
|
|
apiFormat: 'anthropic',
|
|
models: {
|
|
main: 'provider-main',
|
|
haiku: 'provider-fast',
|
|
sonnet: 'provider-main',
|
|
opus: '',
|
|
},
|
|
}],
|
|
activeId: 'provider-a',
|
|
hasLoadedProviders: true,
|
|
isLoading: true,
|
|
})
|
|
useChatStore.setState({
|
|
setSessionRuntime,
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
render(<ModelSelector runtimeKey="session-1" />)
|
|
|
|
await clickByRole(/alpha/i)
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: /provider-fast/ }))
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
|
|
providerId: 'provider-a',
|
|
modelId: 'provider-fast',
|
|
})
|
|
expect(setSessionRuntime).toHaveBeenCalledWith('session-1', {
|
|
providerId: 'provider-a',
|
|
modelId: 'provider-fast',
|
|
})
|
|
})
|
|
|
|
it('portals the dropdown outside clipping containers and positions it below the trigger', async () => {
|
|
useSettingsStore.setState({
|
|
locale: 'en',
|
|
availableModels: MODELS,
|
|
currentModel: MODELS[0],
|
|
})
|
|
|
|
const { container } = render(
|
|
<div data-testid="scroll-container" className="overflow-hidden">
|
|
<ModelSelector value="alpha" onChange={vi.fn()} />
|
|
</div>,
|
|
)
|
|
|
|
const trigger = screen.getByRole('button', { name: /alpha/i })
|
|
Object.defineProperty(trigger.parentElement, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () => ({
|
|
top: 120,
|
|
right: 520,
|
|
bottom: 150,
|
|
left: 240,
|
|
width: 280,
|
|
height: 30,
|
|
x: 240,
|
|
y: 120,
|
|
toJSON: () => {},
|
|
}),
|
|
})
|
|
|
|
await act(async () => {
|
|
fireEvent.click(trigger)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
const dropdown = screen.getByTestId('model-selector-dropdown')
|
|
expect(container.contains(dropdown)).toBe(false)
|
|
expect(document.body.contains(dropdown)).toBe(true)
|
|
expect(dropdown.className).toContain('fixed')
|
|
expect(dropdown.style.top).toBe('158px')
|
|
expect(dropdown.style.left).toBe('160px')
|
|
expect(dropdown.style.width).toBe('360px')
|
|
})
|
|
})
|