mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 10:14:56 +00:00
fix(image-analysis): surface runtime readiness
This commit is contained in:
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user