Skip to content

Commit 740bc45

Browse files
committed
feat(web): 优化模型选择器全局切换并完善面板交互与样式
ModelSelector: 将单会话模型切换改为全局 selectProviderModel 路径, 生成中缓存待切换请求,生成结束后一次性生效。 Sidebar: 新增单元测试覆盖。 FileChangePanel / FileTreePanel / GitDiffPreviewEditor: 强化交互联动 与视觉一致性。 index.css: 补充布局与过渡样式。 useWorkspaceStore: 补全状态联动字段。
1 parent 32b86a0 commit 740bc45

12 files changed

Lines changed: 481 additions & 41 deletions

web/src/components/chat/ModelSelector.test.tsx

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('ModelSelector', () => {
5252
expect(mockGatewayAPI.selectProviderModel).not.toHaveBeenCalled()
5353
})
5454

55-
it('defers a session model change until generation completes and applies it once', async () => {
55+
it('defers a session model change until generation completes and applies it once through the global switch path', async () => {
5656
mockGatewayAPI = {
5757
listModels: vi.fn().mockResolvedValue({
5858
payload: {
@@ -64,8 +64,8 @@ describe('ModelSelector', () => {
6464
selected_model_id: 'gpt-4.1',
6565
},
6666
}),
67-
setSessionModel: vi.fn().mockResolvedValue(undefined),
68-
selectProviderModel: vi.fn(),
67+
setSessionModel: vi.fn(),
68+
selectProviderModel: vi.fn().mockResolvedValue(undefined),
6969
}
7070
useSessionStore.setState({ currentSessionId: 'session-1' } as any)
7171
useChatStore.setState({ isGenerating: true } as any)
@@ -77,15 +77,16 @@ describe('ModelSelector', () => {
7777
fireEvent.click(screen.getByText('GPT-4o'))
7878

7979
expect(mockGatewayAPI.setSessionModel).not.toHaveBeenCalled()
80+
expect(mockGatewayAPI.selectProviderModel).not.toHaveBeenCalled()
8081

8182
useChatStore.setState({ isGenerating: false } as any)
8283
view.rerender(<ModelSelector />)
8384

8485
await waitFor(() => {
85-
expect(mockGatewayAPI.setSessionModel).toHaveBeenCalledTimes(1)
86+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledTimes(1)
8687
})
87-
expect(mockGatewayAPI.setSessionModel).toHaveBeenCalledWith('session-1', 'gpt-4o', 'openai')
88-
expect(mockGatewayAPI.selectProviderModel).not.toHaveBeenCalled()
88+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'openai', model_id: 'gpt-4o' })
89+
expect(useGatewayStore.getState().providerChangeTick).toBe(1)
8990
})
9091

9192
it('updates the global default selection when there is no current session', async () => {
@@ -131,8 +132,8 @@ describe('ModelSelector', () => {
131132
selected_model_id: 'gpt-4.1',
132133
},
133134
}),
134-
setSessionModel: vi.fn().mockRejectedValue(new Error('boom')),
135-
selectProviderModel: vi.fn(),
135+
setSessionModel: vi.fn(),
136+
selectProviderModel: vi.fn().mockRejectedValue(new Error('boom')),
136137
}
137138
useSessionStore.setState({ currentSessionId: 'session-1' } as any)
138139
useUIStore.setState({ showToast } as any)
@@ -144,11 +145,12 @@ describe('ModelSelector', () => {
144145
fireEvent.click(screen.getByText('GPT-4o'))
145146

146147
await waitFor(() => {
147-
expect(mockGatewayAPI.setSessionModel).toHaveBeenCalledWith('session-1', 'gpt-4o', 'openai')
148+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'openai', model_id: 'gpt-4o' })
148149
})
149150
await waitFor(() => {
150151
expect(screen.getByRole('button', { name: /openai \/ GPT-4\.1/i })).toBeTruthy()
151152
})
153+
expect(mockGatewayAPI.setSessionModel).not.toHaveBeenCalled()
152154
expect(showToast).toHaveBeenCalledWith('Failed to apply model change', 'error')
153155
})
154156

@@ -186,8 +188,8 @@ describe('ModelSelector', () => {
186188
selected_model_id: 'gpt-4.1',
187189
},
188190
}),
189-
setSessionModel: vi.fn().mockRejectedValue(new Error('boom')),
190-
selectProviderModel: vi.fn(),
191+
setSessionModel: vi.fn(),
192+
selectProviderModel: vi.fn().mockRejectedValue(new Error('boom')),
191193
}
192194
useSessionStore.setState({ currentSessionId: 'session-1' } as any)
193195
useChatStore.setState({ isGenerating: true } as any)
@@ -207,7 +209,7 @@ describe('ModelSelector', () => {
207209
})
208210

209211
await waitFor(() => {
210-
expect(mockGatewayAPI.setSessionModel).toHaveBeenCalledWith('session-1', 'gpt-4o', 'openai')
212+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'openai', model_id: 'gpt-4o' })
211213
})
212214
await waitFor(() => {
213215
expect(mockGatewayAPI.listModels.mock.calls.length).toBeGreaterThanOrEqual(2)
@@ -218,4 +220,38 @@ describe('ModelSelector', () => {
218220
expect(showToast).toHaveBeenCalledWith('Model change will apply on the next turn', 'info')
219221
expect(showToast).toHaveBeenCalledWith('Failed to apply model change', 'error')
220222
})
223+
224+
it('refreshes the displayed selection when the active session changes, such as after switching workspace', async () => {
225+
mockGatewayAPI = {
226+
listModels: vi.fn()
227+
.mockResolvedValueOnce({
228+
payload: {
229+
models: [{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai' }],
230+
selected_provider_id: 'openai',
231+
selected_model_id: 'gpt-4.1',
232+
},
233+
})
234+
.mockResolvedValueOnce({
235+
payload: {
236+
models: [{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'gemini' }],
237+
selected_provider_id: 'gemini',
238+
selected_model_id: 'gemini-2.5-pro',
239+
},
240+
}),
241+
setSessionModel: vi.fn(),
242+
selectProviderModel: vi.fn(),
243+
}
244+
useSessionStore.setState({ currentSessionId: 'session-a' } as any)
245+
246+
const view = render(<ModelSelector />)
247+
248+
await screen.findByText('openai / GPT-4.1')
249+
250+
useSessionStore.setState({ currentSessionId: 'session-b' } as any)
251+
view.rerender(<ModelSelector />)
252+
253+
await screen.findByText('gemini / Gemini 2.5 Pro')
254+
expect(mockGatewayAPI.listModels).toHaveBeenNthCalledWith(1, 'session-a')
255+
expect(mockGatewayAPI.listModels).toHaveBeenNthCalledWith(2, 'session-b')
256+
})
221257
})

web/src/components/chat/ModelSelector.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,6 @@ export default function ModelSelector() {
5353

5454
async function applyModelSelection(model: ModelEntry) {
5555
if (!gatewayAPI) return
56-
if (currentSessionId) {
57-
await gatewayAPI.setSessionModel(currentSessionId, model.id, model.provider)
58-
return
59-
}
6056
await gatewayAPI.selectProviderModel({ provider_id: model.provider, model_id: model.id })
6157
useGatewayStore.getState().notifyProviderChanged()
6258
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
3+
import Sidebar from './Sidebar'
4+
import { useChatStore } from '@/stores/useChatStore'
5+
import { useGatewayStore } from '@/stores/useGatewayStore'
6+
import { useSessionStore } from '@/stores/useSessionStore'
7+
import { useUIStore } from '@/stores/useUIStore'
8+
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
9+
10+
let mockGatewayAPI: any = null
11+
12+
vi.mock('@/context/RuntimeProvider', () => ({
13+
useGatewayAPI: () => mockGatewayAPI,
14+
useRuntime: () => ({
15+
mode: 'browser',
16+
selectWorkdir: vi.fn(),
17+
}),
18+
}))
19+
20+
describe('Sidebar ProviderModal', () => {
21+
beforeEach(() => {
22+
cleanup()
23+
mockGatewayAPI = {
24+
listProviders: vi.fn().mockResolvedValue({
25+
payload: {
26+
providers: [
27+
{
28+
id: 'gemini',
29+
name: 'Gemini',
30+
source: 'builtin',
31+
selected: false,
32+
models: [{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }],
33+
},
34+
{
35+
id: 'openai',
36+
name: 'OpenAI',
37+
source: 'builtin',
38+
selected: true,
39+
models: [{ id: 'gpt-5.4', name: 'GPT-5.4' }],
40+
},
41+
],
42+
},
43+
}),
44+
selectProviderModel: vi.fn().mockResolvedValue({
45+
payload: {
46+
provider_id: 'gemini',
47+
model_id: 'gemini-2.5-pro',
48+
},
49+
}),
50+
getSessionModel: vi.fn().mockResolvedValue({
51+
payload: {
52+
provider_id: 'openai',
53+
model_id: 'gpt-5.4',
54+
model_name: 'GPT-5.4',
55+
provider: 'openai',
56+
},
57+
}),
58+
setSessionModel: vi.fn().mockResolvedValue(undefined),
59+
}
60+
61+
useGatewayStore.getState().reset()
62+
useUIStore.setState({
63+
sidebarOpen: true,
64+
searchQuery: '',
65+
theme: 'dark',
66+
toggleSidebar: vi.fn(),
67+
setSearchQuery: vi.fn(),
68+
setTheme: vi.fn(),
69+
showToast: vi.fn(),
70+
} as any)
71+
useChatStore.setState({
72+
isGenerating: false,
73+
} as any)
74+
useSessionStore.setState({
75+
projects: [{
76+
id: 'group_today',
77+
name: 'Today',
78+
sessions: [
79+
{ id: 'session-1', title: 'Session 1', time: '2026-05-08T12:00:00Z' },
80+
{ id: 'session-2', title: 'Session 2', time: '2026-05-08T12:01:00Z' },
81+
],
82+
}],
83+
currentSessionId: 'session-1',
84+
currentProjectId: '',
85+
loading: false,
86+
_switchAbort: null,
87+
_initialBindDone: false,
88+
switchSession: vi.fn(),
89+
setCurrentProjectId: vi.fn(),
90+
} as any)
91+
useWorkspaceStore.setState({
92+
workspaces: [],
93+
currentWorkspaceHash: '',
94+
switchWorkspace: vi.fn(),
95+
renameWorkspace: vi.fn(),
96+
deleteWorkspace: vi.fn(),
97+
createWorkspace: vi.fn(),
98+
} as any)
99+
})
100+
101+
async function openProviderModal() {
102+
render(<Sidebar />)
103+
fireEvent.click(screen.getByRole('button', { name: //i }))
104+
await screen.findByText('Gemini')
105+
}
106+
107+
function providerCard(name: string): HTMLElement {
108+
const card = screen.getByText(name).closest('.config-card')
109+
if (!(card instanceof HTMLElement)) {
110+
throw new Error(`${name} card not found`)
111+
}
112+
return card
113+
}
114+
115+
it('switches the global provider through a single backend call', async () => {
116+
await openProviderModal()
117+
118+
const geminiCard = providerCard('Gemini')
119+
fireEvent.click(within(geminiCard).getByRole('button', { name: //i }))
120+
121+
await waitFor(() => {
122+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'gemini' })
123+
})
124+
expect(mockGatewayAPI.setSessionModel).not.toHaveBeenCalled()
125+
expect(useGatewayStore.getState().providerChangeTick).toBe(1)
126+
})
127+
128+
it('marks the provider selected according to the active session model, not the global snapshot alone', async () => {
129+
mockGatewayAPI.listProviders = vi.fn().mockResolvedValue({
130+
payload: {
131+
providers: [
132+
{
133+
id: 'deepseek',
134+
name: 'deepseek',
135+
source: 'builtin',
136+
selected: true,
137+
models: [{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }],
138+
},
139+
{
140+
id: 'mimo',
141+
name: 'mimo',
142+
source: 'builtin',
143+
selected: false,
144+
models: [{ id: 'mimo-v2.5-pro', name: 'MiMo V2.5 Pro' }],
145+
},
146+
],
147+
},
148+
})
149+
mockGatewayAPI.getSessionModel = vi.fn().mockResolvedValue({
150+
payload: {
151+
provider_id: 'mimo',
152+
model_id: 'mimo-v2.5-pro',
153+
model_name: 'MiMo V2.5 Pro',
154+
provider: 'mimo',
155+
},
156+
})
157+
158+
render(<Sidebar />)
159+
fireEvent.click(screen.getByRole('button', { name: //i }))
160+
await screen.findByText('deepseek')
161+
162+
expect(within(providerCard('mimo')).getByRole('button', { name: /使/i })).toBeTruthy()
163+
expect(within(providerCard('deepseek')).getByRole('button', { name: //i })).toBeTruthy()
164+
})
165+
166+
it('still works when there are no loaded sessions', async () => {
167+
useSessionStore.setState({ currentSessionId: '', projects: [] } as any)
168+
169+
await openProviderModal()
170+
171+
const geminiCard = providerCard('Gemini')
172+
fireEvent.click(within(geminiCard).getByRole('button', { name: //i }))
173+
174+
await waitFor(() => {
175+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'gemini' })
176+
})
177+
expect(mockGatewayAPI.getSessionModel).not.toHaveBeenCalled()
178+
expect(mockGatewayAPI.setSessionModel).not.toHaveBeenCalled()
179+
expect(useGatewayStore.getState().providerChangeTick).toBe(1)
180+
})
181+
182+
it('shows the backend error without synthesizing partial sync messages', async () => {
183+
const showToast = vi.fn()
184+
mockGatewayAPI.selectProviderModel = vi.fn().mockRejectedValue(new Error('switch failed'))
185+
useUIStore.setState({ showToast } as any)
186+
187+
await openProviderModal()
188+
189+
const geminiCard = providerCard('Gemini')
190+
fireEvent.click(within(geminiCard).getByRole('button', { name: //i }))
191+
192+
await waitFor(() => {
193+
expect(mockGatewayAPI.selectProviderModel).toHaveBeenCalledWith({ provider_id: 'gemini' })
194+
})
195+
expect(mockGatewayAPI.setSessionModel).not.toHaveBeenCalled()
196+
expect(mockGatewayAPI.listProviders).toHaveBeenCalledTimes(1)
197+
expect(useGatewayStore.getState().providerChangeTick).toBe(0)
198+
expect(showToast).not.toHaveBeenCalled()
199+
expect(screen.getByText('switch failed')).toBeInTheDocument()
200+
})
201+
})

web/src/components/layout/Sidebar.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,8 @@ function emptyProviderForm(): CreateProviderParams & { modelsJSON?: string } {
815815
function ProviderModal({ onClose }: { onClose: () => void }) {
816816
const gatewayAPI = useGatewayAPI()
817817
const isGenerating = useChatStore((s) => s.isGenerating)
818+
const currentSessionId = useSessionStore((s) => s.currentSessionId)
819+
const providerChangeTick = useGatewayStore((s) => s.providerChangeTick)
818820
const [providers, setProviders] = useState<ProviderOption[]>([])
819821
const [loading, setLoading] = useState(false)
820822
const [error, setError] = useState('')
@@ -826,17 +828,35 @@ function ProviderModal({ onClose }: { onClose: () => void }) {
826828
const load = useCallback(async () => {
827829
if (!gatewayAPI) return
828830
setLoading(true); setError('')
829-
try { const result = await gatewayAPI.listProviders(); setProviders(result?.payload?.providers ?? []) }
831+
try {
832+
const result = await gatewayAPI.listProviders()
833+
const listedProviders = result?.payload?.providers ?? []
834+
if (!currentSessionId) {
835+
setProviders(listedProviders)
836+
return
837+
}
838+
const sessionModel = await gatewayAPI.getSessionModel(currentSessionId)
839+
const effectiveProviderID = sessionModel?.payload?.provider || ''
840+
setProviders(listedProviders.map((provider) => ({
841+
...provider,
842+
selected: provider.id === effectiveProviderID,
843+
})))
844+
}
830845
catch (err) { setError(err instanceof Error ? err.message : 'Failed to load providers'); console.error('listProviders failed:', err) }
831846
finally { setLoading(false) }
832-
}, [gatewayAPI])
847+
}, [currentSessionId, gatewayAPI])
833848

834-
useEffect(() => { load() }, [load])
849+
useEffect(() => { load() }, [load, providerChangeTick])
835850

836851
async function handleSelect(providerId: string) {
837852
if (!gatewayAPI) return
838853
if (isGenerating) { useUIStore.getState().showToast('Cannot switch provider while generating', 'info'); return }
839-
try { await gatewayAPI.selectProviderModel({ provider_id: providerId }); useGatewayStore.getState().notifyProviderChanged(); await load() }
854+
setError('')
855+
try {
856+
await gatewayAPI.selectProviderModel({ provider_id: providerId })
857+
useGatewayStore.getState().notifyProviderChanged()
858+
await load()
859+
}
840860
catch (err) { console.error('selectProviderModel failed:', err); setError(err instanceof Error ? err.message : 'Failed to switch provider') }
841861
}
842862

0 commit comments

Comments
 (0)