Skip to content
Merged
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
43 changes: 36 additions & 7 deletions apps/frontend/src/app/pages/AIWorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useNavigate } from 'react-router-dom';
import { Bot, Send, AlertCircle } from 'lucide-react';
import { Bot, Send, AlertCircle, Settings } from 'lucide-react';
import { PageHeader } from '@/shared/components/ui/PageHeader';
import { PreviewBanner } from '@/shared/components/ui/PreviewBanner';
import { EmptyState } from '@/shared/components/ui/EmptyState';
import { DataSourceBadge } from '@/shared/components/ui/DataSourceBadge';
import { useAIWorkspace } from '@/features/ai/hooks/useAIWorkspace';
import { getErrorDetail } from '@/shared/services/api';
import { cn } from '@/shared/utils/cn';

// Kept identical to the `limitation` recorded for this surface in the
Expand Down Expand Up @@ -85,12 +86,24 @@ export function AIWorkspacePage() {
))
)}
{aiWorkspace.loading && <p className="text-xs text-muted-foreground">AI provider is thinking...</p>}
{aiWorkspace.error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive" />
<p className="text-sm text-destructive">{aiWorkspace.error}</p>
</div>
)}
{aiWorkspace.error && (() => {
const detail = getErrorDetail(aiWorkspace.error);
return (
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/5 px-4 py-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="space-y-1">
<p className="text-sm text-destructive">{detail.message}</p>
{detail.details.length > 0 && (
<ul className="list-disc pl-4 text-xs text-destructive/80">
{detail.details.map((d, i) => (
<li key={i}>{d}</li>
))}
</ul>
)}
</div>
</div>
);
})()}
{aiWorkspace.suggestions.length > 0 && (
<div className="flex flex-wrap gap-2">
{aiWorkspace.suggestions.map((suggestion) => (
Expand All @@ -102,6 +115,22 @@ export function AIWorkspacePage() {
)}
</div>
<div className="border-t border-border p-4">
{aiWorkspace.providerConfigured === false && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/5 px-4 py-2.5 text-sm text-amber-200">
<Settings className="h-4 w-4 shrink-0" />
<span>
No AI provider is configured. Free-form questions cannot run until you save a provider in{' '}
<button
type="button"
onClick={() => navigate('/settings')}
className="underline underline-offset-2 hover:text-amber-100"
>
Settings
</button>
.
</span>
</div>
)}
<form
className="flex items-center gap-2"
onSubmit={(event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('@/shared/feature-state/useRepositoryFeatureStatus', () => ({
vi.mock('@/shared/services/api', () => ({
aiService: {
query: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ provider: 'anthropic', hasKey: true }),
},
getErrorMessage: vi.fn((error: unknown) => String(error)),
}));
Expand Down
15 changes: 14 additions & 1 deletion apps/frontend/src/features/ai/hooks/useAIWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { aiService, getErrorMessage } from '@/shared/services/api';
import type { AiMessage } from '@/shared/services/api/types';
import { useRepositoryFeatureStatus } from '@/shared/feature-state/useRepositoryFeatureStatus';
Expand All @@ -10,6 +10,18 @@ export function useAIWorkspace() {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [providerConfigured, setProviderConfigured] = useState<boolean | null>(null);

useEffect(() => {
let cancelled = false;
aiService
.getConfig()
.then(() => !cancelled && setProviderConfigured(true))
.catch(() => !cancelled && setProviderConfigured(false));
return () => {
cancelled = true;
};
}, []);

const ask = useCallback(async () => {
const activeRepository = repositoryFeature.activeRepository;
Expand Down Expand Up @@ -50,6 +62,7 @@ export function useAIWorkspace() {
suggestions,
loading: repositoryFeature.loading || loading,
error: repositoryFeature.error || error,
providerConfigured,
ask,
};
}
40 changes: 38 additions & 2 deletions apps/frontend/src/shared/services/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,19 @@ export function isCancelledError(error: unknown): error is CancelledError {
export function getErrorMessage(error: unknown): string {
if (isApiError(error)) {
const backendMessage = getBackendMessage(error.body);
if (backendMessage) return backendMessage;
const details = getBackendDetails(error.body);
if (backendMessage && !details.length) return backendMessage;
if (backendMessage && details.length) {
return `${backendMessage}\n${details.map((d) => `- ${d}`).join('\n')}`;
}

switch (error.status) {
case 401: return 'Authentication required. Please sign in.';
case 403: return 'You do not have permission to perform this action.';
case 404: return 'The requested resource was not found.';
case 422: return 'The request data is invalid.';
case 422: return details.length
? `The request data is invalid:\n${details.map((d) => `- ${d}`).join('\n')}`
: 'The request data is invalid.';
case 409: return 'The request conflicts with an existing resource.';
case 429: return 'Too many requests. Please try again later.';
case 503: return 'Service is temporarily unavailable. Please try again.';
Expand All @@ -87,12 +93,42 @@ export function getErrorMessage(error: unknown): string {
return 'An unknown error occurred.';
}

/** Structured error for UIs that want to render field-level validation details. */
export function getErrorDetail(error: unknown): { message: string; details: string[] } {
if (isApiError(error)) {
const message = getBackendMessage(error.body) ?? getErrorMessage(error);
return { message, details: getBackendDetails(error.body) };
}
return { message: getErrorMessage(error), details: [] };
}

function getBackendMessage(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const maybeMessage = (body as { message?: unknown }).message;
return typeof maybeMessage === 'string' && maybeMessage.trim() ? maybeMessage : null;
}

function getBackendDetails(body: unknown): string[] {
if (!body || typeof body !== 'object') return [];
const raw = (body as { details?: unknown }).details;
if (Array.isArray(raw)) {
return raw
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object') {
const loc = (item as { loc?: unknown }).loc;
const msg = (item as { msg?: unknown }).msg;
const locStr = Array.isArray(loc) ? loc.filter((p) => typeof p === 'string' || typeof p === 'number').join('.') : '';
if (typeof msg === 'string') return locStr ? `${locStr}: ${msg}` : msg;
}
return null;
})
.filter((d): d is string => Boolean(d));
}
if (typeof raw === 'string') return [raw];
return [];
}

function getBackendRequestId(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const maybeRequestId = (body as { request_id?: unknown }).request_id;
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/shared/services/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { api, configureApiClient, getApiConfig, uploadFile, requestSharedRefresh } from './client';
export type { RequestConfig, ApiClientConfig, HttpMethod } from './client';

export { ApiError, NetworkError, TimeoutError, CancelledError, isApiError, isNetworkError, isTimeoutError, isCancelledError, getErrorMessage } from './errors';
export { ApiError, NetworkError, TimeoutError, CancelledError, isApiError, isNetworkError, isTimeoutError, isCancelledError, getErrorMessage, getErrorDetail } from './errors';

export { authService } from './auth';
export { repositoryService, repositoryIntelligenceService } from './repositories';
Expand Down
Loading