fix(image-analysis): surface runtime readiness

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent d394772f7c
commit 1a01c6fc68
12 files changed
+788 -123

No files matched your search

@@ -9,66 +9,103 @@ interface ImageAnalysisStatusSectionProps {
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
}
function getBadge(status: ImageAnalysisStatus | null | undefined): {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
} {
switch (status?.status) {
case 'active':
return { label: 'Configured', variant: 'default' };
case 'mapped':
return { label: 'Saved mapping', variant: 'secondary' };
case 'attention':
return { label: 'Needs review', variant: 'outline' };
case 'disabled':
return { label: 'Disabled', variant: 'outline' };
case 'hook-missing':
return { label: 'Setup needed', variant: 'destructive' };
case 'skipped':
return { label: 'Not available', variant: 'outline' };
default:
return { label: 'Checking', variant: 'outline' };
function getBadge(status: ImageAnalysisStatus | null | undefined) {
if (!status) return { label: 'Checking', variant: 'outline' as const };
if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const };
if (status.status === 'hook-missing')
return { label: 'Setup needed', variant: 'destructive' as const };
if (status.authReadiness === 'missing')
return { label: 'Needs auth', variant: 'destructive' as const };
if (status.proxyReadiness === 'unavailable')
return { label: 'Needs proxy', variant: 'destructive' as const };
if (
status.effectiveRuntimeMode === 'cliproxy-image-analysis' &&
status.proxyReadiness === 'stopped'
) {
return { label: 'Starts on launch', variant: 'secondary' as const };
}
if (status.effectiveRuntimeMode === 'cliproxy-image-analysis') {
return {
label: status.resolutionSource === 'profile-backend' ? 'Ready via mapping' : 'Ready',
variant: 'default' as const,
};
}
if (
status.authReadiness === 'unknown' ||
status.proxyReadiness === 'unknown' ||
status.status === 'attention'
) {
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 };
}
function getSummary(status: ImageAnalysisStatus): string {
const backendName = status.backendDisplayName || status.backendId || 'this backend';
switch (status.status) {
case 'disabled':
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
case 'mapped':
return `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`;
case 'attention':
return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`;
case 'hook-missing':
return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`;
case 'skipped':
return status.reason || 'Skipped for this profile.';
case 'active':
default:
if (status.resolutionSource === 'cliproxy-bridge') {
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
}
if (status.resolutionSource === 'fallback-backend') {
return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
}
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
if (status.status === 'disabled') {
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
}
if (!status.backendId) {
return status.reason || "This profile uses Claude's built-in file reading.";
}
if (status.status === 'hook-missing') {
return `Configured for ${backendName}, but ${status.reason || 'the image-analysis 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.'}`;
}
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.`;
}
if (status.resolutionSource === 'profile-backend') {
return `Configured via saved ${backendName} mapping. Auth and runtime are ready, so image and PDF reads use CLIProxy.`;
}
if (status.status === 'attention' && status.reason) {
return `Configured via ${backendName}. Image and PDF reads use CLIProxy, but ${status.reason}`;
}
return `Configured via ${backendName}. Image and PDF reads use CLIProxy for this profile.`;
}
function getRuntimeLine(status: ImageAnalysisStatus): string {
if (status.status === 'hook-missing') {
return 'Read -> native file access (hook install required)';
}
if (!status.runtimePath) {
if (status.effectiveRuntimeMode === 'native-read') {
return 'Read -> native file access';
}
return `Read -> image-analysis hook -> ${status.runtimePath}`;
if (status.proxyReadiness === 'stopped') {
return 'Read -> image-analysis hook -> start local CLIProxy';
}
if (status.proxyReadiness === 'remote') {
return 'Read -> image-analysis hook -> remote CLIProxy';
}
return `Read -> image-analysis hook -> ${status.runtimePath || 'CLIProxy'}`;
}
function getAuthLine(status: ImageAnalysisStatus): string {
if (status.authReadiness === 'not-needed') return 'Not required';
if (status.authReadiness === 'ready')
return `${status.authDisplayName || status.authProvider} ready`;
return status.authReason || 'Auth readiness could not be verified.';
}
function getProxyLine(status: ImageAnalysisStatus): string {
if (status.proxyReadiness === 'not-needed') return 'Not required';
if (status.proxyReadiness === 'ready') return 'Local CLIProxy ready';
if (status.proxyReadiness === 'remote') return status.proxyReason || 'Remote CLIProxy ready';
if (status.proxyReadiness === 'stopped') return 'Local CLIProxy idle; starts on launch';
return status.proxyReason || 'CLIProxy runtime readiness could not be verified.';
}
function getStatusContext(
@@ -78,28 +115,21 @@ function getStatusContext(
if (previewState === 'invalid') {
return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.';
}
if (previewState === 'refreshing') {
return 'Refreshing the live preview from the current editor state.';
}
if (source === 'editor') {
return 'Live preview from the current editor state. Save to persist these backend and hook changes.';
return 'Live preview from the current editor state. Save to persist config changes; auth and proxy readiness stay derived below.';
}
return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.';
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) {
if (!status.shouldPersistHook || !status.persistencePath)
return 'Not persisted for this profile type';
}
if (status.hookInstalled) {
return `${status.persistencePath} hook`;
}
return `${status.persistencePath} hook missing`;
return status.hookInstalled
? `${status.persistencePath} hook`
: `${status.persistencePath} hook missing`;
}
export function ImageAnalysisStatusSection({
@@ -118,8 +148,12 @@ export function ImageAnalysisStatusSection({
}
const badge = getBadge(status);
const detailLabel = status.supported ? 'Model' : 'Reason';
const detailValue = status.supported ? status.model : status.reason || 'Unavailable';
const notice =
status.effectiveRuntimeMode === 'native-read'
? status.effectiveRuntimeReason
: status.status === 'attention' || status.status === 'hook-missing'
? status.reason
: null;
return (
<section className="rounded-md border bg-muted/20 p-4">
@@ -149,7 +183,6 @@ export function ImageAnalysisStatusSection({
</dt>
<dd className="text-sm font-medium">{status.backendDisplayName || 'Unresolved'}</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Runtime
@@ -161,7 +194,18 @@ export function ImageAnalysisStatusSection({
{getRuntimeLine(status)}
</dd>
</div>
<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">{getAuthLine(status)}</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">{getProxyLine(status)}</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Persistence
@@ -173,21 +217,20 @@ export function ImageAnalysisStatusSection({
{getPersistenceLine(status)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{detailLabel}
Model
</dt>
<dd className={cn('text-sm text-foreground', status.supported && 'font-mono text-xs')}>
{detailValue}
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
{status.model || status.reason || 'Unavailable'}
</dd>
</div>
</dl>
{(status.status === 'attention' || status.status === 'hook-missing') && (
{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" />
<span>{status.reason}</span>
<span>{notice}</span>
</div>
)}
+8
View File
@@ -138,6 +138,14 @@ export interface ImageAnalysisStatus {
usesCurrentAuthToken: boolean | null;
hookInstalled: boolean | null;
sharedHookInstalled: boolean | null;
authReadiness: 'not-needed' | 'ready' | 'missing' | 'unknown';
authProvider: string | null;
authDisplayName: string | null;
authReason: string | null;
proxyReadiness: 'not-needed' | 'ready' | 'remote' | 'stopped' | 'unavailable' | 'unknown';
proxyReason: string | null;
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
effectiveRuntimeReason: string | null;
}
export interface Profile {
@@ -48,6 +48,14 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
usesCurrentAuthToken: true,
hookInstalled: true,
sharedHookInstalled: true,
authReadiness: 'ready',
authProvider: 'gemini',
authDisplayName: 'Google Gemini',
authReason: null,
proxyReadiness: 'ready',
proxyReason: 'Local CLIProxy service is reachable.',
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: null,
...overrides,
};
}
@@ -75,13 +83,17 @@ describe('ImageAnalysisStatusSection', () => {
expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
expect(
screen.getByText(
/Saved runtime status for this profile\. This section is derived and is not written into the JSON editor above\./i
/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('Configured')).toBeInTheDocument();
expect(screen.getByText(/Configured via Google Gemini\./i)).toBeInTheDocument();
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(
screen.getByText(/Configured via Google Gemini\. Image and PDF reads use CLIProxy/i)
).toBeInTheDocument();
expect(screen.getByText('Google Gemini')).toBeInTheDocument();
expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument();
expect(screen.getAllByText('Google Gemini ready')).toHaveLength(1);
expect(screen.getByText('Local CLIProxy ready')).toBeInTheDocument();
expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument();
});
@@ -89,19 +101,22 @@ describe('ImageAnalysisStatusSection', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
status: 'mapped',
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
resolutionSource: 'profile-backend',
reason: 'Using explicit profile mapping.',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
})}
/>
);
expect(screen.getByText('Saved mapping')).toBeInTheDocument();
expect(screen.getByText('Ready via mapping')).toBeInTheDocument();
expect(
screen.getByText(/Configured via saved GitHub Copilot \(OAuth\) mapping/i)
screen.getByText(
/Configured via saved GitHub Copilot \(OAuth\) mapping\. Auth and runtime are ready/i
)
).toBeInTheDocument();
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
@@ -113,6 +128,8 @@ describe('ImageAnalysisStatusSection', () => {
status={createStatus({
status: 'hook-missing',
reason: 'Profile hook is missing from the persisted settings file.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason: 'Profile hook is missing from the persisted settings file.',
})}
/>
);
@@ -121,31 +138,55 @@ describe('ImageAnalysisStatusSection', () => {
expect(
screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i)
).toBeInTheDocument();
expect(screen.getByTitle(/native file access \(hook install required\)/i)).toBeInTheDocument();
expect(screen.getByTitle(/native file access/i)).toBeInTheDocument();
});
it('uses the resolver reason for attention states like auth-token drift', () => {
it('shows auth readiness gaps separately from backend resolution', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
status: 'attention',
reason:
'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.',
usesCurrentTarget: true,
usesCurrentAuthToken: false,
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
runtimePath: '/api/provider/ghcp',
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.',
})}
/>
);
expect(screen.getByText('Needs review')).toBeInTheDocument();
expect(screen.getByText('Needs auth')).toBeInTheDocument();
expect(
screen.getByText(
/Configured via Google Gemini, but Runtime uses the current CLIProxy auth token/i
/Configured via GitHub Copilot \(OAuth\), but GitHub Copilot \(OAuth\) auth is missing/i
)
).toBeInTheDocument();
expect(screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i)).toHaveLength(3);
});
it('treats an idle local proxy as launchable instead of unavailable', () => {
render(
<ImageAnalysisStatusSection
status={createStatus({
proxyReadiness: 'stopped',
proxyReason:
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
})}
/>
);
expect(screen.getByText('Starts on launch')).toBeInTheDocument();
expect(
screen.getAllByText(/current CLIProxy auth token instead of the saved token/i)
).toHaveLength(2);
screen.getByText(/Auth is ready and CCS will start the local CLIProxy runtime on launch/i)
).toBeInTheDocument();
expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument();
expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument();
});
it('switches the panel to a live preview when the current editor JSON changes', async () => {
@@ -178,6 +219,9 @@ describe('ImageAnalysisStatusSection', () => {
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
runtimePath: '/api/provider/ghcp',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
}),
})
);
@@ -212,7 +256,7 @@ describe('ImageAnalysisStatusSection', () => {
});
expect(
screen.getByText(
/Live preview from the current editor state\. Save to persist these backend and hook changes\./i
/Live preview from the current editor state\. Save to persist config changes; auth and proxy readiness stay derived below\./i
)
).toBeInTheDocument();
});