From dc9fce9201dde8994933779016224569825af831 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Mon, 27 Jul 2026 13:26:53 +0100 Subject: [PATCH] fix(frontend): surface AI provider-required state and field-level validation errors - AIWorkspacePage now shows a prominent 'No AI provider configured' notice (links to Settings) instead of only a weak footer hint, so the surface is not misleading when it cannot run (issue #177). - errors.ts getErrorMessage/getErrorDetail now surface field-level validation details from 422 bodies instead of collapsing to the generic 'Request validation failed' message (issue #180). - useAIWorkspace fetches /ai/config to expose providerConfigured; the workspace error box renders structured field errors. - Test + lint green; build passes. Note: #177's literal 'reads legacy context' claim was already corrected in the UI copy (it now states sealed-snapshot structural facts); this PR adds the missing explicit provider-required state. --- .../src/app/pages/AIWorkspacePage.tsx | 43 ++++++++++++++++--- .../features/ai/hooks/useAIWorkspace.test.tsx | 1 + .../src/features/ai/hooks/useAIWorkspace.ts | 15 ++++++- .../src/shared/services/api/errors.ts | 40 ++++++++++++++++- .../frontend/src/shared/services/api/index.ts | 2 +- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/app/pages/AIWorkspacePage.tsx b/apps/frontend/src/app/pages/AIWorkspacePage.tsx index 0c6a54d..7652382 100644 --- a/apps/frontend/src/app/pages/AIWorkspacePage.tsx +++ b/apps/frontend/src/app/pages/AIWorkspacePage.tsx @@ -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 @@ -85,12 +86,24 @@ export function AIWorkspacePage() { )) )} {aiWorkspace.loading &&

AI provider is thinking...

} - {aiWorkspace.error && ( -
- -

{aiWorkspace.error}

-
- )} + {aiWorkspace.error && (() => { + const detail = getErrorDetail(aiWorkspace.error); + return ( +
+ +
+

{detail.message}

+ {detail.details.length > 0 && ( +
    + {detail.details.map((d, i) => ( +
  • {d}
  • + ))} +
+ )} +
+
+ ); + })()} {aiWorkspace.suggestions.length > 0 && (
{aiWorkspace.suggestions.map((suggestion) => ( @@ -102,6 +115,22 @@ export function AIWorkspacePage() { )}
+ {aiWorkspace.providerConfigured === false && ( +
+ + + No AI provider is configured. Free-form questions cannot run until you save a provider in{' '} + + . + +
+ )}
{ diff --git a/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx b/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx index f2f665f..4c56061 100644 --- a/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx +++ b/apps/frontend/src/features/ai/hooks/useAIWorkspace.test.tsx @@ -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)), })); diff --git a/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts b/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts index e729312..edd1ddb 100644 --- a/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts +++ b/apps/frontend/src/features/ai/hooks/useAIWorkspace.ts @@ -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'; @@ -10,6 +10,18 @@ export function useAIWorkspace() { const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [providerConfigured, setProviderConfigured] = useState(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; @@ -50,6 +62,7 @@ export function useAIWorkspace() { suggestions, loading: repositoryFeature.loading || loading, error: repositoryFeature.error || error, + providerConfigured, ask, }; } diff --git a/apps/frontend/src/shared/services/api/errors.ts b/apps/frontend/src/shared/services/api/errors.ts index 901b790..360e8bf 100644 --- a/apps/frontend/src/shared/services/api/errors.ts +++ b/apps/frontend/src/shared/services/api/errors.ts @@ -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.'; @@ -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; diff --git a/apps/frontend/src/shared/services/api/index.ts b/apps/frontend/src/shared/services/api/index.ts index a92a5ab..4258d2c 100644 --- a/apps/frontend/src/shared/services/api/index.ts +++ b/apps/frontend/src/shared/services/api/index.ts @@ -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';