fix(image-analysis): preview backend status safely

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent 665668579d
commit d394772f7c
10 files changed
+507 -26

No files matched your search

@@ -5,6 +5,8 @@ import type { ImageAnalysisStatus } from '@/lib/api-client';
interface ImageAnalysisStatusSectionProps {
status?: ImageAnalysisStatus | null;
source?: 'saved' | 'editor';
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
}
function getBadge(status: ImageAnalysisStatus | null | undefined): {
@@ -13,7 +15,7 @@ function getBadge(status: ImageAnalysisStatus | null | undefined): {
} {
switch (status?.status) {
case 'active':
return { label: 'Ready', variant: 'default' };
return { label: 'Configured', variant: 'default' };
case 'mapped':
return { label: 'Saved mapping', variant: 'secondary' };
case 'attention':
@@ -36,28 +38,32 @@ function getSummary(status: ImageAnalysisStatus): string {
case 'disabled':
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
case 'mapped':
return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`;
return `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`;
case 'attention':
return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`;
return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`;
case 'hook-missing':
return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`;
return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`;
case 'skipped':
return status.reason || 'Skipped for this profile.';
case 'active':
default:
if (status.resolutionSource === 'cliproxy-bridge') {
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
}
if (status.resolutionSource === 'fallback-backend') {
return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`;
return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
}
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
}
}
function getRuntimeLine(status: ImageAnalysisStatus): string {
if (status.status === 'hook-missing') {
return 'Read -> native file access (hook install required)';
}
if (!status.runtimePath) {
return 'Read -> native file access';
}
@@ -65,6 +71,25 @@ function getRuntimeLine(status: ImageAnalysisStatus): string {
return `Read -> image-analysis hook -> ${status.runtimePath}`;
}
function getStatusContext(
source: 'saved' | 'editor',
previewState: ImageAnalysisStatusSectionProps['previewState']
): string {
if (previewState === 'invalid') {
return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.';
}
if (previewState === 'refreshing') {
return 'Refreshing the live preview from the current editor state.';
}
if (source === 'editor') {
return 'Live preview from the current editor state. Save to persist these backend and hook changes.';
}
return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.';
}
function getPersistenceLine(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
return 'Not persisted for this profile type';
@@ -77,7 +102,11 @@ function getPersistenceLine(status: ImageAnalysisStatus): string {
return `${status.persistencePath} hook missing`;
}
export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) {
export function ImageAnalysisStatusSection({
status,
source = 'saved',
previewState = 'saved',
}: ImageAnalysisStatusSectionProps) {
if (!status) {
return (
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
@@ -101,7 +130,7 @@ export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectio
<h3 className="text-sm font-semibold">Image-analysis backend</h3>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Derived runtime status. This section is not written into the JSON editor above.
{getStatusContext(source, previewState)}
</p>
</div>
<Badge variant={badge.variant} className="shrink-0 text-[11px]">
+61 -2
View File
@@ -4,7 +4,7 @@
*/
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback, useEffect, useDeferredValue } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
@@ -107,6 +107,63 @@ export function ProfileEditor({
return Object.keys(localEdits).length > 0;
}, [rawJsonEdits, localEdits, settings]);
const deferredPreviewJson = useDeferredValue(computedRawJsonContent);
const previewSettings = useMemo((): Settings | null => {
if (!computedHasChanges || !computedIsRawJsonValid) {
return null;
}
try {
return JSON.parse(deferredPreviewJson) as Settings;
} catch {
return null;
}
}, [computedHasChanges, computedIsRawJsonValid, deferredPreviewJson]);
const {
data: previewStatusResponse,
isFetching: isPreviewStatusFetching,
isError: isPreviewStatusError,
} = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({
queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson],
enabled: previewSettings !== null,
placeholderData: (previousData) => previousData,
queryFn: async () => {
const res = await fetch(`/api/settings/${profileName}/image-analysis-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: previewSettings }),
});
if (!res.ok) {
throw new Error(`Failed to preview image-analysis status: ${res.status}`);
}
return res.json();
},
});
const imageAnalysisStatus =
computedHasChanges && computedIsRawJsonValid && !isPreviewStatusError
? (previewStatusResponse?.imageAnalysisStatus ?? data?.imageAnalysisStatus)
: data?.imageAnalysisStatus;
const imageAnalysisStatusSource =
computedHasChanges &&
computedIsRawJsonValid &&
!isPreviewStatusError &&
previewStatusResponse?.imageAnalysisStatus
? 'editor'
: 'saved';
const imageAnalysisStatusPreviewState = !computedHasChanges
? 'saved'
: !computedIsRawJsonValid
? 'invalid'
: isPreviewStatusError
? 'saved'
: isPreviewStatusFetching && !previewStatusResponse?.imageAnalysisStatus
? 'refreshing'
: 'preview';
// Check for missing required fields (informational warning)
const missingRequiredFields = useMemo(() => {
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
@@ -254,7 +311,9 @@ export function ProfileEditor({
isRawJsonValid={computedIsRawJsonValid}
rawJsonEdits={rawJsonEdits}
settings={settings}
imageAnalysisStatus={data?.imageAnalysisStatus}
imageAnalysisStatus={imageAnalysisStatus}
imageAnalysisStatusSource={imageAnalysisStatusSource}
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
onChange={handleRawJsonChange}
missingRequiredFields={missingRequiredFields}
/>
@@ -21,6 +21,8 @@ interface RawEditorSectionProps {
rawJsonEdits: string | null;
settings: Settings | undefined;
imageAnalysisStatus?: ImageAnalysisStatus | null;
imageAnalysisStatusSource?: 'saved' | 'editor';
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
onChange: (value: string) => void;
missingRequiredFields?: string[];
}
@@ -31,6 +33,8 @@ export function RawEditorSection({
rawJsonEdits,
settings,
imageAnalysisStatus,
imageAnalysisStatusSource = 'saved',
imageAnalysisStatusPreviewState = 'saved',
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
@@ -80,7 +84,11 @@ export function RawEditorSection({
</div>
</div>
<div className="mx-6 mb-4">
<ImageAnalysisStatusSection status={imageAnalysisStatus} />
<ImageAnalysisStatusSection
status={imageAnalysisStatus}
source={imageAnalysisStatusSource}
previewState={imageAnalysisStatusPreviewState}
/>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
@@ -1,8 +1,36 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section';
import { fireEvent, render, screen, waitFor } from '@tests/setup/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ImageAnalysisStatus } from '@/lib/api-client';
vi.mock('@/components/shared/code-editor', () => ({
CodeEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
aria-label="raw config editor"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock('@/components/profiles/editor/header-section', () => ({
HeaderSection: () => <div data-testid="profile-editor-header" />,
}));
vi.mock('@/components/profiles/editor/friendly-ui-section', () => ({
FriendlyUISection: () => <div data-testid="profile-editor-friendly-ui" />,
}));
vi.mock('@/components/shared/confirm-dialog', () => ({
ConfirmDialog: () => null,
}));
vi.mock('@/components/shared/global-env-indicator', () => ({
GlobalEnvIndicator: () => <div data-testid="global-env-indicator" />,
}));
import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section';
import { ProfileEditor } from '@/components/profiles/editor';
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
return {
enabled: true,
@@ -24,18 +52,34 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
};
}
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('ImageAnalysisStatusSection', () => {
it('renders active bridge diagnostics near the raw editor footer stack', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('renders saved diagnostics for an active backend', () => {
render(<ImageAnalysisStatusSection status={createStatus()} />);
expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
expect(
screen.getByText(
/Derived runtime status\. This section is not written into the JSON editor above\./i
/Saved runtime status for this profile\. This section is derived and is not written into the JSON editor above\./i
)
).toBeInTheDocument();
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(screen.getByText(/Ready via Google Gemini\./i)).toBeInTheDocument();
expect(screen.getByText('Configured')).toBeInTheDocument();
expect(screen.getByText(/Configured via Google Gemini\./i)).toBeInTheDocument();
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument();
@@ -57,9 +101,164 @@ describe('ImageAnalysisStatusSection', () => {
expect(screen.getByText('Saved mapping')).toBeInTheDocument();
expect(
screen.getByText(/Ready via saved GitHub Copilot \(OAuth\) mapping/i)
screen.getByText(/Configured via saved GitHub Copilot \(OAuth\) mapping/i)
).toBeInTheDocument();
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
});
it('renders hook-missing state as native file access until the hook is installed', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
status: 'hook-missing',
reason: 'Profile hook is missing from the persisted settings file.',
})}
/>
);
expect(screen.getByText('Setup needed')).toBeInTheDocument();
expect(
screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i)
).toBeInTheDocument();
expect(screen.getByTitle(/native file access \(hook install required\)/i)).toBeInTheDocument();
});
it('uses the resolver reason for attention states like auth-token drift', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
status: 'attention',
reason:
'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.',
usesCurrentTarget: true,
usesCurrentAuthToken: false,
})}
/>
);
expect(screen.getByText('Needs review')).toBeInTheDocument();
expect(
screen.getByText(
/Configured via Google Gemini, but Runtime uses the current CLIProxy auth token/i
)
).toBeInTheDocument();
expect(
screen.getAllByText(/current CLIProxy auth token instead of the saved token/i)
).toHaveLength(2);
});
it('switches the panel to a live preview when the current editor JSON changes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
if (url.includes('/api/settings/glm/image-analysis-status')) {
expect(init?.method).toBe('POST');
return Promise.resolve(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
runtimePath: '/api/provider/ghcp',
}),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
value: JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
ANTHROPIC_AUTH_TOKEN: 'preview-token',
},
},
null,
2
),
},
});
await waitFor(() => {
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
});
expect(
screen.getByText(
/Live preview from the current editor state\. Save to persist these backend and hook changes\./i
)
).toBeInTheDocument();
});
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: { value: '{' },
});
await waitFor(() => {
expect(
screen.getByText(
/Showing last saved runtime status\. The live preview resumes when the JSON above is valid again\./i
)
).toBeInTheDocument();
});
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
});
});