feat(ui): clarify image-analysis target status

This commit is contained in:
Tam Nhu Tran
2026-04-01 15:32:37 -04:00
parent 3b61673ad2
commit 0246e327fe
10 changed files with 495 additions and 84 deletions
@@ -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) {
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -353,6 +354,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -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
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -433,6 +434,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
>
<option value="claude">{t('cliproxyDialog.claudeCode')}</option>
<option value="droid">{t('cliproxyDialog.factoryDroid')}</option>
<option value="codex">Codex CLI</option>
</select>
</div>
@@ -85,6 +85,7 @@ export function HeaderSection({
<SelectContent>
<SelectItem value="claude">Claude Code</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
</SelectContent>
</Select>
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
@@ -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<ImageAnalysisStatus['resolutionSource'], string> = {
'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<CliTarget, string> = {
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 (
<section className="rounded-md border bg-muted/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-sky-600" />
<h3 className="text-sm font-semibold">Image-analysis backend</h3>
<h3 className="text-sm font-semibold">Image Analysis</h3>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{getStatusContext(source, previewState)}
</p>
</div>
<Badge variant={badge.variant} className="shrink-0 text-[11px]">
{badge.label}
</Badge>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={previewBadge.variant} className="h-5 shrink-0 px-1.5 text-[10px]">
{previewBadge.label}
</Badge>
<Badge variant={runtimeBadge.variant} className="h-5 shrink-0 px-1.5 text-[10px]">
{runtimeBadge.label}
</Badge>
</div>
</div>
<p aria-live="polite" className="mt-3 text-sm leading-6 text-muted-foreground">
{getSummary(status)}
{getSummary(status, target)}
</p>
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Backend
</dt>
<dd className="text-sm font-medium">{status.backendDisplayName || 'Unresolved'}</dd>
{bypassedOnCurrentTarget && (
<div className="mt-4 flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-sm text-sky-900 dark:text-sky-200">
<Route className="mt-0.5 h-4 w-4 shrink-0 text-sky-600 dark:text-sky-300" />
<span>
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.
</span>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Runtime
</dt>
<dd
className="font-mono text-xs leading-5 text-foreground"
title={getRuntimeLine(status)}
)}
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Configured backend
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{status.backendDisplayName || status.backendId || 'No backend mapped'}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getConfiguredBackendDetail(status)}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Effective runtime
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{getEffectiveRuntimeValue(status, target)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getEffectiveRuntimeDetail(status, target)}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Hook persistence
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{getPersistenceValue(status)}
</div>
<p
className="mt-1 text-xs leading-5 text-muted-foreground"
title={status.persistencePath || 'Not persisted'}
>
{getRuntimeLine(status)}
</dd>
{getPersistenceDetail(status)}
</p>
</div>
</div>
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Auth
@@ -206,17 +390,6 @@ export function ImageAnalysisStatusSection({
</dt>
<dd className="text-sm text-foreground">{getProxyLine(status)}</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Persistence
</dt>
<dd
className="font-mono text-xs leading-5 text-foreground"
title={status.persistencePath || 'Not persisted'}
>
{getPersistenceLine(status)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Model
@@ -227,6 +400,18 @@ export function ImageAnalysisStatusSection({
</div>
</dl>
<div className="mt-4 rounded-md border bg-background/60 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Runtime path
</div>
<div
className="mt-1 font-mono text-xs leading-5 text-foreground"
title={getRuntimeLine(status, target)}
>
{getRuntimeLine(status, target)}
</div>
</div>
{notice && (
<div className="mt-4 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
@@ -237,7 +422,8 @@ export function ImageAnalysisStatusSection({
<div className="mt-4 flex items-center gap-2 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<Route className="h-3.5 w-3.5" />
<span>
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.
</span>
</div>
</section>
+6 -2
View File
@@ -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}
@@ -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({
<div className="mx-6 mb-4">
<ImageAnalysisStatusSection
status={imageAnalysisStatus}
target={profileTarget}
source={imageAnalysisStatusSource}
previewState={imageAnalysisStatusPreviewState}
/>
@@ -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<typeof schema>;
@@ -521,6 +521,7 @@ export function ProfileCreateDialog({
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
<SelectItem value="codex">Codex CLI</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -529,6 +530,11 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintPreferredAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs-droid</code>.
</>
) : targetValue === 'codex' ? (
<>
{t('profileEditor.targetHintPreferredAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccsx</code>.
</>
) : (
<>
{t('profileEditor.targetHintClaudeDefault')}{' '}
@@ -541,6 +547,12 @@ export function ProfileCreateDialog({
{t('profileEditor.targetHintLegacyAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccsd</code>.
</>
) : targetValue === 'codex' ? (
<>
{' '}
{t('profileEditor.targetHintLegacyAlias')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs-codex</code>.
</>
) : null}{' '}
{t('profileEditor.targetHintOverride')}{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
+1 -1
View File
@@ -99,7 +99,7 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
}
// Types
export type CliTarget = 'claude' | 'droid';
export type CliTarget = 'claude' | 'droid' | 'codex';
export interface CliproxyBridgeMetadata {
provider: CLIProxyProvider;
+2 -2
View File
@@ -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',
@@ -80,17 +80,25 @@ describe('ImageAnalysisStatusSection', () => {
it('renders saved diagnostics for an active backend', () => {
render(<ImageAnalysisStatusSection status={createStatus()} />);
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(<ImageAnalysisStatusSection status={createStatus()} target="droid" />);
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(
<ImageAnalysisStatusSection
status={createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
authReadiness: 'missing',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
effectiveRuntimeMode: 'native-read',
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
)
).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(
<ImageAnalysisStatusSection
status={createStatus({
backendDisplayName: null,
backendId: 'ghcp',
})}
/>
);
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<string, string> };
};
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<Response>((resolve) => {
secondPreviewResolver = resolve;
});
}
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
value: JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp',
ANTHROPIC_AUTH_TOKEN: 'preview-token',
},
},
null,
2
),
},
});
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();
});
});
});