diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 87177af6..77ae4f76 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -51,31 +51,31 @@ export function ModelQuickSelect({ return provider ? formatProviderName(provider) : providerID }, [providersData]) - const favoriteModelsWithNames = useMemo(() => { - return favoriteModels - .filter(favorite => `${favorite.providerID}/${favorite.modelID}` !== modelString) - .slice(0, 5) - .map(favorite => ({ - ...favorite, - displayName: getDisplayName(favorite.providerID, favorite.modelID), - providerName: getProviderName(favorite.providerID), - key: `${favorite.providerID}/${favorite.modelID}`, - })) + const favoriteModelsWithNames = useMemo(() => { + return favoriteModels + .filter(favorite => `${favorite.providerID}/${favorite.modelID}` !== modelString) + .slice(0, 5) + .map(favorite => ({ + ...favorite, + displayName: getDisplayName(favorite.providerID, favorite.modelID), + providerName: getProviderName(favorite.providerID), + key: `${favorite.providerID}/${favorite.modelID}`, + })) }, [favoriteModels, getDisplayName, getProviderName, modelString]) - const recentModelsWithNames = useMemo(() => { - return recentModels - .filter(recent => { - const key = `${recent.providerID}/${recent.modelID}` - return key !== modelString && !favoriteModels.some(favorite => favorite.providerID === recent.providerID && favorite.modelID === recent.modelID) - }) - .slice(0, 5) - .map(recent => ({ - ...recent, - displayName: getDisplayName(recent.providerID, recent.modelID), - providerName: getProviderName(recent.providerID), - key: `${recent.providerID}/${recent.modelID}`, - })) + const recentModelsWithNames = useMemo(() => { + return recentModels + .filter(recent => { + const key = `${recent.providerID}/${recent.modelID}` + return key !== modelString && !favoriteModels.some(favorite => favorite.providerID === recent.providerID && favorite.modelID === recent.modelID) + }) + .slice(0, 5) + .map(recent => ({ + ...recent, + displayName: getDisplayName(recent.providerID, recent.modelID), + providerName: getProviderName(recent.providerID), + key: `${recent.providerID}/${recent.modelID}`, + })) }, [recentModels, favoriteModels, getDisplayName, getProviderName, modelString]) const duplicateDisplayNames = useMemo(() => { @@ -87,6 +87,15 @@ export function ModelQuickSelect({ return new Set(Object.entries(counts).filter(([, count]) => count > 1).map(([name]) => name)) }, [favoriteModelsWithNames, recentModelsWithNames]) + const duplicateModelIds = useMemo(() => { + const counts = [...favoriteModelsWithNames, ...recentModelsWithNames].reduce>((acc, item) => { + acc[item.modelID] = (acc[item.modelID] || 0) + 1 + return acc + }, {}) + + return new Set(Object.entries(counts).filter(([, count]) => count > 1).map(([id]) => id)) + }, [favoriteModelsWithNames, recentModelsWithNames]) + const handleVariantSelect = (variant: string | undefined) => { if (variant === undefined) { clearVariant() @@ -122,7 +131,7 @@ export function ModelQuickSelect({ <> - {duplicateDisplayNames.has(currentModelDisplayName) + {duplicateModelIds.has(model.modelID) || duplicateDisplayNames.has(currentModelDisplayName) ? `${currentProviderName}/${currentModelDisplayName}` : currentModelDisplayName} @@ -170,7 +179,7 @@ export function ModelQuickSelect({ className="flex items-center justify-between" > - {duplicateDisplayNames.has(favorite.displayName) + {duplicateModelIds.has(favorite.modelID) || duplicateDisplayNames.has(favorite.displayName) ? `${favorite.providerName}/${favorite.displayName}` : favorite.displayName} @@ -189,7 +198,7 @@ export function ModelQuickSelect({ className="flex items-center justify-between" > - {duplicateDisplayNames.has(recent.displayName) + {duplicateModelIds.has(recent.modelID) || duplicateDisplayNames.has(recent.displayName) ? `${recent.providerName}/${recent.displayName}` : recent.displayName} diff --git a/frontend/src/components/session/SessionSendErrorBanner.test.tsx b/frontend/src/components/session/SessionSendErrorBanner.test.tsx new file mode 100644 index 00000000..7805fae3 --- /dev/null +++ b/frontend/src/components/session/SessionSendErrorBanner.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { SessionSendErrorBanner } from './SessionSendErrorBanner' +import { useSendErrorStore } from '@/stores/sendErrorStore' + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn() }, +})) + +describe('SessionSendErrorBanner', () => { + beforeEach(() => { + useSendErrorStore.setState({ errors: {} }) + }) + + it('renders banner when error exists for session', () => { + useSendErrorStore.getState().setError({ + sessionID: 'test-session', + title: 'Error', + message: 'Something failed', + detail: 'Stack trace here', + }) + + render() + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Something failed')).toBeInTheDocument() + expect(screen.getByText('Stack trace here')).toBeInTheDocument() + }) + + it('does not render banner when no error exists', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('does not render banner when sessionId is undefined', () => { + useSendErrorStore.getState().setError({ + sessionID: 'test-session', + title: 'Error', + message: 'Something failed', + }) + + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('clears error on dismiss', () => { + useSendErrorStore.getState().setError({ + sessionID: 'test-session', + title: 'Error', + message: 'Something failed', + }) + + render() + expect(screen.getByText('Something failed')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByText('Something failed')).not.toBeInTheDocument() + expect(useSendErrorStore.getState().getError('test-session')).toBeNull() + }) + + it('does not call showToast.error', async () => { + const { showToast } = await import('@/lib/toast') + useSendErrorStore.getState().setError({ + sessionID: 'test-session', + title: 'Error', + message: 'Something failed', + }) + + render() + expect(showToast.error).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/session/SessionSendErrorBanner.tsx b/frontend/src/components/session/SessionSendErrorBanner.tsx new file mode 100644 index 00000000..21cfb051 --- /dev/null +++ b/frontend/src/components/session/SessionSendErrorBanner.tsx @@ -0,0 +1,23 @@ +import { ErrorBanner } from '@/components/ui/error-banner' +import { useSendErrorStore } from '@/stores/sendErrorStore' + +interface SessionSendErrorBannerProps { + sessionId: string | undefined +} + +export function SessionSendErrorBanner({ sessionId }: SessionSendErrorBannerProps) { + const sendError = useSendErrorStore((s) => sessionId ? s.errors[sessionId] : null) + const clearSendError = useSendErrorStore((s) => s.clearError) + + if (!sendError || !sessionId) return null + + return ( + clearSendError(sessionId)} + className="mb-2" + /> + ) +} diff --git a/frontend/src/components/source-control/GitErrorBanner.tsx b/frontend/src/components/source-control/GitErrorBanner.tsx index 0d09d868..3e6a4a5c 100644 --- a/frontend/src/components/source-control/GitErrorBanner.tsx +++ b/frontend/src/components/source-control/GitErrorBanner.tsx @@ -1,6 +1,4 @@ -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { AlertCircle, X } from 'lucide-react' +import { ErrorBanner } from '@/components/ui/error-banner' interface GitErrorBannerProps { error: { summary: string; detail?: string } @@ -9,26 +7,11 @@ interface GitErrorBannerProps { export function GitErrorBanner({ error, onDismiss }: GitErrorBannerProps) { return ( - -
-
- - {error.summary} - -
- {error.detail && ( -
-            {error.detail}
-          
- )} -
-
+ ) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/error-banner.test.tsx b/frontend/src/components/ui/error-banner.test.tsx new file mode 100644 index 00000000..d4970cc3 --- /dev/null +++ b/frontend/src/components/ui/error-banner.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { ErrorBanner } from './error-banner' + +describe('ErrorBanner', () => { + it('renders summary text', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render() + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders detail when provided', () => { + render( + , + ) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText('Stack trace here')).toBeInTheDocument() + }) + + it('does not render dismiss button when onDismiss is not provided', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders dismiss button when onDismiss is provided', () => { + const onDismiss = vi.fn() + render( + , + ) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('calls onDismiss when dismiss button is clicked', () => { + const onDismiss = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button')) + expect(onDismiss).toHaveBeenCalled() + }) + + it('applies custom className', () => { + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) \ No newline at end of file diff --git a/frontend/src/components/ui/error-banner.tsx b/frontend/src/components/ui/error-banner.tsx new file mode 100644 index 00000000..3897ca90 --- /dev/null +++ b/frontend/src/components/ui/error-banner.tsx @@ -0,0 +1,44 @@ +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { AlertCircle, X } from 'lucide-react' + +export interface ErrorBannerProps { + title?: string + summary: string + detail?: string + onDismiss?: () => void + className?: string +} + +export function ErrorBanner({ title, summary, detail, onDismiss, className }: ErrorBannerProps) { + return ( + +
+
+ +
+ {title && ( + {title} + )} + {summary} +
+ {onDismiss && ( + + )} +
+ {detail && ( +
+            {detail}
+          
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index da45f131..91dcd44a 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -12,6 +12,7 @@ import type { paths, components } from "../api/opencode-types"; import { parseNetworkError } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSessionStatus } from "../stores/sessionStatusStore"; +import { useSendErrorStore } from "../stores/sendErrorStore"; type AssistantMessage = components["schemas"]["AssistantMessage"]; @@ -300,6 +301,22 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: if (model) { const parsedModel = parseModelString(model); if (parsedModel) { + const cachedProviders = queryClient.getQueryData<{ + providers: Array<{ id: string; models: Record }>; + }>(['opencode', 'providers', opcodeUrl, directory]); + if (cachedProviders?.providers) { + const provider = cachedProviders.providers.find( + (p) => p.id === parsedModel.providerID, + ); + if (!provider || !(parsedModel.modelID in provider.models)) { + throw new FetchError( + 'Selected model is no longer available. Pick a different model.', + 409, + 'MODEL_UNAVAILABLE', + ); + } + } + requestData.model = { providerID: parsedModel.providerID, modelID: parsedModel.modelID, @@ -342,9 +359,11 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: } const parsed = parseNetworkError(error); - showToast.error(parsed.title, { - description: parsed.message, - duration: 5000, + useSendErrorStore.getState().setError({ + sessionID, + title: parsed.title, + message: parsed.message, + detail: error instanceof FetchError ? error.detail : undefined, }); }, onSuccess: async (data, variables) => { @@ -352,6 +371,8 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: const { response } = data; const messagesQueryKey = ["opencode", "messages", opcodeUrl, sessionID, directory]; + useSendErrorStore.getState().clearError(sessionID); + if (data.queued || !response) { queryClient.invalidateQueries({ queryKey: messagesQueryKey }); return; diff --git a/frontend/src/hooks/useSendPrompt.test.ts b/frontend/src/hooks/useSendPrompt.test.ts new file mode 100644 index 00000000..4c36a341 --- /dev/null +++ b/frontend/src/hooks/useSendPrompt.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useSendPrompt } from './useOpenCode' +import { FetchError } from '../api/fetchWrapper' + +const mockSendPrompt = vi.fn() +const mockSendPromptAsync = vi.fn() + +vi.mock('../api/opencode', async () => { + const actual = await vi.importActual('../api/opencode') + return { + ...actual, + OpenCodeClient: vi.fn().mockImplementation(() => ({ + sendPrompt: mockSendPrompt, + sendPromptAsync: mockSendPromptAsync, + })), + } +}) + +vi.mock('@/stores/sessionStatusStore', () => ({ + useSessionStatus: vi.fn(() => vi.fn()), +})) + +vi.mock('../lib/toast', () => ({ + showToast: { error: vi.fn() }, +})) + +vi.mock('../lib/opencode-errors', () => ({ + parseNetworkError: vi.fn((err) => ({ + title: 'Error', + message: err.message, + isRetryable: false, + })), +})) + +const mockClearError = vi.fn() +const mockSetError = vi.fn() + +vi.mock('../stores/sendErrorStore', () => ({ + useSendErrorStore: { + getState: () => ({ + clearError: mockClearError, + setError: mockSetError, + getError: vi.fn(), + }), + }, +})) + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +describe('useSendPrompt', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + queryClient = createTestQueryClient() + mockSendPrompt.mockResolvedValue({ + info: { id: 'test-response' }, + parts: [], + }) + mockSendPromptAsync.mockResolvedValue(undefined) + }) + + const renderHookWithProviders = () => + renderHook( + () => useSendPrompt('http://localhost:5551', '/test'), + { + wrapper: ({ children }) => + createElement(QueryClientProvider, { client: queryClient }, children), + } + ) + + it('proceeds when no providers in cache', async () => { + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + ).resolves.toBeDefined() + + expect(mockSendPrompt).toHaveBeenCalled() + }) + + it('throws FetchError with MODEL_UNAVAILABLE when model not in providers', async () => { + queryClient.setQueryData( + ['opencode', 'providers', 'http://localhost:5551', '/test'], + { + providers: [ + { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-4': { id: 'gpt-4', name: 'GPT-4' }, + }, + isConnected: true, + }, + ], + connected: ['openai'], + default: {}, + } + ) + + const { result } = renderHookWithProviders() + + let error: Error | undefined + try { + await result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + } catch (e) { + error = e as Error + } + + expect(error).toBeInstanceOf(FetchError) + expect((error as FetchError).code).toBe('MODEL_UNAVAILABLE') + expect((error as FetchError).statusCode).toBe(409) + expect(error!.message).toBe('Selected model is no longer available. Pick a different model.') + expect(mockSendPrompt).not.toHaveBeenCalled() + }) + + it('proceeds when model exists in providers', async () => { + queryClient.setQueryData( + ['opencode', 'providers', 'http://localhost:5551', '/test'], + { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + }, + isConnected: true, + }, + ], + connected: ['anthropic'], + default: {}, + } + ) + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + ).resolves.toBeDefined() + + expect(mockSendPrompt).toHaveBeenCalled() + }) + + it('clears stored send error on successful queued retry', async () => { + mockClearError.mockClear() + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'session-1', + prompt: 'Hello', + queued: true, + }) + ).resolves.toEqual(expect.objectContaining({ queued: true })) + + expect(mockClearError).toHaveBeenCalledWith('session-1') + }) + + it('clears stored send error on successful non-queued response', async () => { + mockClearError.mockClear() + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'session-2', + prompt: 'Hello', + }) + ).resolves.toBeDefined() + + expect(mockClearError).toHaveBeenCalledWith('session-2') + }) +}) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 982ccef6..c1100a9f 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -47,6 +47,7 @@ import { QuestionPrompt } from "@/components/session/QuestionPrompt"; import { MinimizedQuestionIndicator } from "@/components/session/MinimizedQuestionIndicator"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; import { SourceControlPanel } from "@/components/source-control"; +import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; import { SessionTodoDisplay } from "@/components/message/SessionTodoDisplay"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useSidebarAction } from "@/hooks/useSidebarAction"; @@ -581,6 +582,7 @@ export function SessionDetail() { onMinimize={() => handleMinimizeQuestion(currentQuestion)} /> )} + ({ + showToast: { error: vi.fn() }, +})) + +describe('SessionDetail send error integration', () => { + beforeEach(() => { + vi.clearAllMocks() + useSendErrorStore.setState({ errors: {} }) + }) + + it('shows error banner with title, message, and detail when store is seeded', () => { + useSendErrorStore.getState().setError({ + sessionID: 'sess-1', + title: 'Model Unavailable', + message: 'Selected model is no longer available.', + detail: '409 Conflict', + }) + + render() + + expect(screen.getByText('Model Unavailable')).toBeInTheDocument() + expect(screen.getByText('Selected model is no longer available.')).toBeInTheDocument() + expect(screen.getByText('409 Conflict')).toBeInTheDocument() + }) + + it('dismisses banner and clears store entry on click', () => { + useSendErrorStore.getState().setError({ + sessionID: 'sess-2', + title: 'Error', + message: 'Something failed', + }) + + render() + expect(screen.getByText('Something failed')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button')) + + expect(screen.queryByText('Something failed')).not.toBeInTheDocument() + expect(useSendErrorStore.getState().getError('sess-2')).toBeNull() + }) + + it('does not trigger a toast error when banner renders', async () => { + const { showToast } = await import('@/lib/toast') + + useSendErrorStore.getState().setError({ + sessionID: 'sess-3', + title: 'Error', + message: 'No toast please', + }) + + render() + expect(screen.getByText('No toast please')).toBeInTheDocument() + expect(showToast.error).not.toHaveBeenCalled() + }) + + it('does not render banner when no error exists for session', () => { + render() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/stores/modelStore.test.ts b/frontend/src/stores/modelStore.test.ts new file mode 100644 index 00000000..db73f40f --- /dev/null +++ b/frontend/src/stores/modelStore.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useModelStore } from '@/stores/modelStore' +import type { Provider } from '@/api/providers' + +vi.mock('zustand/middleware', async () => { + const actual = await vi.importActual('zustand/middleware') + return { + ...actual, + persist: (config: any) => config, + } +}) + +function makeProvider(overrides: Partial): Provider { + return { + id: overrides.id ?? 'test-provider', + name: overrides.name ?? 'Test Provider', + models: overrides.models ?? {}, + env: [], + isConnected: true, + options: {}, + ...overrides, + } +} + +describe('validateAndSyncModel', () => { + beforeEach(() => { + useModelStore.setState({ + model: null, + agentModels: {}, + recentModels: [], + favoriteModels: [], + variants: {}, + lastConfigModel: undefined, + }) + }) + + it('falls back to syncFromConfig when providers is undefined', () => { + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', undefined) + + expect(useModelStore.getState().model).toEqual({ providerID: 'anthropic', modelID: 'claude-sonnet-4' }) + }) + + it('sets active model from configModel when current model not in providers but configModel parses to valid model', () => { + const providers = [ + makeProvider({ + id: 'openai', + models: { 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + }) + + useModelStore.getState().validateAndSyncModel('openai/gpt-4o', providers) + + expect(useModelStore.getState().model).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + }) + + it('clears active model when invalid and no config fallback', () => { + const providers = [ + makeProvider({ + id: 'openai', + models: { 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + }) + + useModelStore.getState().validateAndSyncModel(undefined, providers) + + expect(useModelStore.getState().model).toBeNull() + }) + + it('prunes stale recents and favorites, keeps valid entries', () => { + const providers = [ + makeProvider({ + id: 'anthropic', + models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + recentModels: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'openai', modelID: 'gpt-4o' }, + ], + favoriteModels: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'openai', modelID: 'gpt-4o' }, + ], + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + + expect(useModelStore.getState().recentModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + expect(useModelStore.getState().favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + }) + + it('is idempotent when re-running with same valid state', () => { + const providers = [ + makeProvider({ + id: 'anthropic', + models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + recentModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + + const afterFirst = useModelStore.getState() + + let updateCount = 0 + const unsubscribe = useModelStore.subscribe(() => { + updateCount++ + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + unsubscribe() + + const afterSecond = useModelStore.getState() + + expect(updateCount).toBe(0) + expect(afterSecond.model).toEqual(afterFirst.model) + expect(afterSecond.recentModels).toEqual(afterFirst.recentModels) + expect(afterSecond.favoriteModels).toEqual(afterFirst.favoriteModels) + }) +}) diff --git a/frontend/src/stores/modelStore.ts b/frontend/src/stores/modelStore.ts index 69108726..66c83bcf 100644 --- a/frontend/src/stores/modelStore.ts +++ b/frontend/src/stores/modelStore.ts @@ -129,15 +129,15 @@ export const useModelStore = create()( }, validateAndSyncModel: (configModel: string | undefined, providers?: Provider[]) => { - if (!configModel) return - - const state = get() - if (!providers) { - get().syncFromConfig(configModel) + if (configModel) { + get().syncFromConfig(configModel) + } return } + const state = get() + const modelExists = (model: ModelSelection) => providers.some( (p) => p.id === model.providerID && p.models && model.modelID in p.models @@ -157,7 +157,16 @@ export const useModelStore = create()( } if (!currentModelExists) { - get().syncFromConfig(configModel, true) + const parsedConfig = configModel ? parseModelString(configModel) : null + const configIsValid = parsedConfig + ? providers.some(p => p.id === parsedConfig.providerID && p.models && parsedConfig.modelID in p.models) + : false + + if (configIsValid && configModel) { + get().syncFromConfig(configModel, true) + } else { + set({ model: null, lastConfigModel: configModel }) + } } }, diff --git a/frontend/src/stores/sendErrorStore.test.ts b/frontend/src/stores/sendErrorStore.test.ts new file mode 100644 index 00000000..c5e9dad7 --- /dev/null +++ b/frontend/src/stores/sendErrorStore.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSendErrorStore } from './sendErrorStore' + +describe('useSendErrorStore', () => { + beforeEach(() => { + useSendErrorStore.setState({ errors: {} }) + }) + + it('stores error keyed by sessionID', () => { + const err = { sessionID: 'session-1', title: 'Error', message: 'Something failed' } + useSendErrorStore.getState().setError(err) + expect(useSendErrorStore.getState().getError('session-1')).toEqual(err) + }) + + it('clears error only for the specified sessionID', () => { + useSendErrorStore.getState().setError({ sessionID: 'session-1', title: 'Error', message: 'msg1' }) + useSendErrorStore.getState().setError({ sessionID: 'session-2', title: 'Error', message: 'msg2' }) + useSendErrorStore.getState().clearError('session-1') + expect(useSendErrorStore.getState().getError('session-1')).toBeNull() + expect(useSendErrorStore.getState().getError('session-2')).not.toBeNull() + }) + + it('returns null when no error exists for sessionID', () => { + expect(useSendErrorStore.getState().getError('nonexistent')).toBeNull() + }) +}) diff --git a/frontend/src/stores/sendErrorStore.ts b/frontend/src/stores/sendErrorStore.ts new file mode 100644 index 00000000..0863e33a --- /dev/null +++ b/frontend/src/stores/sendErrorStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' + +export interface SendError { + sessionID: string + title: string + message: string + detail?: string +} + +interface SendErrorStore { + errors: Record + setError: (err: SendError) => void + clearError: (sessionID: string) => void + getError: (sessionID: string) => SendError | null +} + +export const useSendErrorStore = create((set, get) => ({ + errors: {}, + setError: (err: SendError) => { + set((state) => ({ + errors: { ...state.errors, [err.sessionID]: err }, + })) + }, + clearError: (sessionID: string) => { + set((state) => { + const newErrors = { ...state.errors } + delete newErrors[sessionID] + return { errors: newErrors } + }) + }, + getError: (sessionID: string) => { + return get().errors[sessionID] || null + }, +}))