From 0246e327feea99f2c2f5e089caa05faddabeb7fb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 01:59:22 -0400 Subject: [PATCH] feat(ui): clarify image-analysis target status --- .../components/cliproxy/cliproxy-dialog.tsx | 6 +- .../cliproxy/cliproxy-edit-dialog.tsx | 6 +- .../profiles/editor/header-section.tsx | 1 + .../editor/image-analysis-status-section.tsx | 310 ++++++++++++++---- ui/src/components/profiles/editor/index.tsx | 8 +- .../profiles/editor/raw-editor-section.tsx | 5 +- .../profiles/profile-create-dialog.tsx | 14 +- ui/src/lib/api-client.ts | 2 +- ui/src/lib/support-updates-catalog.ts | 4 +- .../image-analysis-status-section.test.tsx | 223 ++++++++++++- 10 files changed, 495 insertions(+), 84 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 4af6f2bd..69f9a0e9 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -30,7 +30,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); const compositeSchema = z.object({ @@ -39,7 +39,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -249,6 +249,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { > + @@ -353,6 +354,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { > + diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 07d3b430..32c0e229 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -23,12 +23,12 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); const compositeSchema = z.object({ default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -375,6 +375,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit > + @@ -433,6 +434,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit > + diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 637a93f3..0ed87e02 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -85,6 +85,7 @@ export function HeaderSection({ Claude Code Factory Droid + Codex CLI {isTargetSaving && } 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 daaa238e..27bab3c2 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -1,16 +1,64 @@ import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; -import type { ImageAnalysisStatus } from '@/lib/api-client'; +import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; interface ImageAnalysisStatusSectionProps { status?: ImageAnalysisStatus | null; + target?: CliTarget; source?: 'saved' | 'editor'; previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; } -function getBadge(status: ImageAnalysisStatus | null | undefined) { +const RESOLUTION_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', + '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', + codex: 'Codex CLI', +}; + +function getPreviewBadge( + source: 'saved' | 'editor', + previewState: ImageAnalysisStatusSectionProps['previewState'] +) { + 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 }; @@ -25,10 +73,7 @@ function getBadge(status: ImageAnalysisStatus | null | undefined) { return { label: 'Starts on launch', variant: 'secondary' as const }; } if (status.effectiveRuntimeMode === 'cliproxy-image-analysis') { - return { - label: status.resolutionSource === 'profile-backend' ? 'Ready via mapping' : 'Ready', - variant: 'default' as const, - }; + return { label: 'CLIProxy Active', variant: 'default' as const }; } if ( status.authReadiness === 'unknown' || @@ -37,47 +82,72 @@ function getBadge(status: ImageAnalysisStatus | null | undefined) { ) { return { label: 'Needs review', variant: 'outline' as const }; } - if (status.status === 'skipped' && status.reason?.includes('native file access')) { - return { label: 'Native Claude', variant: 'secondary' as const }; - } - return { label: 'Native Read', variant: 'outline' as const }; + return { label: 'Native Access', variant: 'outline' as const }; } -function getSummary(status: ImageAnalysisStatus): string { +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.`; + } if (status.status === 'disabled') { - return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; + return 'Image Analysis is disabled globally, so images and PDFs use built-in file access for this profile.'; } if (!status.backendId) { - return status.reason || "This profile uses Claude's built-in file reading."; + return status.reason || 'This profile currently uses built-in file access for images and PDFs.'; } if (status.status === 'hook-missing') { - return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`; + return `Images and PDFs are configured for ${backendName}, but ${status.reason || 'the profile hook is not fully installed yet.'}`; } if (status.effectiveRuntimeMode === 'native-read') { - return `Configured via ${backendName}, but ${status.effectiveRuntimeReason || status.reason || 'runtime readiness could not be confirmed.'}`; + return `This profile currently falls back to native file access. ${status.effectiveRuntimeReason || status.reason || `Image Analysis via ${backendName} could not be confirmed.`}`; } if (status.proxyReadiness === 'stopped') { - return `Configured via ${backendName}. Auth is ready and CCS will start the local CLIProxy runtime on launch, so image and PDF reads still use CLIProxy.`; + 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.resolutionSource === 'profile-backend') { - return `Configured via saved ${backendName} mapping. Auth and runtime are ready, so image and PDF reads use CLIProxy.`; + return `Images and PDFs for this profile resolve through ${backendName} via a saved Image Analysis mapping.`; } if (status.status === 'attention' && status.reason) { - return `Configured via ${backendName}. Image and PDF reads use CLIProxy, but ${status.reason}`; + return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy, but ${status.reason}`; } - return `Configured via ${backendName}. Image and PDF reads use CLIProxy for this profile.`; + return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy.`; } -function getRuntimeLine(status: ImageAnalysisStatus): string { +function getRuntimeLine(status: ImageAnalysisStatus, target: CliTarget): string { + if (target !== 'claude') { + return `${TARGET_LABELS[target]} launch -> no Claude Read hook`; + } + if (status.effectiveRuntimeMode === 'native-read') { return 'Read -> native file access'; } @@ -93,6 +163,69 @@ function getRuntimeLine(status: ImageAnalysisStatus): string { 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') @@ -124,16 +257,25 @@ function getStatusContext( return 'Saved runtime status for this profile. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime.'; } -function getPersistenceLine(status: ImageAnalysisStatus): string { - if (!status.shouldPersistHook || !status.persistencePath) - return 'Not persisted for this profile type'; - return status.hookInstalled - ? `${status.persistencePath} hook` - : `${status.persistencePath} hook missing`; +function getPersistenceValue(status: ImageAnalysisStatus): string { + if (!status.shouldPersistHook || !status.persistencePath) { + return 'Not persisted'; + } + + return status.hookInstalled ? 'Hook saved to profile' : 'Hook missing from profile'; +} + +function getPersistenceDetail(status: ImageAnalysisStatus): string { + if (!status.shouldPersistHook || !status.persistencePath) { + return 'Not required for this profile type'; + } + + return status.persistencePath; } export function ImageAnalysisStatusSection({ status, + target = 'claude', source = 'saved', previewState = 'saved', }: ImageAnalysisStatusSectionProps) { @@ -147,9 +289,12 @@ export function ImageAnalysisStatusSection({ ); } - const badge = getBadge(status); - const notice = - status.effectiveRuntimeMode === 'native-read' + 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 @@ -157,43 +302,82 @@ export function ImageAnalysisStatusSection({ return (
-
+
-

Image-analysis backend

+

Image Analysis

{getStatusContext(source, previewState)}

- - {badge.label} - +
+ + {previewBadge.label} + + + {runtimeBadge.label} + +

- {getSummary(status)} + {getSummary(status, target)}

-
-
-
- Backend -
-
{status.backendDisplayName || 'Unresolved'}
+ {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. +
-
-
- Runtime -
-
+
+
+ Configured backend +
+
+ {status.backendDisplayName || status.backendId || 'No backend mapped'} +
+

+ {getConfiguredBackendDetail(status)} +

+
+ +
+
+ Effective runtime +
+
+ {getEffectiveRuntimeValue(status, target)} +
+

+ {getEffectiveRuntimeDetail(status, target)} +

+
+ +
+
+ Hook persistence +
+
+ {getPersistenceValue(status)} +
+

- {getRuntimeLine(status)} -

+ {getPersistenceDetail(status)} +

+
+ +
Auth @@ -206,17 +390,6 @@ export function ImageAnalysisStatusSection({
{getProxyLine(status)}
-
-
- Persistence -
-
- {getPersistenceLine(status)} -
-
Model @@ -227,6 +400,18 @@ export function ImageAnalysisStatusSection({
+
+
+ Runtime path +
+
+ {getRuntimeLine(status, target)} +
+
+ {notice && (
@@ -237,7 +422,8 @@ export function ImageAnalysisStatusSection({
- WebSearch stays managed separately and is not controlled by this backend status. + This panel covers the Image Analysis hook only. WebSearch stays managed separately and is + not controlled here.
diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index bb4242a2..c633ad81 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -124,6 +124,7 @@ export function ProfileEditor({ data: previewStatusResponse, isFetching: isPreviewStatusFetching, isError: isPreviewStatusError, + isPlaceholderData: isPreviewStatusPlaceholderData, } = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({ queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson], enabled: previewSettings !== null, @@ -160,7 +161,8 @@ export function ProfileEditor({ ? 'invalid' : isPreviewStatusError ? 'saved' - : isPreviewStatusFetching && !previewStatusResponse?.imageAnalysisStatus + : isPreviewStatusFetching && + (!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData) ? 'refreshing' : 'preview'; @@ -219,7 +221,8 @@ export function ProfileEditor({ toast.success(i18n.t('commonToast.defaultTargetUpdated')); }, onError: (error: Error, target: CliTarget) => { - const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code'; + const targetLabel = + target === 'droid' ? 'Factory Droid' : target === 'codex' ? 'Codex CLI' : 'Claude Code'; const suffix = error.message.trim() ? `: ${error.message}` : ''; toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix })); }, @@ -311,6 +314,7 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} + profileTarget={resolvedTarget} imageAnalysisStatus={imageAnalysisStatus} imageAnalysisStatusSource={imageAnalysisStatusSource} imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState} diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index 45902541..a331d605 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -8,7 +8,7 @@ import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; import { ImageAnalysisStatusSection } from './image-analysis-status-section'; import type { Settings } from './types'; -import type { ImageAnalysisStatus } from '@/lib/api-client'; +import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -20,6 +20,7 @@ interface RawEditorSectionProps { isRawJsonValid: boolean; rawJsonEdits: string | null; settings: Settings | undefined; + profileTarget?: CliTarget; imageAnalysisStatus?: ImageAnalysisStatus | null; imageAnalysisStatusSource?: 'saved' | 'editor'; imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; @@ -32,6 +33,7 @@ export function RawEditorSection({ isRawJsonValid, rawJsonEdits, settings, + profileTarget = 'claude', imageAnalysisStatus, imageAnalysisStatusSource = 'saved', imageAnalysisStatusPreviewState = 'saved', @@ -86,6 +88,7 @@ export function RawEditorSection({
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index ca256fe2..cdf8b449 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -80,7 +80,7 @@ const schema = z.object({ opusModel: z.string().optional(), sonnetModel: z.string().optional(), haikuModel: z.string().optional(), - target: z.enum(['claude', 'droid']), + target: z.enum(['claude', 'droid', 'codex']), }); type FormData = z.infer; @@ -521,6 +521,7 @@ export function ProfileCreateDialog({ Claude Code (default) Factory Droid + Codex CLI

@@ -529,6 +530,11 @@ export function ProfileCreateDialog({ {t('profileEditor.targetHintPreferredAlias')}{' '} ccs-droid. + ) : targetValue === 'codex' ? ( + <> + {t('profileEditor.targetHintPreferredAlias')}{' '} + ccsx. + ) : ( <> {t('profileEditor.targetHintClaudeDefault')}{' '} @@ -541,6 +547,12 @@ export function ProfileCreateDialog({ {t('profileEditor.targetHintLegacyAlias')}{' '} ccsd. + ) : targetValue === 'codex' ? ( + <> + {' '} + {t('profileEditor.targetHintLegacyAlias')}{' '} + ccs-codex. + ) : null}{' '} {t('profileEditor.targetHintOverride')}{' '} --target. diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e8159ef4..63f03870 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -99,7 +99,7 @@ async function request(url: string, options?: RequestInit): Promise { } // Types -export type CliTarget = 'claude' | 'droid'; +export type CliTarget = 'claude' | 'droid' | 'codex'; export interface CliproxyBridgeMetadata { provider: CLIProxyProvider; diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 33c48e2d..8d3cbd61 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -70,7 +70,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ 'Use ccs-codex or ccsx for native Codex runs.', 'Use ccsxp for the built-in CCS Codex provider shortcut on native Codex.', 'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.', - 'Saved default targets for API profiles and variants remain claude or droid.', + 'Saved default targets for API profiles and variants can now be claude, droid, or codex.', ], actions: [ { @@ -256,7 +256,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ routes: [{ label: 'Codex CLI', path: '/codex' }], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], notes: - 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', + 'Saved default targets for API profiles and CLIProxy variants can now be claude, droid, or codex.', }, { id: 'codex-cliproxy', 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 9a29cb7d..4958cc72 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 @@ -80,17 +80,25 @@ describe('ImageAnalysisStatusSection', () => { it('renders saved diagnostics for an active backend', () => { render(); - expect(screen.getByText('Image-analysis backend')).toBeInTheDocument(); + 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('Ready')).toBeInTheDocument(); + expect(screen.getByText('Saved')).toBeInTheDocument(); + expect(screen.getByText('CLIProxy Active')).toBeInTheDocument(); expect( - screen.getByText(/Configured via Google Gemini\. Image and PDF reads use CLIProxy/i) + screen.getByText( + /Images and PDFs for this profile resolve through Google Gemini via 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(); @@ -112,13 +120,14 @@ describe('ImageAnalysisStatusSection', () => { /> ); - expect(screen.getByText('Ready via mapping')).toBeInTheDocument(); + expect(screen.getByText('CLIProxy Active')).toBeInTheDocument(); expect( screen.getByText( - /Configured via saved GitHub Copilot \(OAuth\) mapping\. Auth and runtime are ready/i + /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(); }); @@ -136,8 +145,11 @@ describe('ImageAnalysisStatusSection', () => { expect(screen.getByText('Setup needed')).toBeInTheDocument(); expect( - screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i) + screen.getByText( + /Images and PDFs are configured for Google Gemini, but Profile hook is missing/i + ) ).toBeInTheDocument(); + expect(screen.getByText('Native file access')).toBeInTheDocument(); expect(screen.getByTitle(/native file access/i)).toBeInTheDocument(); }); @@ -163,11 +175,12 @@ describe('ImageAnalysisStatusSection', () => { expect(screen.getByText('Needs auth')).toBeInTheDocument(); expect( - screen.getByText( - /Configured via GitHub Copilot \(OAuth\), but GitHub Copilot \(OAuth\) auth is missing/i - ) + screen.getByText(/This profile currently falls back to native file access\./i) ).toBeInTheDocument(); - expect(screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i)).toHaveLength(3); + 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', () => { @@ -183,12 +196,81 @@ describe('ImageAnalysisStatusSection', () => { expect(screen.getByText('Starts on launch')).toBeInTheDocument(); expect( - screen.getByText(/Auth is ready and CCS will start the local CLIProxy runtime on launch/i) + 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', () => { + render( + + ); + + 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 + ) + ).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 () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -305,4 +387,123 @@ describe('ImageAnalysisStatusSection', () => { }); expect(screen.getByText('Google Gemini')).toBeInTheDocument(); }); + + it('marks the preview as refreshing when a newer editor 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); + + 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'); + const body = JSON.parse(String(init?.body ?? '{}')) as { + settings?: { env?: Record }; + }; + const baseUrl = body.settings?.env?.ANTHROPIC_BASE_URL ?? ''; + + if (baseUrl.includes('/ghcp')) { + return Promise.resolve( + createJsonResponse({ + imageAnalysisStatus: createStatus({ + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + runtimePath: '/api/provider/ghcp', + authReadiness: 'ready', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + }), + }) + ); + } + + if (baseUrl.includes('/codex')) { + return new Promise((resolve) => { + secondPreviewResolver = resolve; + }); + } + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + vi.stubGlobal('fetch', fetchMock); + + render(); + + 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 + ), + }, + }); + + expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('raw config editor'), { + target: { + value: JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'preview-token-2', + }, + }, + null, + 2 + ), + }, + }); + + await waitFor(() => { + expect(screen.getByText('Refreshing')).toBeInTheDocument(); + }); + expect( + screen.getByText(/Refreshing the live preview from the current editor state\./i) + ).toBeInTheDocument(); + + secondPreviewResolver?.( + createJsonResponse({ + imageAnalysisStatus: createStatus({ + backendId: 'codex', + backendDisplayName: 'Codex', + model: 'gpt-5.4', + runtimePath: '/api/provider/codex', + authReadiness: 'ready', + authProvider: 'codex', + authDisplayName: 'Codex', + }), + }) + ); + + await waitFor(() => { + expect(screen.getByText('Codex')).toBeInTheDocument(); + }); + }); });