diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts
new file mode 100644
index 00000000..f6f4d942
--- /dev/null
+++ b/src/web-server/routes/image-analysis-routes.ts
@@ -0,0 +1,421 @@
+import { Router, type Request, type Response } from 'express';
+import * as fs from 'fs';
+import { getImageAnalysisConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
+import {
+ CLIPROXY_PROVIDER_IDS,
+ getProviderDisplayName,
+ mapExternalProviderName,
+} from '../../cliproxy/provider-capabilities';
+import type { CLIProxyProvider } from '../../cliproxy/types';
+import { listApiProfiles, resolveCliproxyBridgeMetadata } from '../../api/services';
+import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
+import { expandPath } from '../../utils/helpers';
+import { loadSettings } from '../../utils/config-manager';
+import type { Settings } from '../../types/config';
+import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
+import {
+ normalizeImageAnalysisBackendId,
+ resolveImageAnalysisRuntimeStatus,
+} from '../../utils/hooks';
+import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
+import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
+
+const router = Router();
+const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR =
+ 'Image Analysis endpoints require localhost access when dashboard auth is disabled.';
+
+type DashboardTarget = 'claude' | 'droid' | 'codex';
+type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled';
+type BackendState = 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review';
+type CurrentTargetMode = 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved';
+
+interface ImageAnalysisRouteBody {
+ enabled?: boolean;
+ timeout?: number;
+ providerModels?: Record;
+ fallbackBackend?: string | null;
+ profileBackends?: Record;
+}
+
+function safeLoadSettings(settingsPath: string | null): Settings | null {
+ if (!settingsPath) return null;
+
+ try {
+ const expandedPath = expandPath(settingsPath);
+ if (!fs.existsSync(expandedPath)) return null;
+ return loadSettings(expandedPath);
+ } catch {
+ return null;
+ }
+}
+
+function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null {
+ if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
+ return null;
+ }
+
+ try {
+ const parsed = new URL(baseUrl);
+ const extracted = extractProviderFromPathname(parsed.pathname);
+ return extracted ? mapExternalProviderName(extracted) : null;
+ } catch {
+ const extracted = extractProviderFromPathname(baseUrl);
+ return extracted ? mapExternalProviderName(extracted) : null;
+ }
+}
+
+function resolveTarget(target: unknown): DashboardTarget {
+ if (target === 'droid' || target === 'codex') return target;
+ return 'claude';
+}
+
+function resolveCurrentTargetMode(
+ target: DashboardTarget,
+ status: Awaited>
+): CurrentTargetMode {
+ if (!status.enabled) return 'disabled';
+ if (!status.backendId) return 'unresolved';
+ if (target !== 'claude') return 'bypassed';
+ if (status.status === 'hook-missing') return 'setup';
+ if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
+ return 'active';
+}
+
+function resolveBackendState(
+ status: Awaited>
+): BackendState {
+ if (status.authReadiness === 'missing') return 'needs_auth';
+ if (status.proxyReadiness === 'unavailable') return 'needs_proxy';
+ if (status.proxyReadiness === 'stopped') return 'starts_on_launch';
+ if (status.effectiveRuntimeMode === 'native-read' || status.status === 'attention')
+ return 'review';
+ return 'ready';
+}
+
+function getKnownBackends(): string[] {
+ return Array.from(new Set(CLIPROXY_PROVIDER_IDS)).sort((left, right) =>
+ left.localeCompare(right)
+ );
+}
+
+async function buildDashboardPayload() {
+ const config = getImageAnalysisConfig();
+ const { profiles, variants } = listApiProfiles();
+ const sharedHookInstalled = hasImageAnalyzerHook();
+
+ const profileRows = await Promise.all(
+ profiles.map(async (profile) => {
+ const settingsPath = profile.settingsPath || null;
+ const settings = safeLoadSettings(settingsPath);
+ const cliproxyProvider =
+ mapExternalProviderName(profile.name) ??
+ resolveCliproxyBridgeMetadata(settings ?? undefined)?.provider ??
+ resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL);
+ const status = await resolveImageAnalysisRuntimeStatus(
+ {
+ profileName: profile.name,
+ profileType: 'settings',
+ cliproxyProvider,
+ settingsPath,
+ settings,
+ cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined),
+ hookInstalled: settingsPath
+ ? hasImageAnalysisProfileHook(profile.name, settingsPath)
+ : undefined,
+ sharedHookInstalled,
+ },
+ config
+ );
+
+ return {
+ name: profile.name,
+ kind: 'profile' as const,
+ target: resolveTarget(profile.target),
+ configured: profile.isConfigured,
+ settingsPath,
+ backendId: status.backendId,
+ backendDisplayName: status.backendDisplayName,
+ resolutionSource: status.resolutionSource,
+ status: status.status,
+ effectiveRuntimeMode: status.effectiveRuntimeMode,
+ effectiveRuntimeReason: status.effectiveRuntimeReason,
+ currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status),
+ };
+ })
+ );
+
+ const variantRows = await Promise.all(
+ variants.map(async (variant) => {
+ const settingsPath =
+ typeof variant.settings === 'string' && variant.settings !== '-' ? variant.settings : null;
+ const settings = safeLoadSettings(settingsPath);
+ const cliproxyProvider = mapExternalProviderName(variant.provider);
+ const status = await resolveImageAnalysisRuntimeStatus(
+ {
+ profileName: variant.name,
+ profileType: 'cliproxy',
+ cliproxyProvider,
+ isComposite: variant.provider === 'composite',
+ settingsPath,
+ settings,
+ cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined),
+ hookInstalled: settingsPath
+ ? hasImageAnalysisProfileHook(variant.name, settingsPath)
+ : undefined,
+ sharedHookInstalled,
+ },
+ config
+ );
+
+ return {
+ name: variant.name,
+ kind: 'variant' as const,
+ target: resolveTarget(variant.target),
+ configured: true,
+ settingsPath,
+ backendId: status.backendId,
+ backendDisplayName: status.backendDisplayName,
+ resolutionSource: status.resolutionSource,
+ status: status.status,
+ effectiveRuntimeMode: status.effectiveRuntimeMode,
+ effectiveRuntimeReason: status.effectiveRuntimeReason,
+ currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status),
+ };
+ })
+ );
+
+ const allProfileRows = [...profileRows, ...variantRows].sort((left, right) =>
+ left.name.localeCompare(right.name)
+ );
+
+ const backendRows = await Promise.all(
+ Object.entries(config.provider_models)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(async ([backendId, model]) => {
+ const status = await resolveImageAnalysisRuntimeStatus(
+ {
+ profileName: backendId,
+ profileType: 'cliproxy',
+ cliproxyProvider: mapExternalProviderName(backendId),
+ hookInstalled: true,
+ sharedHookInstalled: true,
+ },
+ config
+ );
+
+ return {
+ backendId,
+ displayName: getProviderDisplayName(backendId as CLIProxyProvider),
+ model,
+ state: resolveBackendState(status),
+ authReadiness: status.authReadiness,
+ authReason: status.authReason,
+ proxyReadiness: status.proxyReadiness,
+ proxyReason: status.proxyReason,
+ profilesUsing: allProfileRows.filter((profile) => profile.backendId === backendId).length,
+ };
+ })
+ );
+
+ const activeProfileCount = allProfileRows.filter(
+ (row) => row.currentTargetMode === 'active'
+ ).length;
+ const bypassedProfileCount = allProfileRows.filter(
+ (row) => row.currentTargetMode === 'bypassed'
+ ).length;
+ const mappedProfileCount = allProfileRows.filter(
+ (row) => row.resolutionSource === 'profile-backend'
+ ).length;
+ const blockerCount = backendRows.filter(
+ (row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review'
+ ).length;
+
+ let summaryState: DashboardSummaryState = 'ready';
+ let title = 'Ready';
+ let detail = `${activeProfileCount} profile${activeProfileCount === 1 ? '' : 's'} can use Image Analysis on the current Claude target path.`;
+
+ if (!config.enabled) {
+ summaryState = 'disabled';
+ title = 'Disabled';
+ detail =
+ 'Image Analysis is turned off globally. Images and PDFs fall back to native file access.';
+ } else if (backendRows.length === 0) {
+ summaryState = 'needs_setup';
+ title = 'Needs provider models';
+ detail = 'Add at least one provider model before turning Image Analysis on for profiles.';
+ } else if (blockerCount > 0) {
+ summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup';
+ title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup';
+ detail = `${blockerCount} backend${blockerCount === 1 ? '' : 's'} still need auth, runtime, or review before every profile path is healthy.`;
+ }
+
+ return {
+ config: {
+ enabled: config.enabled,
+ timeout: config.timeout,
+ providerModels: config.provider_models,
+ fallbackBackend: config.fallback_backend ?? null,
+ profileBackends: config.profile_backends ?? {},
+ },
+ summary: {
+ state: summaryState,
+ title,
+ detail,
+ backendCount: backendRows.length,
+ mappedProfileCount,
+ activeProfileCount,
+ bypassedProfileCount,
+ },
+ backends: backendRows,
+ profiles: allProfileRows,
+ catalog: {
+ knownBackends: getKnownBackends(),
+ profileNames: allProfileRows.map((row) => row.name),
+ },
+ };
+}
+
+router.use((req: Request, res: Response, next) => {
+ if (requireLocalAccessWhenAuthDisabled(req, res, IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR)) {
+ next();
+ }
+});
+
+router.get('/', async (_req: Request, res: Response): Promise => {
+ try {
+ res.json(await buildDashboardPayload());
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+router.put('/', async (req: Request, res: Response): Promise => {
+ const body = req.body as ImageAnalysisRouteBody;
+
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
+ res.status(400).json({ error: 'Invalid request body. Must be an object.' });
+ return;
+ }
+
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean') {
+ res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' });
+ return;
+ }
+
+ if (body.timeout !== undefined) {
+ if (!Number.isInteger(body.timeout) || body.timeout < 10 || body.timeout > 600) {
+ res.status(400).json({ error: 'Timeout must be an integer between 10 and 600 seconds.' });
+ return;
+ }
+ }
+
+ if (
+ body.providerModels !== undefined &&
+ (body.providerModels === null ||
+ Array.isArray(body.providerModels) ||
+ typeof body.providerModels !== 'object')
+ ) {
+ res.status(400).json({ error: 'Invalid value for providerModels. Must be an object.' });
+ return;
+ }
+
+ if (
+ body.profileBackends !== undefined &&
+ (body.profileBackends === null ||
+ Array.isArray(body.profileBackends) ||
+ typeof body.profileBackends !== 'object')
+ ) {
+ res.status(400).json({ error: 'Invalid value for profileBackends. Must be an object.' });
+ return;
+ }
+
+ if (
+ body.fallbackBackend !== undefined &&
+ body.fallbackBackend !== null &&
+ typeof body.fallbackBackend !== 'string'
+ ) {
+ res.status(400).json({ error: 'Invalid value for fallbackBackend. Must be a string or null.' });
+ return;
+ }
+
+ try {
+ const currentConfig = getImageAnalysisConfig();
+ const knownBackends = new Set([
+ ...getKnownBackends(),
+ ...Object.keys(currentConfig.provider_models),
+ ]);
+ const nextProviderModels = Object.entries(
+ body.providerModels ?? currentConfig.provider_models
+ ).reduce(
+ (acc, [backendId, model]) => {
+ const normalizedBackend = normalizeImageAnalysisBackendId(backendId, knownBackends);
+ const normalizedModel = typeof model === 'string' ? model.trim() : '';
+ if (!normalizedBackend || normalizedModel.length === 0) {
+ return acc;
+ }
+ acc[normalizedBackend] = normalizedModel;
+ return acc;
+ },
+ {} as Record
+ );
+
+ if (Object.keys(nextProviderModels).length === 0) {
+ res.status(400).json({ error: 'At least one provider model must remain configured.' });
+ return;
+ }
+
+ const requestedFallback =
+ typeof body.fallbackBackend === 'string'
+ ? body.fallbackBackend
+ : currentConfig.fallback_backend;
+ const normalizedFallback = normalizeImageAnalysisBackendId(
+ requestedFallback,
+ Object.keys(nextProviderModels)
+ );
+ if (!normalizedFallback || !nextProviderModels[normalizedFallback]) {
+ res
+ .status(400)
+ .json({ error: 'Fallback backend must reference a configured provider model.' });
+ return;
+ }
+
+ const nextProfileBackends = {} as Record;
+ for (const [profileName, backendId] of Object.entries(
+ body.profileBackends ?? currentConfig.profile_backends ?? {}
+ )) {
+ const trimmedProfileName = profileName.trim();
+ if (!trimmedProfileName) {
+ continue;
+ }
+
+ const normalizedBackend = normalizeImageAnalysisBackendId(
+ backendId,
+ Object.keys(nextProviderModels)
+ );
+ if (!normalizedBackend || !nextProviderModels[normalizedBackend]) {
+ res.status(400).json({
+ error: `Profile mapping for "${trimmedProfileName}" references an unknown backend.`,
+ });
+ return;
+ }
+
+ nextProfileBackends[trimmedProfileName] = normalizedBackend;
+ }
+
+ mutateUnifiedConfig((config) => {
+ config.image_analysis = {
+ enabled: body.enabled ?? currentConfig.enabled,
+ timeout: body.timeout ?? currentConfig.timeout,
+ provider_models: nextProviderModels,
+ fallback_backend: normalizedFallback,
+ profile_backends: nextProfileBackends,
+ };
+ });
+
+ res.json(await buildDashboardPayload());
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+export default router;
diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts
index 4a8876e0..85534e0c 100644
--- a/src/web-server/routes/index.ts
+++ b/src/web-server/routes/index.ts
@@ -17,6 +17,7 @@ import variantRoutes from './variant-routes';
import settingsRoutes from './settings-routes';
import channelsRoutes from './channels-routes';
import websearchRoutes from './websearch-routes';
+import imageAnalysisRoutes from './image-analysis-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
@@ -68,6 +69,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ====================
apiRoutes.use('/websearch', websearchRoutes);
+apiRoutes.use('/image-analysis', imageAnalysisRoutes);
// ==================== Copilot ====================
apiRoutes.use('/copilot', copilotRoutes);
diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts
new file mode 100644
index 00000000..3d9b399b
--- /dev/null
+++ b/tests/unit/web-server/image-analysis-routes.test.ts
@@ -0,0 +1,244 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
+import express from 'express';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import type { Server } from 'http';
+import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
+import imageAnalysisRoutes from '../../../src/web-server/routes/image-analysis-routes';
+
+describe('image-analysis routes', () => {
+ let server: Server;
+ let baseUrl = '';
+ let tempHome: string;
+ let originalCcsHome: string | undefined;
+ let originalDashboardAuthEnabled: string | undefined;
+ let forcedRemoteAddress = '127.0.0.1';
+
+ beforeAll(async () => {
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ Object.defineProperty(req.socket, 'remoteAddress', {
+ value: forcedRemoteAddress,
+ configurable: true,
+ });
+ next();
+ });
+ app.use('/api/image-analysis', imageAnalysisRoutes);
+
+ await new Promise((resolve, reject) => {
+ server = app.listen(0, '127.0.0.1');
+ const onError = (error: Error) => reject(error);
+
+ server.once('error', onError);
+ server.once('listening', () => {
+ server.off('error', onError);
+ resolve();
+ });
+ });
+
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ throw new Error('Unable to resolve test server port');
+ }
+
+ baseUrl = `http://127.0.0.1:${address.port}`;
+ });
+
+ afterAll(async () => {
+ await new Promise((resolve) => server.close(() => resolve()));
+ });
+
+ beforeEach(() => {
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-routes-test-'));
+ originalCcsHome = process.env.CCS_HOME;
+ originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
+ process.env.CCS_HOME = tempHome;
+ process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
+ forcedRemoteAddress = '127.0.0.1';
+
+ const glmSettingsPath = path.join(tempHome, 'glm.settings.json');
+ const codexSettingsPath = path.join(tempHome, 'codex-profile.settings.json');
+
+ fs.writeFileSync(
+ glmSettingsPath,
+ JSON.stringify(
+ {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
+ ANTHROPIC_AUTH_TOKEN: 'glm-token',
+ },
+ },
+ null,
+ 2
+ )
+ );
+ fs.writeFileSync(
+ codexSettingsPath,
+ JSON.stringify(
+ {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
+ ANTHROPIC_AUTH_TOKEN: 'codex-token',
+ },
+ },
+ null,
+ 2
+ )
+ );
+
+ mutateUnifiedConfig((config) => {
+ config.profiles.glm = {
+ settings: glmSettingsPath,
+ target: 'claude',
+ };
+ config.profiles.codexProfile = {
+ settings: codexSettingsPath,
+ target: 'droid',
+ };
+ config.image_analysis = {
+ enabled: true,
+ timeout: 60,
+ provider_models: {
+ gemini: 'gemini-2.5-flash',
+ ghcp: 'claude-haiku-4.5',
+ },
+ fallback_backend: 'gemini',
+ profile_backends: {
+ codexProfile: 'ghcp',
+ },
+ };
+ });
+ });
+
+ afterEach(() => {
+ if (originalCcsHome !== undefined) {
+ process.env.CCS_HOME = originalCcsHome;
+ } else {
+ delete process.env.CCS_HOME;
+ }
+
+ if (originalDashboardAuthEnabled !== undefined) {
+ process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
+ } else {
+ delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
+ }
+
+ if (tempHome && fs.existsSync(tempHome)) {
+ fs.rmSync(tempHome, { recursive: true, force: true });
+ }
+ });
+
+ it('blocks remote access when dashboard auth is disabled', async () => {
+ forcedRemoteAddress = '10.10.0.24';
+
+ const response = await fetch(`${baseUrl}/api/image-analysis`);
+ expect(response.status).toBe(403);
+ expect(await response.json()).toEqual({
+ error: 'Image Analysis endpoints require localhost access when dashboard auth is disabled.',
+ });
+ });
+
+ it('returns global settings, backend readiness, and profile coverage', async () => {
+ const response = await fetch(`${baseUrl}/api/image-analysis`);
+ expect(response.status).toBe(200);
+ const payload = await response.json();
+
+ expect(payload.config).toMatchObject({
+ enabled: true,
+ timeout: 60,
+ fallbackBackend: 'gemini',
+ profileBackends: {
+ codexProfile: 'ghcp',
+ },
+ });
+ expect(payload.catalog.knownBackends).toContain('gemini');
+ expect(payload.backends).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ backendId: 'gemini',
+ model: 'gemini-2.5-flash',
+ }),
+ expect.objectContaining({
+ backendId: 'ghcp',
+ profilesUsing: 1,
+ }),
+ ])
+ );
+ expect(payload.profiles).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'glm',
+ target: 'claude',
+ currentTargetMode: 'setup',
+ }),
+ expect.objectContaining({
+ name: 'codexProfile',
+ target: 'droid',
+ backendId: 'ghcp',
+ currentTargetMode: 'bypassed',
+ }),
+ ])
+ );
+ expect(payload.summary).toMatchObject({
+ backendCount: 2,
+ bypassedProfileCount: 1,
+ });
+ });
+
+ it('updates the saved config through the dashboard route', async () => {
+ const response = await fetch(`${baseUrl}/api/image-analysis`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled: true,
+ timeout: 120,
+ providerModels: {
+ gemini: 'gemini-2.5-pro',
+ ghcp: 'claude-haiku-4.5',
+ },
+ fallbackBackend: 'ghcp',
+ profileBackends: {
+ glm: 'gemini',
+ codexProfile: 'ghcp',
+ },
+ }),
+ });
+
+ expect(response.status).toBe(200);
+ const payload = await response.json();
+ expect(payload.config).toMatchObject({
+ timeout: 120,
+ fallbackBackend: 'ghcp',
+ profileBackends: {
+ glm: 'gemini',
+ codexProfile: 'ghcp',
+ },
+ });
+ expect(payload.config.providerModels).toMatchObject({
+ gemini: 'gemini-2.5-pro',
+ });
+ });
+
+ it('rejects profile mappings that point to a missing backend with a client error', async () => {
+ const response = await fetch(`${baseUrl}/api/image-analysis`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ providerModels: {
+ gemini: 'gemini-2.5-flash',
+ },
+ fallbackBackend: 'gemini',
+ profileBackends: {
+ codexProfile: 'ghcp',
+ },
+ }),
+ });
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({
+ error: 'Profile mapping for "codexProfile" references an unknown backend.',
+ });
+ });
+});
diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx
index 27bab3c2..2008269b 100644
--- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx
+++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx
@@ -1,5 +1,7 @@
-import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react';
+import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react';
+import { Link } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client';
@@ -10,19 +12,20 @@ interface ImageAnalysisStatusSectionProps {
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
}
-const RESOLUTION_SOURCE_LABELS: Record = {
+const SOURCE_LABELS: Record = {
'cliproxy-provider': 'Direct provider route',
'cliproxy-variant': 'Variant route',
'cliproxy-composite': 'Composite route',
'copilot-alias': 'Copilot alias',
- 'cliproxy-bridge': 'Derived from profile API route',
- 'profile-backend': 'Saved Image Analysis mapping',
+ 'cliproxy-bridge': 'Derived from profile route',
+ 'profile-backend': 'Explicit profile mapping',
'fallback-backend': 'Fallback backend',
disabled: 'Disabled globally',
'unsupported-profile': 'Unsupported profile type',
unresolved: 'No backend mapped',
'missing-model': 'Missing model',
};
+
const TARGET_LABELS: Record = {
claude: 'Claude Code',
droid: 'Factory Droid',
@@ -36,243 +39,131 @@ function getPreviewBadge(
if (previewState === 'refreshing') {
return { label: 'Refreshing', variant: 'outline' as const };
}
-
if (source === 'editor') {
return { label: 'Live Preview', variant: 'secondary' as const };
}
-
return { label: 'Saved', variant: 'outline' as const };
}
-function getRuntimeBadge(status: ImageAnalysisStatus | null | undefined, target: CliTarget) {
- if (!status) return { label: 'Checking', variant: 'outline' as const };
- if (target !== 'claude') {
- if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const };
- if (status.status === 'hook-missing')
- return { label: 'Setup needed', variant: 'destructive' as const };
- if (status.authReadiness === 'missing')
- return { label: 'Needs auth', variant: 'destructive' as const };
- if (status.proxyReadiness === 'unavailable')
- return { label: 'Needs proxy', variant: 'destructive' as const };
- if (status.effectiveRuntimeMode === 'native-read' && status.backendId) {
- return { label: 'Saved fallback', variant: 'outline' as const };
- }
- return { label: 'Target bypassed', variant: 'outline' as const };
- }
- if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const };
- if (status.status === 'hook-missing')
- return { label: 'Setup needed', variant: 'destructive' as const };
- if (status.authReadiness === 'missing')
- return { label: 'Needs auth', variant: 'destructive' as const };
- if (status.proxyReadiness === 'unavailable')
- return { label: 'Needs proxy', variant: 'destructive' as const };
- if (
- status.effectiveRuntimeMode === 'cliproxy-image-analysis' &&
- status.proxyReadiness === 'stopped'
- ) {
- return { label: 'Starts on launch', variant: 'secondary' as const };
- }
- if (status.effectiveRuntimeMode === 'cliproxy-image-analysis') {
- return { label: 'CLIProxy Active', variant: 'default' as const };
- }
- if (
- status.authReadiness === 'unknown' ||
- status.proxyReadiness === 'unknown' ||
- status.status === 'attention'
- ) {
- return { label: 'Needs review', variant: 'outline' as const };
- }
- return { label: 'Native Access', variant: 'outline' as const };
-}
-
-function getSummary(status: ImageAnalysisStatus, target: CliTarget): string {
- const backendName = status.backendDisplayName || status.backendId || 'this backend';
- const currentTarget = TARGET_LABELS[target];
-
- if (target !== 'claude') {
- if (!status.backendId) {
- return (
- status.reason ||
- `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and no Claude-side Image Analysis backend is currently mapped.`
- );
- }
-
- if (status.status === 'disabled') {
- return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and saved Claude-side Image Analysis is disabled for this profile.`;
- }
-
- if (status.status === 'hook-missing') {
- return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for ${backendName} still falls back to native file access because ${status.reason || 'the profile hook is not fully installed yet.'}`;
- }
-
- if (status.effectiveRuntimeMode === 'native-read') {
- return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for ${backendName} still falls back to native file access. ${status.effectiveRuntimeReason || status.reason || `Image Analysis via ${backendName} could not be confirmed.`}`;
- }
-
- return `Images and PDFs for this profile are configured to resolve through ${backendName} when the profile runs on Claude Code.`;
- }
-
+function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) {
if (status.status === 'disabled') {
- return 'Image Analysis is disabled globally, so images and PDFs use built-in file access for this profile.';
+ return {
+ label: 'Disabled',
+ className: 'border-border/80 bg-background/85 text-muted-foreground',
+ };
}
-
- if (!status.backendId) {
- return status.reason || 'This profile currently uses built-in file access for images and PDFs.';
- }
-
if (status.status === 'hook-missing') {
- return `Images and PDFs are configured for ${backendName}, but ${status.reason || 'the profile hook is not fully installed yet.'}`;
+ return {
+ label: 'Setup needed',
+ className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
+ };
}
-
- if (status.effectiveRuntimeMode === 'native-read') {
- return `This profile currently falls back to native file access. ${status.effectiveRuntimeReason || status.reason || `Image Analysis via ${backendName} could not be confirmed.`}`;
+ if (status.authReadiness === 'missing') {
+ return {
+ label: 'Needs auth',
+ className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200',
+ };
}
-
- if (status.proxyReadiness === 'stopped') {
- return `Images and PDFs for this profile resolve through ${backendName}. Auth is ready and CCS will start the local CLIProxy runtime when needed.`;
+ if (status.proxyReadiness === 'unavailable') {
+ return {
+ label: 'Needs proxy',
+ className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
+ };
}
-
- if (status.resolutionSource === 'profile-backend') {
- return `Images and PDFs for this profile resolve through ${backendName} via a saved Image Analysis mapping.`;
- }
-
- if (status.status === 'attention' && status.reason) {
- return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy, but ${status.reason}`;
- }
-
- return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy.`;
-}
-
-function getRuntimeLine(status: ImageAnalysisStatus, target: CliTarget): string {
if (target !== 'claude') {
- return `${TARGET_LABELS[target]} launch -> no Claude Read hook`;
+ return {
+ label: 'Bypassed',
+ className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
+ };
}
-
if (status.effectiveRuntimeMode === 'native-read') {
- return 'Read -> native file access';
+ return {
+ label: 'Native fallback',
+ className: 'border-border/80 bg-background/85 text-muted-foreground',
+ };
}
-
if (status.proxyReadiness === 'stopped') {
- return 'Read -> image-analysis hook -> start local CLIProxy';
+ return {
+ label: 'Starts on launch',
+ className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
+ };
}
-
- if (status.proxyReadiness === 'remote') {
- return 'Read -> image-analysis hook -> remote CLIProxy';
- }
-
- return `Read -> image-analysis hook -> ${status.runtimePath || 'CLIProxy'}`;
-}
-
-function getConfiguredBackendDetail(status: ImageAnalysisStatus): string {
- if (!status.backendId) {
- return status.reason || 'No backend mapped';
- }
-
- return RESOLUTION_SOURCE_LABELS[status.resolutionSource] || 'Configured';
-}
-
-function getEffectiveRuntimeValue(status: ImageAnalysisStatus, target: CliTarget): string {
- if (target !== 'claude') {
- return `Not active on ${TARGET_LABELS[target]}`;
- }
-
- if (status.effectiveRuntimeMode === 'native-read') {
- return 'Native file access';
- }
-
- return 'CLIProxy image analysis';
-}
-
-function getEffectiveRuntimeDetail(status: ImageAnalysisStatus, target: CliTarget): string {
- if (target !== 'claude') {
- if (status.status === 'hook-missing') {
- return (
- status.reason ||
- `${TARGET_LABELS[target]} bypasses the Claude Read hook, and the saved Claude-side profile hook is still missing.`
- );
- }
-
- if (status.effectiveRuntimeMode === 'native-read') {
- return (
- status.effectiveRuntimeReason ||
- status.reason ||
- `${TARGET_LABELS[target]} bypasses the Claude Read hook, and the saved Claude-side setup currently falls back to native file access.`
- );
- }
-
- return `${TARGET_LABELS[target]} bypasses the Claude Read hook. Switch the target back to Claude Code to use the saved backend shown here.`;
- }
-
- if (status.effectiveRuntimeMode === 'native-read') {
- return (
- status.effectiveRuntimeReason ||
- status.reason ||
- 'Uses built-in file access for this profile.'
- );
- }
-
- if (status.proxyReadiness === 'stopped') {
- return 'Auth ready • Local CLIProxy starts on demand';
- }
-
- if (status.proxyReadiness === 'remote') {
- return 'Auth ready • Remote CLIProxy reachable';
- }
-
- if (status.authReadiness === 'ready' && status.proxyReadiness === 'ready') {
- return 'Auth ready • Local CLIProxy reachable';
- }
-
- return status.proxyReason || status.authReason || 'Runtime verified';
-}
-
-function getAuthLine(status: ImageAnalysisStatus): string {
- if (status.authReadiness === 'not-needed') return 'Not required';
- if (status.authReadiness === 'ready')
- return `${status.authDisplayName || status.authProvider} ready`;
- return status.authReason || 'Auth readiness could not be verified.';
-}
-
-function getProxyLine(status: ImageAnalysisStatus): string {
- if (status.proxyReadiness === 'not-needed') return 'Not required';
- if (status.proxyReadiness === 'ready') return 'Local CLIProxy ready';
- if (status.proxyReadiness === 'remote') return status.proxyReason || 'Remote CLIProxy ready';
- if (status.proxyReadiness === 'stopped') return 'Local CLIProxy idle; starts on launch';
- return status.proxyReason || 'CLIProxy runtime readiness could not be verified.';
+ return {
+ label: 'CLIProxy active',
+ className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
+ };
}
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.';
+ return 'Showing saved status until the JSON above is valid again.';
}
if (previewState === 'refreshing') {
- return 'Refreshing the live preview from the current editor state.';
+ return 'Refreshing from the current editor state.';
}
- if (source === 'editor') {
- return 'Live preview from the current editor state. Save to persist config changes; auth and proxy readiness stay derived below.';
+ return source === 'editor'
+ ? 'Preview from the current editor JSON.'
+ : 'Saved status for this profile.';
+}
+
+function getSummary(status: ImageAnalysisStatus, target: CliTarget): string {
+ const backendName = status.backendDisplayName || status.backendId || 'native file access';
+ if (status.status === 'disabled') {
+ return 'Image Analysis is disabled globally for this profile.';
}
- return 'Saved runtime status for this profile. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime.';
+ if (!status.backendId) {
+ return target === 'claude'
+ ? 'This profile currently uses native file access for images and PDFs.'
+ : `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`;
+ }
+ if (target !== 'claude') {
+ return status.effectiveRuntimeMode === 'native-read'
+ ? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.`
+ : `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`;
+ }
+ if (status.effectiveRuntimeMode === 'native-read') {
+ return `Saved backend: ${backendName}. Current runtime falls back to native file access.`;
+ }
+ return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`;
+}
+
+function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string {
+ if (target !== 'claude') {
+ return 'Current launch path bypasses the Claude Read hook.';
+ }
+ if (status.status === 'hook-missing') {
+ return 'Hook must be persisted before this profile can use Image Analysis.';
+ }
+ if (status.effectiveRuntimeMode === 'native-read') {
+ return status.effectiveRuntimeReason || status.reason || 'Using native file access.';
+ }
+ if (status.proxyReadiness === 'stopped') {
+ return 'Auth is ready. Local CLIProxy will start on demand.';
+ }
+ return 'Current target can use the saved backend.';
}
function getPersistenceValue(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
- return 'Not persisted';
+ return 'Not required';
}
-
- return status.hookInstalled ? 'Hook saved to profile' : 'Hook missing from profile';
+ return status.hookInstalled ? 'Hook saved' : 'Hook missing';
}
function getPersistenceDetail(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
- return 'Not required for this profile type';
+ return 'No profile-level hook persistence required.';
}
-
return status.persistencePath;
}
+function getModelValue(status: ImageAnalysisStatus): string {
+ return status.model || status.reason || 'Unavailable';
+}
+
export function ImageAnalysisStatusSection({
status,
target = 'claude',
@@ -282,23 +173,18 @@ export function ImageAnalysisStatusSection({
if (!status) {
return (
-
-
-
Checking backend status...
+
+
);
}
const previewBadge = getPreviewBadge(source, previewState);
const runtimeBadge = getRuntimeBadge(status, target);
- const bypassedOnCurrentTarget = target !== 'claude';
- const notice = bypassedOnCurrentTarget
- ? null
- : status.effectiveRuntimeMode === 'native-read'
- ? status.effectiveRuntimeReason
- : status.status === 'attention' || status.status === 'hook-missing'
- ? status.reason
- : null;
+ const showNotice =
+ status.status === 'hook-missing' ||
+ status.authReadiness === 'missing' ||
+ status.proxyReadiness === 'unavailable';
return (
@@ -313,64 +199,50 @@ export function ImageAnalysisStatusSection({
-
+
{previewBadge.label}
-
+
{runtimeBadge.label}
-
- {getSummary(status, target)}
-
-
- {bypassedOnCurrentTarget && (
-
-
-
- Current default target: {TARGET_LABELS[target]}. The diagnostics below describe the
- saved Claude-side Image Analysis hook and apply again if you switch back to Claude Code.
-
-
- )}
+ {getSummary(status, target)}
- Configured backend
+ Backend
- {status.backendDisplayName || status.backendId || 'No backend mapped'}
+ {status.backendDisplayName || status.backendId || 'Native file access'}
- {getConfiguredBackendDetail(status)}
+ {SOURCE_LABELS[status.resolutionSource]}
- Effective runtime
-
-
- {getEffectiveRuntimeValue(status, target)}
+ Current target
+
{TARGET_LABELS[target]}
- {getEffectiveRuntimeDetail(status, target)}
+ {getTargetDetail(status, target)}
- Hook persistence
+ Persistence
{getPersistenceValue(status)}
{getPersistenceDetail(status)}
@@ -382,49 +254,61 @@ export function ImageAnalysisStatusSection({
Auth
-
{getAuthLine(status)}
+
+ {status.authReadiness === 'ready'
+ ? `${status.authDisplayName || status.authProvider} ready`
+ : status.authReadiness === 'missing'
+ ? status.authReason
+ : status.authReadiness === 'not-needed'
+ ? 'Not required'
+ : 'Unknown'}
+
Proxy
- {getProxyLine(status)}
+
+ {status.proxyReadiness === 'ready'
+ ? 'Local CLIProxy ready'
+ : status.proxyReadiness === 'remote'
+ ? 'Remote CLIProxy ready'
+ : status.proxyReadiness === 'stopped'
+ ? 'Local CLIProxy idle'
+ : status.proxyReadiness === 'not-needed'
+ ? 'Not required'
+ : status.proxyReason || 'Unknown'}
+
Model
- {status.model || status.reason || 'Unavailable'}
+ {getModelValue(status)}
-
-
- Runtime path
-
-
- {getRuntimeLine(status, target)}
-
-
-
- {notice && (
+ {showNotice && (
-
{notice}
+
+ {status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
+
)}
-
-
-
- This panel covers the Image Analysis hook only. WebSearch stays managed separately and is
- not controlled here.
-
+
+
+ CRUD and backend routing now live in global Settings.
+
+
+
+ Open Settings
+
+
+
);
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts
index 63f03870..3d4ffeea 100644
--- a/ui/src/lib/api-client.ts
+++ b/ui/src/lib/api-client.ts
@@ -148,6 +148,72 @@ export interface ImageAnalysisStatus {
effectiveRuntimeReason: string | null;
}
+export interface ImageAnalysisSettingsConfig {
+ enabled: boolean;
+ timeout: number;
+ providerModels: Record
;
+ fallbackBackend: string | null;
+ profileBackends: Record;
+}
+
+export interface ImageAnalysisDashboardSummary {
+ state: 'ready' | 'partial' | 'needs_setup' | 'disabled';
+ title: string;
+ detail: string;
+ backendCount: number;
+ mappedProfileCount: number;
+ activeProfileCount: number;
+ bypassedProfileCount: number;
+}
+
+export interface ImageAnalysisDashboardBackend {
+ backendId: string;
+ displayName: string;
+ model: string;
+ state: 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review';
+ authReadiness: ImageAnalysisStatus['authReadiness'];
+ authReason: string | null;
+ proxyReadiness: ImageAnalysisStatus['proxyReadiness'];
+ proxyReason: string | null;
+ profilesUsing: number;
+}
+
+export interface ImageAnalysisDashboardProfile {
+ name: string;
+ kind: 'profile' | 'variant';
+ target: CliTarget;
+ configured: boolean;
+ settingsPath: string | null;
+ backendId: string | null;
+ backendDisplayName: string | null;
+ resolutionSource: ImageAnalysisStatus['resolutionSource'];
+ status: ImageAnalysisStatus['status'];
+ effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
+ effectiveRuntimeReason: string | null;
+ currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved';
+}
+
+export interface ImageAnalysisDashboardCatalog {
+ knownBackends: string[];
+ profileNames: string[];
+}
+
+export interface ImageAnalysisDashboardData {
+ config: ImageAnalysisSettingsConfig;
+ summary: ImageAnalysisDashboardSummary;
+ backends: ImageAnalysisDashboardBackend[];
+ profiles: ImageAnalysisDashboardProfile[];
+ catalog: ImageAnalysisDashboardCatalog;
+}
+
+export interface UpdateImageAnalysisSettingsPayload {
+ enabled?: boolean;
+ timeout?: number;
+ providerModels?: Record;
+ fallbackBackend?: string | null;
+ profileBackends?: Record;
+}
+
export interface Profile {
name: string;
settingsPath: string;
@@ -843,6 +909,14 @@ export const api = {
body: JSON.stringify(data),
}),
},
+ imageAnalysis: {
+ get: () => request('/image-analysis'),
+ update: (data: UpdateImageAnalysisSettingsPayload) =>
+ request('/image-analysis', {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ },
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
getAuthStatus: () =>
diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx
index 0c3fb57d..9e9f28ed 100644
--- a/ui/src/pages/settings/components/tab-navigation.tsx
+++ b/ui/src/pages/settings/components/tab-navigation.tsx
@@ -4,7 +4,16 @@
*/
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { Globe, Settings2, Server, KeyRound, Brain, Archive, MessageSquare } from 'lucide-react';
+import {
+ Globe,
+ Image as ImageIcon,
+ Settings2,
+ Server,
+ KeyRound,
+ Brain,
+ Archive,
+ MessageSquare,
+} from 'lucide-react';
import type { SettingsTab } from '../types';
import { useTranslation } from 'react-i18next';
@@ -17,6 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
const { t } = useTranslation();
const tabs = [
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
+ { value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon },
{ value: 'channels' as const, label: 'Channels', icon: MessageSquare },
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
@@ -27,7 +37,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
return (
onTabChange(v as SettingsTab)}>
-
+
{tabs.map(({ value, label, icon: Icon }) => (
diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts
index 15a05a5f..66315458 100644
--- a/ui/src/pages/settings/hooks/use-settings-tab.ts
+++ b/ui/src/pages/settings/hooks/use-settings-tab.ts
@@ -11,19 +11,21 @@ export function useSettingsTab() {
// Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups)
const tabParam = searchParams.get('tab')?.toLowerCase();
const activeTab: SettingsTab =
- tabParam === 'channels'
- ? 'channels'
- : tabParam === 'globalenv'
- ? 'globalenv'
- : tabParam === 'proxy'
- ? 'proxy'
- : tabParam === 'auth'
- ? 'auth'
- : tabParam === 'thinking'
- ? 'thinking'
- : tabParam === 'backups'
- ? 'backups'
- : 'websearch';
+ tabParam === 'imageanalysis'
+ ? 'imageanalysis'
+ : tabParam === 'channels'
+ ? 'channels'
+ : tabParam === 'globalenv'
+ ? 'globalenv'
+ : tabParam === 'proxy'
+ ? 'proxy'
+ : tabParam === 'auth'
+ ? 'auth'
+ : tabParam === 'thinking'
+ ? 'thinking'
+ : tabParam === 'backups'
+ ? 'backups'
+ : 'websearch';
const setActiveTab = useCallback(
(tab: SettingsTab) => {
diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx
index 5d1bf4a1..fbc63798 100644
--- a/ui/src/pages/settings/index.tsx
+++ b/ui/src/pages/settings/index.tsx
@@ -48,6 +48,7 @@ function lazyWithRetry>(importFn: () => Promise
// Lazy-loaded sections with retry capability
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
+const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis'));
const ChannelsSection = lazyWithRetry(() => import('./sections/channels'));
const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section'));
const ThinkingSection = lazyWithRetry(() => import('./sections/thinking'));
@@ -131,6 +132,7 @@ function SettingsPageInner() {
}>
{activeTab === 'websearch' && }
+ {activeTab === 'imageanalysis' && }
{activeTab === 'channels' && }
{activeTab === 'globalenv' && }
{activeTab === 'thinking' && }
@@ -155,6 +157,7 @@ function SettingsPageInner() {
}>
{activeTab === 'websearch' && }
+ {activeTab === 'imageanalysis' && }
{activeTab === 'channels' && }
{activeTab === 'globalenv' && }
{activeTab === 'thinking' && }
diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx
new file mode 100644
index 00000000..acb7796b
--- /dev/null
+++ b/ui/src/pages/settings/sections/image-analysis/index.tsx
@@ -0,0 +1,693 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ AlertCircle,
+ CheckCircle2,
+ Image as ImageIcon,
+ Plus,
+ RefreshCw,
+ Save,
+ Trash2,
+} from 'lucide-react';
+import { api, type ImageAnalysisDashboardData } from '@/lib/api-client';
+import { cn } from '@/lib/utils';
+import { useRawConfig } from '../../hooks';
+
+interface MappingDraft {
+ id: string;
+ profileName: string;
+ backendId: string;
+}
+
+const NO_BACKEND = '__no_backend__';
+
+function isStringRecord(value: unknown): value is Record {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.values(value).every((entry) => typeof entry === 'string')
+ );
+}
+
+function isImageAnalysisDashboardData(value: unknown): value is ImageAnalysisDashboardData {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false;
+ }
+
+ const candidate = value as Partial;
+ return (
+ !!candidate.config &&
+ typeof candidate.config.enabled === 'boolean' &&
+ typeof candidate.config.timeout === 'number' &&
+ isStringRecord(candidate.config.providerModels) &&
+ (candidate.config.fallbackBackend === null ||
+ typeof candidate.config.fallbackBackend === 'string') &&
+ isStringRecord(candidate.config.profileBackends) &&
+ !!candidate.summary &&
+ typeof candidate.summary.state === 'string' &&
+ typeof candidate.summary.title === 'string' &&
+ typeof candidate.summary.detail === 'string' &&
+ Array.isArray(candidate.backends) &&
+ Array.isArray(candidate.profiles) &&
+ !!candidate.catalog &&
+ Array.isArray(candidate.catalog.knownBackends) &&
+ Array.isArray(candidate.catalog.profileNames)
+ );
+}
+
+function toMappingDrafts(profileBackends: Record): MappingDraft[] {
+ return Object.entries(profileBackends)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([profileName, backendId], index) => ({
+ id: `${profileName}-${backendId}-${index}`,
+ profileName,
+ backendId,
+ }));
+}
+
+function summaryToneClass(state: ImageAnalysisDashboardData['summary']['state']): string {
+ switch (state) {
+ case 'ready':
+ return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200';
+ case 'partial':
+ return 'border-amber-500/25 bg-amber-500/10 text-amber-900 dark:text-amber-200';
+ case 'needs_setup':
+ return 'border-rose-500/25 bg-rose-500/10 text-rose-900 dark:text-rose-200';
+ case 'disabled':
+ return 'border-border/80 bg-background/85 text-muted-foreground';
+ }
+}
+
+function backendStateClass(state: ImageAnalysisDashboardData['backends'][number]['state']): string {
+ switch (state) {
+ case 'ready':
+ return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200';
+ case 'starts_on_launch':
+ return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200';
+ case 'needs_auth':
+ return 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200';
+ case 'needs_proxy':
+ return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200';
+ case 'review':
+ return 'border-border/80 bg-background/85 text-muted-foreground';
+ }
+}
+
+function currentTargetModeLabel(
+ mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode']
+): string {
+ switch (mode) {
+ case 'active':
+ return 'Active';
+ case 'bypassed':
+ return 'Bypassed';
+ case 'fallback':
+ return 'Native fallback';
+ case 'setup':
+ return 'Needs setup';
+ case 'disabled':
+ return 'Disabled';
+ case 'unresolved':
+ return 'Native only';
+ }
+}
+
+function currentTargetModeClass(
+ mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode']
+): string {
+ switch (mode) {
+ case 'active':
+ return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200';
+ case 'bypassed':
+ return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200';
+ case 'fallback':
+ case 'setup':
+ return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200';
+ case 'disabled':
+ case 'unresolved':
+ return 'border-border/80 bg-background/85 text-muted-foreground';
+ }
+}
+
+export default function ImageAnalysisSection() {
+ const { fetchRawConfig } = useRawConfig();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ const [enabled, setEnabled] = useState(true);
+ const [timeout, setTimeout] = useState('60');
+ const [fallbackBackend, setFallbackBackend] = useState('');
+ const [providerModels, setProviderModels] = useState>({});
+ const [mappingDrafts, setMappingDrafts] = useState([]);
+
+ const hydrateDraft = useCallback((nextData: ImageAnalysisDashboardData) => {
+ setEnabled(nextData.config.enabled);
+ setTimeout(String(nextData.config.timeout));
+ setFallbackBackend(nextData.config.fallbackBackend ?? '');
+ setProviderModels(
+ nextData.catalog.knownBackends.reduce(
+ (acc, backendId) => {
+ acc[backendId] = nextData.config.providerModels[backendId] ?? '';
+ return acc;
+ },
+ {} as Record
+ )
+ );
+ setMappingDrafts(toMappingDrafts(nextData.config.profileBackends));
+ }, []);
+
+ const fetchData = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const payload = await api.imageAnalysis.get();
+ if (!isImageAnalysisDashboardData(payload)) {
+ throw new Error(
+ 'Image Analysis settings returned an unexpected response. Restart the dashboard server so the new API route is available.'
+ );
+ }
+ setData(payload);
+ hydrateDraft(payload);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load Image Analysis settings.');
+ } finally {
+ setLoading(false);
+ }
+ }, [hydrateDraft]);
+
+ useEffect(() => {
+ void fetchData();
+ void fetchRawConfig();
+ }, [fetchData, fetchRawConfig]);
+
+ useEffect(() => {
+ if (!success) return;
+ const timer = window.setTimeout(() => setSuccess(null), 2500);
+ return () => window.clearTimeout(timer);
+ }, [success]);
+
+ const configuredBackendIds = useMemo(
+ () =>
+ Object.entries(providerModels)
+ .filter(([, model]) => model.trim().length > 0)
+ .map(([backendId]) => backendId),
+ [providerModels]
+ );
+
+ useEffect(() => {
+ if (configuredBackendIds.length === 0) {
+ setFallbackBackend('');
+ return;
+ }
+ if (!configuredBackendIds.includes(fallbackBackend)) {
+ setFallbackBackend(configuredBackendIds[0]);
+ }
+ }, [configuredBackendIds, fallbackBackend]);
+
+ const payloadPreview = useMemo(() => {
+ const nextProviderModels = Object.entries(providerModels).reduce(
+ (acc, [backendId, model]) => {
+ const normalizedModel = typeof model === 'string' ? model.trim() : '';
+ acc[backendId] = normalizedModel || null;
+ return acc;
+ },
+ {} as Record
+ );
+
+ const nextProfileBackends = mappingDrafts.reduce(
+ (acc, row) => {
+ const profileName = row.profileName.trim();
+ if (!profileName || !row.backendId) {
+ return acc;
+ }
+ acc[profileName] = row.backendId;
+ return acc;
+ },
+ {} as Record
+ );
+
+ return {
+ enabled,
+ timeout,
+ fallbackBackend,
+ providerModels: nextProviderModels,
+ profileBackends: nextProfileBackends,
+ };
+ }, [enabled, fallbackBackend, mappingDrafts, providerModels, timeout]);
+
+ const hasChanges = useMemo(() => {
+ if (!data) return false;
+ return (
+ JSON.stringify(payloadPreview) !==
+ JSON.stringify({
+ enabled: data.config.enabled,
+ timeout: String(data.config.timeout),
+ fallbackBackend: data.config.fallbackBackend ?? '',
+ providerModels: data.catalog.knownBackends.reduce(
+ (acc, backendId) => {
+ acc[backendId] = data.config.providerModels[backendId] ?? null;
+ return acc;
+ },
+ {} as Record
+ ),
+ profileBackends: data.config.profileBackends,
+ })
+ );
+ }, [data, payloadPreview]);
+
+ const canSave = configuredBackendIds.length > 0 && Number.isInteger(Number(timeout));
+
+ const handleRefresh = async () => {
+ if (loading || saving) return;
+ setSuccess(null);
+ await Promise.all([fetchData(), fetchRawConfig()]);
+ };
+
+ const handleSave = async () => {
+ if (!data) return;
+ const parsedTimeout = Number.parseInt(timeout, 10);
+
+ if (!Number.isInteger(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 600) {
+ setError('Timeout must be an integer between 10 and 600 seconds.');
+ return;
+ }
+
+ if (configuredBackendIds.length === 0) {
+ setError('Keep at least one provider model configured, or disable Image Analysis globally.');
+ return;
+ }
+
+ try {
+ setSaving(true);
+ setError(null);
+ const payload = await api.imageAnalysis.update({
+ enabled,
+ timeout: parsedTimeout,
+ fallbackBackend: fallbackBackend || null,
+ providerModels: payloadPreview.providerModels,
+ profileBackends: payloadPreview.profileBackends,
+ });
+ setData(payload);
+ hydrateDraft(payload);
+ setSuccess('Image Analysis settings saved.');
+ await fetchRawConfig();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save Image Analysis settings.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ Loading Image Analysis settings...
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+
+
+ {error ?? 'Failed to load Image Analysis settings.'}
+
+
+
+
+ Retry
+
+
+
+ );
+ }
+
+ return (
+
+
+ {(error || success) && (
+
+ {error && (
+
+
+ {error}
+
+ )}
+ {success && (
+
+
+ {success}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
Image Analysis
+
+ {data.summary.title}
+
+
+
{data.summary.detail}
+
+
+
+
+
+ Refresh
+
+
+
+ Save changes
+
+
+
+
+
+
+
+ Enabled
+
+
+ {enabled ? 'On' : 'Off'}
+
+
+
+
+
+ Timeout
+
+
+ setTimeout(event.target.value)}
+ inputMode="numeric"
+ className="h-9"
+ />
+ sec
+
+
+
+
+ Fallback backend
+
+
+ setFallbackBackend(value === NO_BACKEND ? '' : value)}
+ >
+
+
+
+
+ {configuredBackendIds.length === 0 ? (
+ Configure a model first
+ ) : (
+ configuredBackendIds.map((backendId) => (
+
+ {backendId}
+
+ ))
+ )}
+
+
+
+
+
+
+ Coverage
+
+
+ {data.summary.backendCount} backends
+ {data.summary.mappedProfileCount} mapped
+ {data.summary.bypassedProfileCount} bypassed
+
+
+
+
+
+
+
+
+
Provider models
+
+ One model per backend. Clear a model to remove that backend from Image Analysis.
+
+
+
+
+
+ {data.catalog.knownBackends.map((backendId) => {
+ const backendStatus = data.backends.find((item) => item.backendId === backendId);
+ const displayName = backendStatus?.displayName || backendId;
+
+ return (
+
+
+
+
{displayName}
+
{backendId}
+
+ {backendStatus ? (
+
+ {backendStatus.state === 'starts_on_launch'
+ ? 'Starts on launch'
+ : backendStatus.state === 'needs_auth'
+ ? 'Needs auth'
+ : backendStatus.state === 'needs_proxy'
+ ? 'Needs proxy'
+ : backendStatus.state === 'review'
+ ? 'Review'
+ : 'Ready'}
+
+ ) : (
+
Inactive
+ )}
+
+
+
+ setProviderModels((current) => ({
+ ...current,
+ [backendId]: event.target.value,
+ }))
+ }
+ />
+
+
+
+ Auth:{' '}
+ {backendStatus?.authReadiness === 'ready'
+ ? 'ready'
+ : backendStatus?.authReadiness === 'missing'
+ ? 'missing'
+ : 'n/a'}
+
+
+ Proxy:{' '}
+ {backendStatus?.proxyReadiness === 'remote'
+ ? 'remote'
+ : backendStatus?.proxyReadiness === 'stopped'
+ ? 'idle'
+ : (backendStatus?.proxyReadiness ?? 'n/a')}
+
+ {backendStatus?.profilesUsing ?? 0} profiles use this backend
+
+
+ );
+ })}
+
+
+
+
+
+
+
Profile mappings
+
+ Use explicit mappings only when a profile should bypass the normal backend
+ resolution.
+
+
+
+ setMappingDrafts((current) => [
+ ...current,
+ {
+ id: `mapping-${Date.now()}`,
+ profileName: '',
+ backendId: configuredBackendIds[0] ?? '',
+ },
+ ])
+ }
+ disabled={configuredBackendIds.length === 0}
+ >
+
+ Add mapping
+
+
+
+
+ {data.catalog.profileNames.map((profileName) => (
+
+ ))}
+
+
+
+ {mappingDrafts.length === 0 ? (
+
+ No explicit profile mappings saved. Profiles follow provider and fallback
+ resolution.
+
+ ) : (
+ mappingDrafts.map((row) => (
+
+
+ setMappingDrafts((current) =>
+ current.map((entry) =>
+ entry.id === row.id
+ ? { ...entry, profileName: event.target.value }
+ : entry
+ )
+ )
+ }
+ />
+
+ setMappingDrafts((current) =>
+ current.map((entry) =>
+ entry.id === row.id
+ ? { ...entry, backendId: value === NO_BACKEND ? '' : value }
+ : entry
+ )
+ )
+ }
+ >
+
+
+
+
+ {configuredBackendIds.length === 0 ? (
+ Configure a model first
+ ) : (
+ configuredBackendIds.map((backendId) => (
+
+ {backendId}
+
+ ))
+ )}
+
+
+
+ setMappingDrafts((current) => current.filter((entry) => entry.id !== row.id))
+ }
+ >
+
+
+
+ ))
+ )}
+
+
+
+
+
+
Profile coverage
+
+ Quick read-only view of which saved profiles can use Image Analysis on their current
+ target.
+
+
+
+
+
+
+ Profile
+ Target
+ Backend
+ Current path
+
+
+ {data.profiles.map((profile) => (
+
+
+
{profile.name}
+
+ {profile.kind === 'variant' ? 'CLIProxy variant' : 'Settings profile'}
+
+
+
{profile.target}
+
+
+ {profile.backendDisplayName || 'Native file access'}
+
+
+ {profile.resolutionSource.replace(/-/g, ' ')}
+
+
+
+
+ {currentTargetModeLabel(profile.currentTargetMode)}
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts
index de75c527..3396e3ce 100644
--- a/ui/src/pages/settings/types.ts
+++ b/ui/src/pages/settings/types.ts
@@ -161,6 +161,7 @@ export interface OfficialChannelsStatus {
export type SettingsTab =
| 'websearch'
+ | 'imageanalysis'
| 'channels'
| 'globalenv'
| 'proxy'
diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
index 4958cc72..7a33f808 100644
--- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
+++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx
@@ -77,158 +77,38 @@ describe('ImageAnalysisStatusSection', () => {
vi.restoreAllMocks();
});
- it('renders saved diagnostics for an active backend', () => {
+ it('renders a compact saved summary with a settings link', () => {
render( );
expect(screen.getByText('Image Analysis')).toBeInTheDocument();
- expect(
- screen.getByText(
- /Saved runtime status for this profile\. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime\./i
- )
- ).toBeInTheDocument();
expect(screen.getByText('Saved')).toBeInTheDocument();
- expect(screen.getByText('CLIProxy Active')).toBeInTheDocument();
+ expect(screen.getByText('CLIProxy active')).toBeInTheDocument();
expect(
- screen.getByText(
- /Images and PDFs for this profile resolve through Google Gemini via CLIProxy\./i
- )
+ screen.getByText(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i)
).toBeInTheDocument();
- expect(screen.getByText('Configured backend')).toBeInTheDocument();
- expect(screen.getByText('Effective runtime')).toBeInTheDocument();
- expect(screen.getByText('Hook persistence')).toBeInTheDocument();
- expect(screen.getByText('Google Gemini')).toBeInTheDocument();
- expect(screen.getByText('CLIProxy image analysis')).toBeInTheDocument();
- expect(screen.getByText('Hook saved to profile')).toBeInTheDocument();
- expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
- expect(screen.getAllByText('Google Gemini ready')).toHaveLength(1);
- expect(screen.getByText('Local CLIProxy ready')).toBeInTheDocument();
- expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument();
- });
-
- it('renders mapped status and the explicit mapping explanation', () => {
- render(
-
+ expect(screen.getByText('Backend')).toBeInTheDocument();
+ expect(screen.getByText('Current target')).toBeInTheDocument();
+ expect(screen.getByText('Persistence')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
+ 'href',
+ '/settings?tab=imageanalysis'
);
-
- expect(screen.getByText('CLIProxy Active')).toBeInTheDocument();
- expect(
- screen.getByText(
- /Images and PDFs for this profile resolve through GitHub Copilot \(OAuth\) via a saved Image Analysis mapping\./i
- )
- ).toBeInTheDocument();
- expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
- expect(screen.getByText('Saved Image Analysis mapping')).toBeInTheDocument();
- expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
});
- it('renders hook-missing state as native file access until the hook is installed', () => {
- render(
-
- );
+ it('shows bypassed mode when the current target is not Claude Code', () => {
+ render( );
- expect(screen.getByText('Setup needed')).toBeInTheDocument();
+ expect(screen.getByText('Bypassed')).toBeInTheDocument();
expect(
- screen.getByText(
- /Images and PDFs are configured for Google Gemini, but Profile hook is missing/i
- )
+ screen.getByText(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i)
+ ).toBeInTheDocument();
+ expect(screen.getByText('Codex CLI')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Current launch path bypasses the Claude Read hook/i)
).toBeInTheDocument();
- expect(screen.getByText('Native file access')).toBeInTheDocument();
- expect(screen.getByTitle(/native file access/i)).toBeInTheDocument();
});
- it('shows auth readiness gaps separately from backend resolution', () => {
- render(
-
- );
-
- expect(screen.getByText('Needs auth')).toBeInTheDocument();
- expect(
- screen.getByText(/This profile currently falls back to native file access\./i)
- ).toBeInTheDocument();
- expect(screen.getByText('Native file access')).toBeInTheDocument();
- expect(
- screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
- ).toBeGreaterThanOrEqual(3);
- });
-
- it('treats an idle local proxy as launchable instead of unavailable', () => {
- render(
-
- );
-
- expect(screen.getByText('Starts on launch')).toBeInTheDocument();
- expect(
- screen.getByText(
- /Images and PDFs for this profile resolve through Google Gemini\. Auth is ready and CCS will start the local CLIProxy runtime when needed\./i
- )
- ).toBeInTheDocument();
- expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument();
- expect(screen.getByText('Auth ready • Local CLIProxy starts on demand')).toBeInTheDocument();
- expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument();
- });
-
- it('shows saved Claude-side diagnostics when the current target bypasses the hook', () => {
- render( );
-
- expect(screen.getByText('Target bypassed')).toBeInTheDocument();
- expect(
- screen.getByText(
- /Images and PDFs for this profile are configured to resolve through Google Gemini when the profile runs on Claude Code\./i
- )
- ).toBeInTheDocument();
- expect(
- screen.getByText(
- /Current default target: Factory Droid\. The diagnostics below describe the saved Claude-side Image Analysis hook/i
- )
- ).toBeInTheDocument();
- expect(screen.getByText('Not active on Factory Droid')).toBeInTheDocument();
- expect(
- screen.getByText(
- /Factory Droid bypasses the Claude Read hook\. Switch the target back to Claude Code to use the saved backend shown here\./i
- )
- ).toBeInTheDocument();
- expect(screen.getByTitle(/Factory Droid launch -> no Claude Read hook/i)).toBeInTheDocument();
- });
-
- it('keeps saved Claude-side auth failures visible when another target bypasses the hook', () => {
+ it('keeps auth failures visible without a long diagnostic wall', () => {
render(
{
effectiveRuntimeReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
})}
- target="codex"
/>
);
expect(screen.getByText('Needs auth')).toBeInTheDocument();
expect(
- screen.getByText(
- /Current default target: Codex CLI\. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for GitHub Copilot \(OAuth\) still falls back to native file access\./i
- )
+ screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i)
).toBeInTheDocument();
expect(
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
).toBeGreaterThanOrEqual(1);
});
- it('falls back to backend id when a display name is unavailable', () => {
- render(
-
- );
-
- expect(screen.getByText('ghcp')).toBeInTheDocument();
- });
-
- it('switches the panel to a live preview when the current editor JSON changes', async () => {
+ it('switches to live preview when editor JSON changes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -300,7 +164,6 @@ describe('ImageAnalysisStatusSection', () => {
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
- runtimePath: '/api/provider/ghcp',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
@@ -334,13 +197,10 @@ describe('ImageAnalysisStatusSection', () => {
});
await waitFor(() => {
- expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
+ expect(screen.getByText('Live Preview')).toBeInTheDocument();
});
- expect(
- screen.getByText(
- /Live preview from the current editor state\. Save to persist config changes; auth and proxy readiness stay derived below\./i
- )
- ).toBeInTheDocument();
+ expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
+ expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument();
});
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
@@ -380,15 +240,12 @@ describe('ImageAnalysisStatusSection', () => {
await waitFor(() => {
expect(
- screen.getByText(
- /Showing last saved runtime status\. The live preview resumes when the JSON above is valid again\./i
- )
+ screen.getByText(/Showing saved status until the JSON above is valid again/i)
).toBeInTheDocument();
});
- expect(screen.getByText('Google Gemini')).toBeInTheDocument();
});
- it('marks the preview as refreshing when a newer editor preview is still loading', async () => {
+ it('marks the preview as refreshing when a newer preview is still loading', async () => {
let secondPreviewResolver: ((value: Response) => void) | null = null;
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -424,7 +281,6 @@ describe('ImageAnalysisStatusSection', () => {
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
- runtimePath: '/api/provider/ghcp',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
@@ -484,9 +340,7 @@ describe('ImageAnalysisStatusSection', () => {
await waitFor(() => {
expect(screen.getByText('Refreshing')).toBeInTheDocument();
});
- expect(
- screen.getByText(/Refreshing the live preview from the current editor state\./i)
- ).toBeInTheDocument();
+ expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument();
secondPreviewResolver?.(
createJsonResponse({
@@ -494,7 +348,6 @@ describe('ImageAnalysisStatusSection', () => {
backendId: 'codex',
backendDisplayName: 'Codex',
model: 'gpt-5.4',
- runtimePath: '/api/provider/codex',
authReadiness: 'ready',
authProvider: 'codex',
authDisplayName: 'Codex',
diff --git a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx
new file mode 100644
index 00000000..5b8a8eec
--- /dev/null
+++ b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx
@@ -0,0 +1,217 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
+import ImageAnalysisSection from '@/pages/settings/sections/image-analysis';
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+describe('ImageAnalysisSection', () => {
+ let fetchMock: ReturnType;
+
+ beforeEach(() => {
+ let payload = {
+ config: {
+ enabled: true,
+ timeout: 60,
+ providerModels: {
+ gemini: 'gemini-2.5-flash',
+ ghcp: 'claude-haiku-4.5',
+ },
+ fallbackBackend: 'gemini',
+ profileBackends: {
+ codexProfile: 'ghcp',
+ },
+ },
+ summary: {
+ state: 'partial',
+ title: 'Partially ready',
+ detail:
+ '1 backend still needs auth, runtime, or review before every profile path is healthy.',
+ backendCount: 2,
+ mappedProfileCount: 1,
+ activeProfileCount: 1,
+ bypassedProfileCount: 1,
+ },
+ backends: [
+ {
+ backendId: 'gemini',
+ displayName: 'Google Gemini',
+ model: 'gemini-2.5-flash',
+ state: 'ready',
+ authReadiness: 'ready',
+ authReason: null,
+ proxyReadiness: 'ready',
+ proxyReason: null,
+ profilesUsing: 1,
+ },
+ {
+ backendId: 'ghcp',
+ displayName: 'GitHub Copilot (OAuth)',
+ model: 'claude-haiku-4.5',
+ state: 'needs_auth',
+ authReadiness: 'missing',
+ authReason: 'Run ccs ghcp --auth',
+ proxyReadiness: 'ready',
+ proxyReason: null,
+ profilesUsing: 1,
+ },
+ ],
+ profiles: [
+ {
+ name: 'glm',
+ kind: 'profile',
+ target: 'claude',
+ configured: true,
+ settingsPath: '/tmp/glm.settings.json',
+ backendId: 'gemini',
+ backendDisplayName: 'Google Gemini',
+ resolutionSource: 'cliproxy-bridge',
+ status: 'active',
+ effectiveRuntimeMode: 'cliproxy-image-analysis',
+ effectiveRuntimeReason: null,
+ currentTargetMode: 'active',
+ },
+ {
+ name: 'codexProfile',
+ kind: 'profile',
+ target: 'codex',
+ configured: true,
+ settingsPath: '/tmp/codex.settings.json',
+ backendId: 'ghcp',
+ backendDisplayName: 'GitHub Copilot (OAuth)',
+ resolutionSource: 'profile-backend',
+ status: 'mapped',
+ effectiveRuntimeMode: 'cliproxy-image-analysis',
+ effectiveRuntimeReason: null,
+ currentTargetMode: 'bypassed',
+ },
+ ],
+ catalog: {
+ knownBackends: ['gemini', 'ghcp', 'codex'],
+ profileNames: ['glm', 'codexProfile'],
+ },
+ };
+
+ fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ const method = init?.method ?? 'GET';
+
+ if (url === '/api/image-analysis' && method === 'GET') {
+ return jsonResponse(payload);
+ }
+
+ if (url === '/api/config/raw' && method === 'GET') {
+ return new Response('image_analysis:\n enabled: true\n');
+ }
+
+ if (url === '/api/image-analysis' && method === 'PUT') {
+ const body = JSON.parse(String(init?.body ?? '{}')) as {
+ timeout?: number;
+ fallbackBackend?: string;
+ profileBackends?: Record;
+ providerModels?: Record;
+ };
+
+ payload = {
+ ...payload,
+ config: {
+ enabled: true,
+ timeout: body.timeout ?? payload.config.timeout,
+ fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend,
+ providerModels: {
+ gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini),
+ ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp),
+ },
+ profileBackends: body.profileBackends ?? payload.config.profileBackends,
+ },
+ };
+
+ return jsonResponse(payload);
+ }
+
+ return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500);
+ });
+
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('renders global controls and saves updated config', async () => {
+ render( , { withSettingsProvider: true });
+
+ expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
+ expect(screen.getByText('Partially ready')).toBeInTheDocument();
+ expect(screen.getByText('Provider models')).toBeInTheDocument();
+ expect(screen.getByText('Profile mappings')).toBeInTheDocument();
+ expect(screen.getByText('Profile coverage')).toBeInTheDocument();
+ expect(screen.getByText('Bypassed')).toBeInTheDocument();
+
+ const timeoutInput = screen.getByDisplayValue('60');
+ await userEvent.clear(timeoutInput);
+ await userEvent.type(timeoutInput, '120');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/image-analysis',
+ expect.objectContaining({
+ method: 'PUT',
+ })
+ );
+ });
+
+ const putCall = fetchMock.mock.calls.find(
+ ([url, init]) =>
+ url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
+ );
+ expect(putCall).toBeDefined();
+
+ const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
+ expect(requestBody).toMatchObject({
+ timeout: 120,
+ fallbackBackend: 'gemini',
+ profileBackends: {
+ codexProfile: 'ghcp',
+ },
+ });
+
+ expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument();
+ });
+
+ it('surfaces a clear retryable error when the backend route is not available yet', async () => {
+ fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.toString();
+ const method = init?.method ?? 'GET';
+
+ if (url === '/api/image-analysis' && method === 'GET') {
+ return new Response('', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=UTF-8' },
+ });
+ }
+
+ if (url === '/api/config/raw' && method === 'GET') {
+ return new Response('image_analysis:\n enabled: true\n');
+ }
+
+ return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500);
+ });
+
+ render( , { withSettingsProvider: true });
+
+ expect(
+ await screen.findByText(
+ /Image Analysis settings returned an unexpected response\. Restart the dashboard server/i
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
+ });
+});