Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 35 additions & 26 deletions frontend/src/components/model/ModelQuickSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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<Record<string, number>>((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()
Expand Down Expand Up @@ -122,7 +131,7 @@ export function ModelQuickSelect({
<>
<DropdownMenuItem className="flex items-center justify-between font-medium">
<span className="truncate text-orange-500">
{duplicateDisplayNames.has(currentModelDisplayName)
{duplicateModelIds.has(model.modelID) || duplicateDisplayNames.has(currentModelDisplayName)
? `${currentProviderName}/${currentModelDisplayName}`
: currentModelDisplayName}
</span>
Expand Down Expand Up @@ -170,7 +179,7 @@ export function ModelQuickSelect({
className="flex items-center justify-between"
>
<span className="truncate">
{duplicateDisplayNames.has(favorite.displayName)
{duplicateModelIds.has(favorite.modelID) || duplicateDisplayNames.has(favorite.displayName)
? `${favorite.providerName}/${favorite.displayName}`
: favorite.displayName}
</span>
Expand All @@ -189,7 +198,7 @@ export function ModelQuickSelect({
className="flex items-center justify-between"
>
<span className="truncate">
{duplicateDisplayNames.has(recent.displayName)
{duplicateModelIds.has(recent.modelID) || duplicateDisplayNames.has(recent.displayName)
? `${recent.providerName}/${recent.displayName}`
: recent.displayName}
</span>
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/components/session/SessionSendErrorBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SessionSendErrorBanner sessionId="test-session" />)
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(<SessionSendErrorBanner sessionId="test-session" />)
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(<SessionSendErrorBanner sessionId={undefined} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

it('clears error on dismiss', () => {
useSendErrorStore.getState().setError({
sessionID: 'test-session',
title: 'Error',
message: 'Something failed',
})

render(<SessionSendErrorBanner sessionId="test-session" />)
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(<SessionSendErrorBanner sessionId="test-session" />)
expect(showToast.error).not.toHaveBeenCalled()
})
})
23 changes: 23 additions & 0 deletions frontend/src/components/session/SessionSendErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ErrorBanner
title={sendError.title}
summary={sendError.message}
detail={sendError.detail}
onDismiss={() => clearSendError(sessionId)}
className="mb-2"
/>
)
}
33 changes: 8 additions & 25 deletions frontend/src/components/source-control/GitErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -9,26 +7,11 @@ interface GitErrorBannerProps {

export function GitErrorBanner({ error, onDismiss }: GitErrorBannerProps) {
return (
<Alert variant="destructive" className="mb-0 p-3 sm:p-4 [&>svg]:hidden [&>svg~*]:pl-0">
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<AlertDescription className="flex-1 min-w-0 text-sm">{error.summary}</AlertDescription>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={onDismiss}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
{error.detail && (
<pre className="p-2 rounded border bg-destructive/5 border-destructive/20 text-xs font-mono overflow-auto max-h-32">
{error.detail}
</pre>
)}
</div>
</Alert>
<ErrorBanner
summary={error.summary}
detail={error.detail}
onDismiss={onDismiss}
className="mb-0 p-3 sm:p-4 [&>svg]:hidden [&>svg~*]:pl-0"
/>
)
}
}
56 changes: 56 additions & 0 deletions frontend/src/components/ui/error-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ErrorBanner summary="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})

it('renders title when provided', () => {
render(<ErrorBanner title="Error" summary="Something went wrong" />)
expect(screen.getByText('Error')).toBeInTheDocument()
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})

it('renders detail when provided', () => {
render(
<ErrorBanner
summary="Something went wrong"
detail="Stack trace here"
/>,
)
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(<ErrorBanner summary="Something went wrong" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

it('renders dismiss button when onDismiss is provided', () => {
const onDismiss = vi.fn()
render(
<ErrorBanner summary="Something went wrong" onDismiss={onDismiss} />,
)
expect(screen.getByRole('button')).toBeInTheDocument()
})

it('calls onDismiss when dismiss button is clicked', () => {
const onDismiss = vi.fn()
render(
<ErrorBanner summary="Something went wrong" onDismiss={onDismiss} />,
)
fireEvent.click(screen.getByRole('button'))
expect(onDismiss).toHaveBeenCalled()
})

it('applies custom className', () => {
const { container } = render(
<ErrorBanner summary="Something went wrong" className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
44 changes: 44 additions & 0 deletions frontend/src/components/ui/error-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert variant="destructive" className={className}>
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
{title && (
<AlertTitle className="mb-1 font-medium leading-none tracking-tight">{title}</AlertTitle>
)}
<AlertDescription className="text-sm">{summary}</AlertDescription>
</div>
{onDismiss && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={onDismiss}
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
{detail && (
<pre className="p-2 rounded border bg-destructive/5 border-destructive/20 text-xs font-mono overflow-auto max-h-32">
{detail}
</pre>
)}
</div>
</Alert>
)
}
27 changes: 24 additions & 3 deletions frontend/src/hooks/useOpenCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -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<string, unknown> }>;
}>(['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,
Expand Down Expand Up @@ -342,16 +359,20 @@ 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) => {
const { sessionID } = variables;
const { response } = data;
const messagesQueryKey = ["opencode", "messages", opcodeUrl, sessionID, directory];

useSendErrorStore.getState().clearError(sessionID);

if (data.queued || !response) {
queryClient.invalidateQueries({ queryKey: messagesQueryKey });
return;
Expand Down
Loading