feat(image): add native-read controls and autosave settings

- add per-profile native image preferences and native-capability detection
- redesign Settings -> Image around compact sections with Web-style autosave
- expose richer backend/profile status and update Gemini Flash defaults
This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent 9d1d281e34
commit 982ffc5895
30 files changed
+1893 -708

No files matched your search

@@ -1,7 +1,8 @@
import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react';
import { ArrowUpRight, Image as ImageIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client';
@@ -10,158 +11,122 @@ interface ImageAnalysisStatusSectionProps {
target?: CliTarget;
source?: 'saved' | 'editor';
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
nativeReadPreferenceOverride?: boolean;
onToggleNativeRead?: (enabled: boolean) => void;
}
const 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 route',
'profile-backend': 'Explicit profile mapping',
'fallback-backend': 'Fallback backend',
disabled: 'Disabled globally',
'unsupported-profile': 'Unsupported profile type',
unresolved: 'No backend mapped',
'missing-model': 'Missing model',
};
const TARGET_LABELS: Record<CliTarget, string> = {
claude: 'Claude Code',
droid: 'Factory Droid',
codex: 'Codex CLI',
};
function getPreviewBadge(
function getPreviewLabel(
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 };
if (previewState === 'refreshing') return 'Refreshing preview';
if (previewState === 'invalid') return 'Saved status';
return source === 'editor' ? 'Live preview' : 'Saved status';
}
function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) {
function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string {
if (status.status === 'disabled') return 'Disabled globally';
if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`;
if (status.nativeReadPreference) return 'Native image reading';
if (status.status === 'hook-missing') return 'Setup needed';
if (status.authReadiness === 'missing') return 'Needs auth';
if (status.proxyReadiness === 'unavailable') return 'Needs proxy';
if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback';
return 'Transformer ready';
}
function getHeaderBadge(
status: ImageAnalysisStatus,
target: CliTarget
): {
label: string;
className: string;
} {
if (status.status === 'disabled') {
return {
label: 'Disabled',
className: 'border-border/80 bg-background/85 text-muted-foreground',
};
}
if (status.status === 'hook-missing') {
return {
label: 'Setup needed',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (status.authReadiness === 'missing') {
return {
label: 'Needs auth',
className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200',
};
}
if (status.proxyReadiness === 'unavailable') {
return {
label: 'Needs proxy',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (target !== 'claude') {
return {
label: 'Bypassed',
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
};
}
if (status.effectiveRuntimeMode === 'native-read') {
if (status.nativeReadPreference) {
return {
label: 'Native fallback',
className: 'border-border/80 bg-background/85 text-muted-foreground',
label: 'Native',
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
if (status.proxyReadiness === 'stopped') {
if (status.status === 'hook-missing' || status.authReadiness === 'missing') {
return {
label: 'Starts on launch',
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
label: status.status === 'hook-missing' ? 'Setup' : 'Auth',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
if (status.proxyReadiness === 'unavailable') {
return {
label: 'Proxy',
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
};
}
return {
label: 'CLIProxy active',
label: 'Ready',
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
};
}
function getStatusContext(
source: 'saved' | 'editor',
previewState: ImageAnalysisStatusSectionProps['previewState']
) {
if (previewState === 'invalid') {
return 'Showing saved status until the JSON above is valid again.';
function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string {
if (status.nativeReadPreference) {
if (status.profileModel && status.nativeImageCapable) {
return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`;
}
if (status.profileModel) {
return `CCS will prefer native reading for ${status.profileModel}.`;
}
return 'CCS will prefer native image reading for this profile.';
}
if (previewState === 'refreshing') {
return 'Refreshing from the current editor state.';
if (!status.backendDisplayName && target === 'claude') {
return 'This profile currently stays on native file access.';
}
return source === 'editor'
? 'Preview from the current editor JSON.'
: 'Saved status for this profile.';
if (!status.backendDisplayName) {
return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`;
}
const modelSuffix = status.model ? ` · ${status.model}` : '';
return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`;
}
function getSummary(status: ImageAnalysisStatus, target: CliTarget): string {
const backendName = status.backendDisplayName || status.backendId || 'native file access';
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
if (status.status === 'disabled') {
return 'Image Analysis is disabled globally for this profile.';
}
if (!status.backendId) {
return target === 'claude'
? 'This profile currently uses native file access for images and PDFs.'
: `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`;
return 'Image is disabled globally in CCS settings.';
}
if (target !== 'claude') {
return status.effectiveRuntimeMode === 'native-read'
? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.`
: `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`;
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
}
if (status.effectiveRuntimeMode === 'native-read') {
return `Saved backend: ${backendName}. Current runtime falls back to native file access.`;
}
return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`;
}
function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string {
if (target !== 'claude') {
return 'Current launch path bypasses the Claude Read hook.';
if (status.nativeReadPreference) {
return status.nativeImageCapable === true ? null : status.nativeImageReason;
}
if (status.status === 'hook-missing') {
return 'Hook must be persisted before this profile can use Image Analysis.';
return 'Persist the profile hook before transformer routing can run here.';
}
if (status.effectiveRuntimeMode === 'native-read') {
return status.effectiveRuntimeReason || status.reason || 'Using native file access.';
if (status.authReadiness === 'missing') {
return status.authReason;
}
if (status.proxyReadiness === 'stopped') {
return 'Auth is ready. Local CLIProxy will start on demand.';
if (status.proxyReadiness === 'unavailable') {
return status.proxyReason;
}
return 'Current target can use the saved backend.';
}
function getPersistenceValue(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
return 'Not required';
}
return status.hookInstalled ? 'Hook saved' : 'Hook missing';
}
function getPersistenceDetail(status: ImageAnalysisStatus): string {
if (!status.shouldPersistHook || !status.persistencePath) {
return 'No profile-level hook persistence required.';
}
return status.persistencePath;
}
function getModelValue(status: ImageAnalysisStatus): string {
return status.model || status.reason || 'Unavailable';
return null;
}
export function ImageAnalysisStatusSection({
@@ -169,147 +134,88 @@ export function ImageAnalysisStatusSection({
target = 'claude',
source = 'saved',
previewState = 'saved',
nativeReadPreferenceOverride,
onToggleNativeRead,
}: ImageAnalysisStatusSectionProps) {
if (!status) {
return (
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-52 animate-pulse rounded bg-muted" />
</div>
);
}
const previewBadge = getPreviewBadge(source, previewState);
const runtimeBadge = getRuntimeBadge(status, target);
const showNotice =
status.status === 'hook-missing' ||
status.authReadiness === 'missing' ||
status.proxyReadiness === 'unavailable';
const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference;
const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked };
const headerBadge = getHeaderBadge(effectiveStatus, target);
const note = getExceptionalNote(effectiveStatus, target);
const capabilityLabel = status.nativeImageCapable
? 'Verified'
: status.profileModel
? 'Unknown'
: null;
return (
<section className="rounded-md border bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<section className="rounded-2xl border bg-background/95 px-4 py-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-sky-600" />
<h3 className="text-sm font-semibold">Image Analysis</h3>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
<ImageIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Image</h3>
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
{headerBadge.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
</p>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{getStatusContext(source, previewState)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={previewBadge.variant} className="h-5 px-1.5 text-[10px]">
{previewBadge.label}
</Badge>
<Badge className={cn('h-5 border px-1.5 text-[10px]', runtimeBadge.className)}>
{runtimeBadge.label}
</Badge>
</div>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{getSummary(status, target)}</p>
<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">
Backend
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{status.backendDisplayName || status.backendId || 'Native file access'}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{SOURCE_LABELS[status.resolutionSource]}
</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">
Current target
</div>
<div className="mt-2 text-sm font-medium text-foreground">{TARGET_LABELS[target]}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getTargetDetail(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">
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 required'}
>
{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
</dt>
<dd className="text-sm text-foreground">
{status.authReadiness === 'ready'
? `${status.authDisplayName || status.authProvider} ready`
: status.authReadiness === 'missing'
? status.authReason
: status.authReadiness === 'not-needed'
? 'Not required'
: 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Proxy
</dt>
<dd className="text-sm text-foreground">
{status.proxyReadiness === 'ready'
? 'Local CLIProxy ready'
: status.proxyReadiness === 'remote'
? 'Remote CLIProxy ready'
: status.proxyReadiness === 'stopped'
? 'Local CLIProxy idle'
: status.proxyReadiness === 'not-needed'
? 'Not required'
: status.proxyReason || 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Model
</dt>
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
{getModelValue(status)}
</dd>
</div>
</dl>
{showNotice && (
<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" />
<span>
{status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
</span>
</div>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<p className="text-xs text-muted-foreground">
CRUD and backend routing now live in global Settings.
</p>
<Button size="sm" variant="outline" asChild>
<Link to="/settings?tab=imageanalysis">
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
<Link to="/settings?tab=image">
Open Settings
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>
<div className="mt-3 rounded-xl border bg-muted/15 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium text-foreground">Use native image reading</div>
{capabilityLabel && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{capabilityLabel}
</Badge>
)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getToggleSummary(effectiveStatus, target)}
</p>
</div>
<Switch
checked={nativeReadChecked}
onCheckedChange={onToggleNativeRead}
disabled={!onToggleNativeRead}
aria-label="Use native image reading"
/>
</div>
</div>
{note && (
<div className="mt-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
{note}
</div>
)}
</section>
);
}
@@ -67,6 +67,31 @@ export function ProfileEditor({
setRawJsonEdits(value);
}, []);
const updateNativeImageRead = useCallback(
(enabled: boolean) => {
const nextSettings = { ...(currentSettings ?? {}) } as Settings;
const currentCcsImage =
nextSettings.ccs_image && typeof nextSettings.ccs_image === 'object'
? { ...nextSettings.ccs_image }
: {};
if (enabled) {
currentCcsImage.native_read = true;
} else {
delete currentCcsImage.native_read;
}
if (Object.keys(currentCcsImage).length > 0) {
nextSettings.ccs_image = currentCcsImage;
} else {
delete nextSettings.ccs_image;
}
setRawJsonEdits(JSON.stringify(nextSettings, null, 2));
},
[currentSettings]
);
// Sync Visual Editor changes to Raw JSON
const updateEnvValue = (key: string, value: string) => {
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
@@ -165,6 +190,7 @@ export function ProfileEditor({
(!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData)
? 'refreshing'
: 'preview';
const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true;
// Check for missing required fields (informational warning)
const missingRequiredFields = useMemo(() => {
@@ -318,6 +344,8 @@ export function ProfileEditor({
imageAnalysisStatus={imageAnalysisStatus}
imageAnalysisStatusSource={imageAnalysisStatusSource}
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
onToggleNativeRead={updateNativeImageRead}
onChange={handleRawJsonChange}
missingRequiredFields={missingRequiredFields}
/>
@@ -24,6 +24,8 @@ interface RawEditorSectionProps {
imageAnalysisStatus?: ImageAnalysisStatus | null;
imageAnalysisStatusSource?: 'saved' | 'editor';
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
nativeReadPreferenceOverride?: boolean;
onToggleNativeRead?: (enabled: boolean) => void;
onChange: (value: string) => void;
missingRequiredFields?: string[];
}
@@ -37,6 +39,8 @@ export function RawEditorSection({
imageAnalysisStatus,
imageAnalysisStatusSource = 'saved',
imageAnalysisStatusPreviewState = 'saved',
nativeReadPreferenceOverride,
onToggleNativeRead,
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
@@ -91,6 +95,8 @@ export function RawEditorSection({
target={profileTarget}
source={imageAnalysisStatusSource}
previewState={imageAnalysisStatusPreviewState}
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
onToggleNativeRead={onToggleNativeRead}
/>
</div>
{/* Global Env Indicator */}
@@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l
export interface Settings {
env?: Record<string, string>;
ccs_image?: {
native_read?: boolean;
};
}
export interface SettingsResponse {
+18 -1
View File
@@ -126,6 +126,7 @@ export interface ImageAnalysisStatus {
| 'cliproxy-bridge'
| 'profile-backend'
| 'fallback-backend'
| 'native-compatible'
| 'disabled'
| 'unsupported-profile'
| 'unresolved'
@@ -146,6 +147,10 @@ export interface ImageAnalysisStatus {
proxyReason: string | null;
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
effectiveRuntimeReason: string | null;
profileModel: string | null;
nativeReadPreference: boolean;
nativeImageCapable: boolean | null;
nativeImageReason: string | null;
}
export interface ImageAnalysisSettingsConfig {
@@ -164,6 +169,7 @@ export interface ImageAnalysisDashboardSummary {
mappedProfileCount: number;
activeProfileCount: number;
bypassedProfileCount: number;
nativeProfileCount: number;
}
export interface ImageAnalysisDashboardBackend {
@@ -190,7 +196,18 @@ export interface ImageAnalysisDashboardProfile {
status: ImageAnalysisStatus['status'];
effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
effectiveRuntimeReason: string | null;
currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved';
currentTargetMode:
| 'active'
| 'bypassed'
| 'fallback'
| 'setup'
| 'disabled'
| 'native'
| 'unresolved';
profileModel: string | null;
nativeReadPreference: boolean;
nativeImageCapable: boolean | null;
nativeImageReason: string | null;
}
export interface ImageAnalysisDashboardCatalog {
+4 -4
View File
@@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
default: 'gemini-3.1-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
haiku: 'gemini-3-1-flash-preview',
},
},
{
id: 'gemini-3-flash-preview',
id: 'gemini-3-1-flash-preview',
name: 'Gemini Flash',
description: 'Resolves to the best advertised Gemini Flash preview via Antigravity',
extendedContext: true,
presetMapping: {
default: 'gemini-3-flash-preview',
default: 'gemini-3-1-flash-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
haiku: 'gemini-3-1-flash-preview',
},
},
],
@@ -26,7 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
const { t } = useTranslation();
const tabs = [
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
{ value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon },
{ value: 'image' as const, label: 'Image', icon: ImageIcon },
{ value: 'channels' as const, label: 'Channels', icon: MessageSquare },
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
@@ -39,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-8">
{tabs.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
</TabsTrigger>
@@ -11,8 +11,8 @@ export function useSettingsTab() {
// Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups)
const tabParam = searchParams.get('tab')?.toLowerCase();
const activeTab: SettingsTab =
tabParam === 'imageanalysis'
? 'imageanalysis'
tabParam === 'imageanalysis' || tabParam === 'image'
? 'image'
: tabParam === 'channels'
? 'channels'
: tabParam === 'globalenv'
+4 -4
View File
@@ -132,7 +132,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -146,7 +146,7 @@ function SettingsPageInner() {
{/* Desktop View - Side-by-side panels */}
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
{/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<Panel defaultSize={46} minSize={36} maxSize={62}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header with Tabs */}
<div className="p-5 border-b bg-background">
@@ -157,7 +157,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'image' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -175,7 +175,7 @@ function SettingsPageInner() {
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<Panel defaultSize={54} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -161,7 +161,7 @@ export interface OfficialChannelsStatus {
export type SettingsTab =
| 'websearch'
| 'imageanalysis'
| 'image'
| 'channels'
| 'globalenv'
| 'proxy'
@@ -38,7 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
status: 'active',
backendId: 'gemini',
backendDisplayName: 'Google Gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3-flash-preview',
resolutionSource: 'cliproxy-bridge',
reason: null,
shouldPersistHook: true,
@@ -56,6 +56,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
proxyReason: 'Local CLIProxy service is reachable.',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
profileModel: 'gemini-3-flash-preview',
nativeReadPreference: false,
nativeImageCapable: true,
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
...overrides,
};
}
@@ -80,18 +84,16 @@ describe('ImageAnalysisStatusSection', () => {
it('renders a compact saved summary with a settings link', () => {
render(<ImageAnalysisStatusSection status={createStatus()} />);
expect(screen.getByText('Image Analysis')).toBeInTheDocument();
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(screen.getByText('CLIProxy active')).toBeInTheDocument();
expect(screen.getByText('Image')).toBeInTheDocument();
expect(screen.getByText(/Saved status · Transformer ready/i)).toBeInTheDocument();
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(screen.getByText('Use native image reading')).toBeInTheDocument();
expect(
screen.getByText(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i)
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(screen.getByText('Backend')).toBeInTheDocument();
expect(screen.getByText('Current target')).toBeInTheDocument();
expect(screen.getByText('Persistence')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
'href',
'/settings?tab=imageanalysis'
'/settings?tab=image'
);
});
@@ -99,12 +101,12 @@ describe('ImageAnalysisStatusSection', () => {
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
expect(screen.getByText('Bypassed')).toBeInTheDocument();
expect(screen.getByText(/Saved status · Codex CLI bypasses the hook/i)).toBeInTheDocument();
expect(
screen.getByText(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i)
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
).toBeInTheDocument();
expect(screen.getByText('Codex CLI')).toBeInTheDocument();
expect(
screen.getByText(/Current launch path bypasses the Claude Read hook/i)
screen.getByText(/Current target Codex CLI bypasses the Claude Read hook/i)
).toBeInTheDocument();
});
@@ -114,6 +116,8 @@ describe('ImageAnalysisStatusSection', () => {
status={createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
profileModel: 'claude-haiku-4.5',
authReadiness: 'missing',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
@@ -126,15 +130,94 @@ describe('ImageAnalysisStatusSection', () => {
/>
);
expect(screen.getByText('Needs auth')).toBeInTheDocument();
expect(screen.getByText('Auth')).toBeInTheDocument();
expect(
screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i)
screen.getByText(/Transformer route: GitHub Copilot \(OAuth\) · claude-haiku-4.5\./i)
).toBeInTheDocument();
expect(
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
).toBeGreaterThanOrEqual(1);
});
it('calls the toggle handler immediately for native image reading', () => {
const onToggleNativeRead = vi.fn();
render(
<ImageAnalysisStatusSection status={createStatus()} onToggleNativeRead={onToggleNativeRead} />
);
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
expect(onToggleNativeRead).toHaveBeenCalledWith(true);
});
it('writes the native image preference into the raw settings json', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/glm/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'glm',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'saved-token',
},
},
mtime: 1,
path: '/tmp/glm.settings.json',
imageAnalysisStatus: createStatus(),
})
);
}
if (url.includes('/api/settings/glm/image-analysis-status')) {
return Promise.resolve(
createJsonResponse({
imageAnalysisStatus: createStatus({
backendId: null,
backendDisplayName: null,
model: null,
resolutionSource: 'native-compatible',
supported: false,
shouldPersistHook: false,
runtimePath: null,
authReadiness: 'not-needed',
authProvider: null,
authDisplayName: null,
authReason: null,
proxyReadiness: 'not-needed',
proxyReason: null,
effectiveRuntimeMode: 'native-read',
nativeReadPreference: true,
}),
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
await screen.findByText(/Transformer route: Google Gemini/i);
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
await waitFor(() => {
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
'"ccs_image"'
);
});
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
'"native_read": true'
);
});
it('switches to live preview when editor JSON changes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -179,7 +262,7 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -197,10 +280,9 @@ describe('ImageAnalysisStatusSection', () => {
});
await waitFor(() => {
expect(screen.getByText('Live Preview')).toBeInTheDocument();
expect(screen.getByText(/Live preview/i)).toBeInTheDocument();
});
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument();
expect(screen.getByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
});
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
@@ -232,16 +314,14 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: { value: '{' },
});
await waitFor(() => {
expect(
screen.getByText(/Showing saved status until the JSON above is valid again/i)
).toBeInTheDocument();
expect(screen.getByText(/Saved status/i)).toBeInTheDocument();
});
});
@@ -303,7 +383,7 @@ describe('ImageAnalysisStatusSection', () => {
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -320,7 +400,7 @@ describe('ImageAnalysisStatusSection', () => {
},
});
expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(await screen.findByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('raw config editor'), {
target: {
@@ -338,9 +418,8 @@ describe('ImageAnalysisStatusSection', () => {
});
await waitFor(() => {
expect(screen.getByText('Refreshing')).toBeInTheDocument();
expect(screen.getByText(/Refreshing preview/i)).toBeInTheDocument();
});
expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument();
secondPreviewResolver?.(
createJsonResponse({
@@ -356,7 +435,7 @@ describe('ImageAnalysisStatusSection', () => {
);
await waitFor(() => {
expect(screen.getByText('Codex')).toBeInTheDocument();
expect(screen.getByText(/Codex/i)).toBeInTheDocument();
});
});
});
@@ -18,7 +18,7 @@ describe('ImageAnalysisSection', () => {
enabled: true,
timeout: 60,
providerModels: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
ghcp: 'claude-haiku-4.5',
},
fallbackBackend: 'gemini',
@@ -30,17 +30,18 @@ describe('ImageAnalysisSection', () => {
state: 'partial',
title: 'Partially ready',
detail:
'1 backend still needs auth, runtime, or review before every profile path is healthy.',
'1 profile routes through Image on the current Claude target path. 1 prefer native image reading.',
backendCount: 2,
mappedProfileCount: 1,
activeProfileCount: 1,
bypassedProfileCount: 1,
nativeProfileCount: 1,
},
backends: [
{
backendId: 'gemini',
displayName: 'Google Gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3-flash-preview',
state: 'ready',
authReadiness: 'ready',
authReason: null,
@@ -74,6 +75,10 @@ describe('ImageAnalysisSection', () => {
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
currentTargetMode: 'active',
profileModel: 'gemini-3-flash-preview',
nativeReadPreference: false,
nativeImageCapable: true,
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
},
{
name: 'codexProfile',
@@ -88,6 +93,10 @@ describe('ImageAnalysisSection', () => {
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
currentTargetMode: 'bypassed',
profileModel: 'claude-haiku-4.5',
nativeReadPreference: true,
nativeImageCapable: true,
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
},
],
catalog: {
@@ -110,21 +119,32 @@ describe('ImageAnalysisSection', () => {
if (url === '/api/image-analysis' && method === 'PUT') {
const body = JSON.parse(String(init?.body ?? '{}')) as {
enabled?: boolean;
timeout?: number;
fallbackBackend?: string;
profileBackends?: Record<string, string>;
providerModels?: Record<string, string | null>;
};
const providerModels = body.providerModels ?? {};
payload = {
...payload,
config: {
enabled: true,
enabled: body.enabled ?? payload.config.enabled,
timeout: body.timeout ?? payload.config.timeout,
fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend,
fallbackBackend:
'fallbackBackend' in body
? (body.fallbackBackend ?? null)
: payload.config.fallbackBackend,
providerModels: {
gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini),
ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp),
gemini:
'gemini' in providerModels
? (providerModels.gemini ?? '')
: payload.config.providerModels.gemini,
ghcp:
'ghcp' in providerModels
? (providerModels.ghcp ?? '')
: payload.config.providerModels.ghcp,
},
profileBackends: body.profileBackends ?? payload.config.profileBackends,
},
@@ -146,18 +166,19 @@ describe('ImageAnalysisSection', () => {
it('renders global controls and saves updated config', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.getByText('Partially ready')).toBeInTheDocument();
expect(screen.getByText('Provider models')).toBeInTheDocument();
expect(screen.getByText('Profile mappings')).toBeInTheDocument();
expect(screen.getByText('Profile coverage')).toBeInTheDocument();
expect(screen.getByText('Bypassed')).toBeInTheDocument();
expect(screen.getByText('Core setup')).toBeInTheDocument();
expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0);
expect(screen.getByText('Profile routing')).toBeInTheDocument();
expect(screen.getAllByText('Coverage').length).toBeGreaterThan(0);
expect(screen.getAllByText('Bypassed').length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
const timeoutInput = screen.getByDisplayValue('60');
await userEvent.clear(timeoutInput);
await userEvent.type(timeoutInput, '120');
await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await userEvent.tab();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
@@ -168,10 +189,12 @@ describe('ImageAnalysisSection', () => {
);
});
const putCall = fetchMock.mock.calls.find(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
);
const putCall = fetchMock.mock.calls
.filter(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
)
.at(-1);
expect(putCall).toBeDefined();
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
@@ -183,7 +206,97 @@ describe('ImageAnalysisSection', () => {
},
});
expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument();
expect(await screen.findByText('Image settings saved.')).toBeInTheDocument();
});
it('allows saving a disabled configuration even when every provider model is cleared', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
await screen.findByDisplayValue('gemini-3-flash-preview');
await userEvent.click(screen.getByRole('switch'));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
await waitFor(() => {
expect(screen.getByDisplayValue('gemini-3-flash-preview')).not.toBeDisabled();
});
fetchMock.mockClear();
await userEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]);
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Clear' })).toHaveLength(1);
});
fetchMock.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
const putCall = fetchMock.mock.calls
.filter(
([url, init]) =>
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
)
.at(-1);
expect(putCall).toBeDefined();
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
expect(requestBody).toMatchObject({
enabled: false,
fallbackBackend: null,
providerModels: {
gemini: null,
ghcp: null,
},
});
});
it('auto-saves edits without rendering a dedicated save button', async () => {
render(<ImageAnalysisSection />, { withSettingsProvider: true });
expect(await screen.findByText('Image')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
const timeoutInput = screen.getByDisplayValue('60');
await userEvent.clear(timeoutInput);
await userEvent.type(timeoutInput, '90');
await userEvent.tab();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/image-analysis',
expect.objectContaining({
method: 'PUT',
})
);
});
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
it('surfaces a clear retryable error when the backend route is not available yet', async () => {
@@ -209,7 +322,7 @@ describe('ImageAnalysisSection', () => {
expect(
await screen.findByText(
/Image Analysis settings returned an unexpected response\. Restart the dashboard server/i
/Image settings returned an unexpected response\. Restart the dashboard server/i
)
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();