mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(ui): clarify image-analysis target status
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user