agent-ecosystem/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
2026-05-24 16:43:16 +03:00

1130 lines
37 KiB
TypeScript

import React from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
import type {
CliProviderStatus,
TeamProviderId,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
type TeamTranslator = ReturnType<typeof useAppTranslation>['t'];
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
export interface ProvisioningProviderCheck {
providerId: TeamProviderId;
status: ProvisioningProviderCheckStatus;
backendSummary?: string | null;
details: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
export function createInitialProviderChecks(
providerIds: TeamProviderId[]
): ProvisioningProviderCheck[] {
return providerIds.map((providerId) => ({
providerId,
status: 'pending',
backendSummary: null,
details: [],
}));
}
export function getProvisioningProviderBackendSummary(
provider:
| Pick<
CliProviderStatus,
'providerId' | 'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
>
| null
| undefined
): string | null {
if (!provider) {
return null;
}
const options = provider.availableBackends ?? [];
const optionById = new Map(options.map((option) => [option.id, option.label]));
const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId;
const effectiveOption = options.find((option) => option.id === effectiveBackendId) ?? null;
const inferredProviderId: TeamProviderId | undefined =
provider.providerId === 'anthropic' ||
provider.providerId === 'codex' ||
provider.providerId === 'gemini' ||
provider.providerId === 'opencode'
? provider.providerId
: effectiveBackendId === 'codex-native' ||
options.some((option) => option.id === 'codex-native')
? 'codex'
: undefined;
const normalizedLabel =
formatProviderBackendLabel(inferredProviderId, effectiveBackendId ?? undefined) ?? null;
const baseSummary = effectiveBackendId
? (normalizedLabel ??
optionById.get(effectiveBackendId) ??
provider.backend?.label ??
effectiveBackendId)
: (provider.backend?.label ?? null);
if (!baseSummary) {
return null;
}
const suffixes: string[] = [];
if (effectiveOption?.audience === 'internal') {
suffixes.push('internal');
}
if (effectiveOption?.state && effectiveOption.state !== 'ready') {
switch (effectiveOption.state) {
case 'locked':
suffixes.push('locked');
break;
case 'disabled':
suffixes.push('disabled');
break;
case 'authentication-required':
suffixes.push('auth required');
break;
case 'runtime-missing':
suffixes.push('runtime missing');
break;
case 'degraded':
if (inferredProviderId !== 'codex') {
suffixes.push('degraded');
}
break;
default:
break;
}
}
return suffixes.length > 0 ? `${baseSummary} - ${suffixes.join(', ')}` : baseSummary;
}
export function updateProviderCheck(
checks: ProvisioningProviderCheck[],
providerId: TeamProviderId,
patch: Partial<ProvisioningProviderCheck>
): ProvisioningProviderCheck[] {
return checks.map((check) =>
check.providerId === providerId
? {
...check,
...patch,
}
: check
);
}
export function failIncompleteProviderChecks(
checks: ProvisioningProviderCheck[],
detail: string
): ProvisioningProviderCheck[] {
return checks.map((check) =>
check.status === 'ready' || check.status === 'notes' || check.status === 'failed'
? check
: {
...check,
status: 'failed',
details: check.details.length > 0 ? check.details : [detail],
}
);
}
export function getProvisioningProviderProgressMessage(
providerIds: readonly TeamProviderId[],
totalProviderCount: number,
t?: TeamTranslator
): string {
if (providerIds.length === 0 || providerIds.length === totalProviderCount) {
return t
? t('provisioning.providerStatus.progress.checkingSelectedProviders')
: 'Checking selected providers in parallel...';
}
if (providerIds.length === 1) {
const provider = getProvisioningProviderLabel(providerIds[0]);
return t
? t('provisioning.providerStatus.progress.checkingProvider', { provider })
: `Checking ${provider} provider...`;
}
const providers = providerIds.map(getProvisioningProviderLabel).join(', ');
return t
? t('provisioning.providerStatus.progress.checkingProviders', { providers })
: `Checking ${providers} providers...`;
}
type ProvisioningDetailSummary =
| 'CLI binary missing'
| 'OpenCode runtime missing'
| 'OpenCode Windows access blocked'
| 'OpenCode runtime check returned no output'
| 'OpenCode app MCP unreachable'
| 'Working directory missing'
| 'CLI binary could not be started'
| 'CLI preflight did not complete'
| 'Authentication required'
| 'Runtime provider is not configured'
| 'CLI preflight failed'
| 'Selected model compatible'
| 'Selected model compatibility pending'
| 'Selected model available'
| 'Selected model verified'
| 'Selected model unavailable'
| 'Selected model verification timed out'
| 'Selected model check failed'
| 'Selected model verification deferred'
| 'Selected model ping not confirmed'
| 'Ready with notes'
| 'Needs attention';
function isSelectedModelDetail(lower: string): boolean {
return lower.includes('selected model');
}
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
function isFormattedModelDetail(lower: string): boolean {
return (
lower.includes(' - checking...') ||
lower.includes(' - verified') ||
lower.includes(' - available for launch') ||
lower.includes(' - compatible for launch') ||
lower.includes(' - compatible, deep verification pending') ||
lower.includes(' - unavailable') ||
lower.includes(' - check failed') ||
lower.includes(' - verification deferred') ||
lower.includes(' - ping not confirmed')
);
}
function isModelDetail(lower: string): boolean {
return isSelectedModelDetail(lower) || isFormattedModelDetail(lower);
}
function isInternalProvisioningDetail(detail: string): boolean {
const normalized = detail.trim().toLowerCase();
return normalized === 'opencode_app_mcp_tool_proof_persisted_cache_hit';
}
function getPublicProvisioningDetails(details: string[]): string[] {
return details.filter((detail) => !isInternalProvisioningDetail(detail));
}
function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
switch (status) {
case 'checking':
return 'checking...';
case 'ready':
return 'OK';
case 'notes':
return 'OK (notes)';
case 'failed':
return 'ERR';
case 'pending':
default:
return 'waiting';
}
}
function getLocalizedStatusLabel(
status: ProvisioningProviderCheckStatus,
t: TeamTranslator
): string {
switch (status) {
case 'checking':
return t('provisioning.providerStatus.status.checking');
case 'ready':
return t('provisioning.providerStatus.status.ready');
case 'notes':
return t('provisioning.providerStatus.status.notes');
case 'failed':
return t('provisioning.providerStatus.status.failed');
case 'pending':
default:
return t('provisioning.providerStatus.status.pending');
}
}
function summarizeDetail(
detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): ProvisioningDetailSummary | null {
const lower = detail.toLowerCase();
if (providerId === 'opencode' && isOpenCodeWindowsAccessDeniedDiagnostic(detail)) {
return 'OpenCode Windows access blocked';
}
if (providerId === 'opencode' && isOpenCodeBridgeNoOutputDiagnostic(detail)) {
return 'OpenCode runtime check returned no output';
}
if (lower.includes('spawn ') && lower.includes(' enoent')) {
return 'CLI binary missing';
}
if (lower.includes('opencode runtime binary is not installed')) {
return 'OpenCode runtime missing';
}
if (
lower.includes('opencode app mcp is unreachable') ||
(lower.includes('unable to connect') &&
(lower.includes('/experimental/tool') || lower.includes('mcp_unavailable')))
) {
return 'OpenCode app MCP unreachable';
}
if (lower.includes('working directory does not exist:')) {
return 'Working directory missing';
}
if (
lower.includes('eacces') ||
lower.includes('enoexec') ||
lower.includes('bad cpu type in executable') ||
lower.includes('image not found')
) {
return 'CLI binary could not be started';
}
if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) {
return 'CLI preflight did not complete';
}
if (lower.includes('not authenticated') || lower.includes('not logged in')) {
return 'Authentication required';
}
if (lower.includes('provider is not configured for runtime use')) {
return 'Runtime provider is not configured';
}
if (lower.includes('claude cli binary failed to start')) {
return 'CLI binary could not be started';
}
if (lower.includes('claude cli preflight check failed')) {
return 'CLI preflight failed';
}
if (isModelDetail(lower) && lower.includes('compatible, deep verification pending')) {
return 'Selected model compatibility pending';
}
if (isModelDetail(lower) && lower.includes('compatible for launch')) {
return 'Selected model compatible';
}
if (isSelectedModelDetail(lower) && lower.includes('verified for launch')) {
return 'Selected model verified';
}
if (isSelectedModelDetail(lower) && lower.includes('available for launch')) {
return 'Selected model available';
}
if (isSelectedModelDetail(lower) && lower.includes('is unavailable')) {
return 'Selected model unavailable';
}
if (
isSelectedModelDetail(lower) &&
lower.includes('could not be verified') &&
lower.includes('timed out')
) {
return 'Selected model verification timed out';
}
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
return 'Selected model check failed';
}
if (isSelectedModelDetail(lower) && lower.includes('verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - verified')) {
return 'Selected model verified';
}
if (lower.includes(' - available for launch')) {
return 'Selected model available';
}
if (lower.includes(' - unavailable -')) {
return 'Selected model unavailable';
}
if (lower.includes(' - check failed') && lower.includes('timed out')) {
return 'Selected model verification timed out';
}
if (lower.includes(' - check failed -')) {
return 'Selected model check failed';
}
if (lower.includes(' - verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - ping not confirmed')) {
return 'Selected model ping not confirmed';
}
if (status === 'notes') {
return 'Ready with notes';
}
if (status === 'failed') {
return 'Needs attention';
}
return null;
}
function localizeProvisioningDetailSummary(
summary: ProvisioningDetailSummary,
t: TeamTranslator
): string {
switch (summary) {
case 'CLI binary missing':
return t('provisioning.providerStatus.detailSummary.cliBinaryMissing');
case 'OpenCode runtime missing':
return t('provisioning.providerStatus.detailSummary.openCodeRuntimeMissing');
case 'OpenCode Windows access blocked':
return t('provisioning.providerStatus.detailSummary.openCodeWindowsAccessBlocked');
case 'OpenCode runtime check returned no output':
return t('provisioning.providerStatus.detailSummary.openCodeNoOutput');
case 'OpenCode app MCP unreachable':
return t('provisioning.providerStatus.detailSummary.openCodeMcpUnreachable');
case 'Working directory missing':
return t('provisioning.providerStatus.detailSummary.workingDirectoryMissing');
case 'CLI binary could not be started':
return t('provisioning.providerStatus.detailSummary.cliBinaryCouldNotStart');
case 'CLI preflight did not complete':
return t('provisioning.providerStatus.detailSummary.cliPreflightIncomplete');
case 'Authentication required':
return t('provisioning.providerStatus.detailSummary.authenticationRequired');
case 'Runtime provider is not configured':
return t('provisioning.providerStatus.detailSummary.runtimeProviderNotConfigured');
case 'CLI preflight failed':
return t('provisioning.providerStatus.detailSummary.cliPreflightFailed');
case 'Selected model compatible':
return t('provisioning.providerStatus.detailSummary.selectedModelCompatible');
case 'Selected model compatibility pending':
return t('provisioning.providerStatus.detailSummary.selectedModelCompatibilityPending');
case 'Selected model available':
return t('provisioning.providerStatus.detailSummary.selectedModelAvailable');
case 'Selected model verified':
return t('provisioning.providerStatus.detailSummary.selectedModelVerified');
case 'Selected model unavailable':
return t('provisioning.providerStatus.detailSummary.selectedModelUnavailable');
case 'Selected model verification timed out':
return t('provisioning.providerStatus.detailSummary.selectedModelTimedOut');
case 'Selected model check failed':
return t('provisioning.providerStatus.detailSummary.selectedModelCheckFailed');
case 'Selected model verification deferred':
return t('provisioning.providerStatus.detailSummary.selectedModelDeferred');
case 'Selected model ping not confirmed':
return t('provisioning.providerStatus.detailSummary.selectedModelPingNotConfirmed');
case 'Ready with notes':
return t('provisioning.providerStatus.detailSummary.readyWithNotes');
case 'Needs attention':
return t('provisioning.providerStatus.detailSummary.needsAttention');
}
}
function getModelDetailSummary(details: string[], t?: TeamTranslator): string | null {
let compatibilityPendingCount = 0;
let compatibleCount = 0;
let availableCount = 0;
let verifiedCount = 0;
let unavailableCount = 0;
let timedOutCount = 0;
let checkFailedCount = 0;
let deferredCount = 0;
let pingNotConfirmedCount = 0;
let checkingCount = 0;
for (const detail of details) {
const lower = detail.toLowerCase();
if (!isModelDetail(lower)) {
continue;
}
if (lower.includes('compatible, deep verification pending')) {
compatibilityPendingCount += 1;
continue;
}
if (lower.includes('compatible for launch')) {
compatibleCount += 1;
continue;
}
if (
lower.includes(' - available for launch') ||
(isSelectedModelDetail(lower) && lower.includes('is available for launch'))
) {
availableCount += 1;
continue;
}
if (
lower.includes(' - verified') ||
(isSelectedModelDetail(lower) && lower.includes('verified for launch'))
) {
verifiedCount += 1;
continue;
}
if (
lower.includes(' - unavailable -') ||
(isSelectedModelDetail(lower) && lower.includes('is unavailable'))
) {
unavailableCount += 1;
continue;
}
if (
lower.includes('timed out') &&
(lower.includes('check failed') ||
(isSelectedModelDetail(lower) && lower.includes('could not be verified')))
) {
timedOutCount += 1;
continue;
}
if (
lower.includes(' - check failed -') ||
(isSelectedModelDetail(lower) && lower.includes('could not be verified'))
) {
checkFailedCount += 1;
continue;
}
if (
lower.includes(' - verification deferred') ||
(isSelectedModelDetail(lower) && lower.includes('verification deferred'))
) {
deferredCount += 1;
continue;
}
if (lower.includes(' - ping not confirmed')) {
pingNotConfirmedCount += 1;
continue;
}
if (lower.includes(' - checking...')) {
checkingCount += 1;
}
}
const parts: string[] = [];
if (unavailableCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.unavailable', { count: unavailableCount })
: `${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`
);
}
if (checkFailedCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.checkFailed', { count: checkFailedCount })
: `${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`
);
}
if (timedOutCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.timedOut', { count: timedOutCount })
: `${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`
);
}
if (deferredCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.deferred', { count: deferredCount })
: `${deferredCount} verification deferred`
);
}
if (pingNotConfirmedCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.pingNotConfirmed', {
count: pingNotConfirmedCount,
})
: `${pingNotConfirmedCount} ping not confirmed`
);
}
if (compatibilityPendingCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.compatibilityPending', {
count: compatibilityPendingCount,
})
: `${compatibilityPendingCount} compatible, deep verification pending`
);
}
if (compatibleCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.compatible', { count: compatibleCount })
: `${compatibleCount} compatible`
);
}
if (checkingCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.checking', { count: checkingCount })
: `${checkingCount} checking`
);
}
if (availableCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.available', { count: availableCount })
: `${availableCount} available`
);
}
if (verifiedCount > 0) {
parts.push(
t
? t('provisioning.providerStatus.modelParts.verified', { count: verifiedCount })
: `${verifiedCount} verified`
);
}
return parts.length > 0
? t
? t('provisioning.providerStatus.modelChecksSummary', { details: parts.join(', ') })
: `Selected model checks - ${parts.join(', ')}`
: null;
}
function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): boolean {
return checks.some((check) =>
check.details.some((detail) =>
detail.toLowerCase().includes('compatible, deep verification pending')
)
);
}
function getDisplayStatusText(check: ProvisioningProviderCheck, t?: TeamTranslator): string {
const publicDetails = getPublicProvisioningDetails(check.details);
const modelSummary = getModelDetailSummary(publicDetails, t);
if (modelSummary) {
return modelSummary;
}
const summarizedDetails = publicDetails
.map((detail) => summarizeDetail(detail, check.status, check.providerId))
.filter((detail): detail is ProvisioningDetailSummary => Boolean(detail));
const summary =
check.status === 'failed'
? (summarizedDetails.find(
(detail) =>
detail === 'OpenCode Windows access blocked' ||
detail === 'OpenCode runtime check returned no output' ||
detail === 'OpenCode app MCP unreachable' ||
detail === 'OpenCode runtime missing' ||
detail === 'Selected model unavailable' ||
detail === 'Selected model check failed' ||
detail === 'Authentication required' ||
detail === 'CLI preflight failed' ||
detail === 'CLI binary could not be started'
) ??
summarizedDetails[0] ??
null)
: (summarizedDetails[0] ?? null);
if (summary) {
return t ? localizeProvisioningDetailSummary(summary, t) : summary;
}
return t ? getLocalizedStatusLabel(check.status, t) : getStatusLabel(check.status);
}
function getDetailTone(
detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): 'success' | 'failure' | 'checking' | 'neutral' {
const summary = summarizeDetail(detail, status, providerId);
if (
summary === 'Selected model verified' ||
summary === 'Selected model available' ||
summary === 'Selected model compatible'
) {
return 'success';
}
if (summary === 'Selected model verification timed out') {
return 'neutral';
}
if (summary === 'Selected model ping not confirmed') {
return 'neutral';
}
if (
summary === 'Selected model unavailable' ||
summary === 'Selected model check failed' ||
summary === 'CLI binary missing' ||
summary === 'OpenCode runtime missing' ||
summary === 'OpenCode Windows access blocked' ||
summary === 'OpenCode runtime check returned no output' ||
summary === 'OpenCode app MCP unreachable' ||
summary === 'Working directory missing' ||
summary === 'CLI binary could not be started' ||
summary === 'CLI preflight did not complete' ||
summary === 'Authentication required' ||
summary === 'Runtime provider is not configured' ||
summary === 'CLI preflight failed' ||
summary === 'Needs attention'
) {
return 'failure';
}
if (detail.toLowerCase().includes(' - checking...')) {
return 'checking';
}
return 'neutral';
}
function getDetailColorClass(
detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): string {
switch (getDetailTone(detail, status, providerId)) {
case 'success':
return 'text-emerald-400';
case 'failure':
return 'text-red-300';
case 'checking':
return 'text-[var(--color-text-secondary)]';
case 'neutral':
default:
return 'text-[var(--color-text-muted)]';
}
}
export function getPrimaryProvisioningFailureDetail(
checks: ProvisioningProviderCheck[]
): string | null {
for (const check of checks) {
if (check.status !== 'failed') {
continue;
}
const publicDetails = getPublicProvisioningDetails(check.details);
const unavailableDetail = publicDetails.find((detail) =>
detail.toLowerCase().includes('selected model') &&
detail.toLowerCase().includes('is unavailable')
? true
: detail.toLowerCase().includes(' - unavailable -')
);
if (unavailableDetail) {
return unavailableDetail;
}
}
for (const check of checks) {
if (check.status !== 'failed') {
continue;
}
const publicDetails = getPublicProvisioningDetails(check.details);
const preferredFailure = publicDetails.find(
(detail) => getDetailTone(detail, check.status, check.providerId) === 'failure'
);
if (preferredFailure) {
return preferredFailure;
}
const nonSuccessDetail = publicDetails.find(
(detail) => getDetailTone(detail, check.status, check.providerId) !== 'success'
);
if (nonSuccessDetail) {
return nonSuccessDetail;
}
if (publicDetails.length > 0) {
return publicDetails[0];
}
}
return null;
}
export function deriveEffectiveProvisioningPrepareState(params: {
state: ProvisioningPrepareState;
message: string | null;
warnings: string[];
checks: ProvisioningProviderCheck[];
t?: TeamTranslator;
}): { state: ProvisioningPrepareState; message: string | null } {
if (params.state !== 'loading') {
return {
state: params.state,
message: params.message,
};
}
if (params.checks.length === 0) {
return {
state: params.state,
message: params.message,
};
}
const hasPendingChecks = params.checks.some(
(check) => check.status === 'pending' || check.status === 'checking'
);
if (hasPendingChecks) {
if (hasCompatibilityPendingDetails(params.checks)) {
return {
state: params.state,
message:
params.t?.('provisioning.providerStatus.deepVerificationPending') ??
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
};
}
return {
state: params.state,
message: params.message,
};
}
if (params.checks.some((check) => check.status === 'failed')) {
return {
state: 'failed',
message:
getPrimaryProvisioningFailureDetail(params.checks) ??
params.message ??
params.t?.('create.prepare.someProvidersNeedAttention') ??
'Some selected providers need attention.',
};
}
const hasNotes =
params.warnings.length > 0 || params.checks.some((check) => check.status === 'notes');
return {
state: 'ready',
message: hasNotes
? (params.t?.('create.prepare.readyWithNotes') ??
'All selected providers are ready, with notes.')
: (params.t?.('create.prepare.ready') ?? 'All selected providers are ready.'),
};
}
export function shouldHideProvisioningProviderStatusList(
checks: ProvisioningProviderCheck[],
message: string | null | undefined
): boolean {
const normalizedMessage = (message ?? '').trim().toLowerCase();
if (!normalizedMessage || checks.length === 0) {
return false;
}
return checks.every((check) => {
if (check.status !== 'failed') {
return false;
}
const summary = getDisplayStatusText(check).toLowerCase();
const visibleDetails = check.details.filter(
(detail) => detail.trim().toLowerCase() !== normalizedMessage
);
return summary === 'working directory missing' && visibleDetails.length === 0;
});
}
function getStatusColor(status: ProvisioningProviderCheckStatus): string {
switch (status) {
case 'ready':
return 'text-emerald-400';
case 'notes':
return 'text-sky-300';
case 'failed':
return 'text-red-300';
case 'checking':
return 'text-[var(--color-text-secondary)]';
case 'pending':
default:
return 'text-[var(--color-text-muted)]';
}
}
const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => {
if (status === 'checking') {
return <Loader2 className="size-3 animate-spin" />;
}
if (status === 'ready') {
return <CheckCircle2 className="size-3" />;
}
if (status === 'notes' || status === 'failed') {
return <AlertTriangle className="size-3" />;
}
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
};
function getProvisioningProviderSettingsActionLabel(
check: ProvisioningProviderCheck,
t?: TeamTranslator
): string | null {
if (check.status !== 'notes' && check.status !== 'failed') {
return null;
}
const details = getPublicProvisioningDetails(check.details);
const combined = [check.backendSummary ?? '', ...details].join('\n').toLowerCase();
if (!combined.trim()) {
return null;
}
const hasActionableProviderSetupDetail =
combined.includes('auth required') ||
combined.includes('authentication required') ||
combined.includes('not authenticated') ||
combined.includes('not logged in') ||
combined.includes('provider is not configured for runtime use') ||
combined.includes('connect a chatgpt account') ||
combined.includes('connected chatgpt account') ||
combined.includes('reconnect chatgpt') ||
combined.includes('openai_api_key') ||
combined.includes('codex_api_key') ||
combined.includes('anthropic_api_key') ||
combined.includes('gemini_api_key') ||
combined.includes('api key mode is selected');
return hasActionableProviderSetupDetail
? t
? t('provisioning.providerStatus.openProviderSettings', {
provider: getProvisioningProviderLabel(check.providerId),
})
: `Open ${getProvisioningProviderLabel(check.providerId)} settings`
: null;
}
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
}
const payloads = (check.supportDiagnostics ?? [])
.map((diagnostic) => diagnostic.copyText.trim())
.filter(Boolean);
return payloads.length > 0 ? payloads.join('\n\n---\n\n') : null;
}
export const ProvisioningProviderStatusList = ({
checks,
className = '',
suppressDetailsMatching,
onOpenProviderSettings,
}: {
checks: ProvisioningProviderCheck[];
className?: string;
suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => {
const { t } = useAppTranslation('team');
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState<string | null>(null);
if (checks.length === 0) {
return null;
}
const copySupportDiagnostics = async (copyKey: string, payload: string): Promise<void> => {
try {
const writeText = globalThis.navigator?.clipboard?.writeText;
if (typeof writeText !== 'function') {
setCopiedDiagnosticsKey(null);
return;
}
await writeText.call(globalThis.navigator.clipboard, payload);
setCopiedDiagnosticsKey(copyKey);
globalThis.setTimeout(() => {
setCopiedDiagnosticsKey((currentKey) => (currentKey === copyKey ? null : currentKey));
}, 1500);
} catch {
setCopiedDiagnosticsKey(null);
}
};
return (
<div className={`space-y-1 pl-5 ${className}`.trim()}>
{checks.map((check) => {
const suppressDetailsMatchingTrimmed = (suppressDetailsMatching ?? '').trim();
const visibleDetails = getPublicProvisioningDetails(check.details).filter(
(detail) => detail.trim() !== suppressDetailsMatchingTrimmed
);
const settingsActionLabel = onOpenProviderSettings
? getProvisioningProviderSettingsActionLabel(check, t)
: null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
supportDiagnosticsPayload && check.supportDiagnostics?.[0]
? `${check.providerId}:${check.supportDiagnostics[0].id}`
: check.providerId;
const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey;
return (
<div key={check.providerId}>
<div
className={`flex items-center gap-1.5 text-[11px] ${getStatusColor(check.status)}`}
>
<StatusIcon status={check.status} />
<span>
{getProvisioningProviderLabel(check.providerId)}
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
{getDisplayStatusText(check, t)}
</span>
</div>
{visibleDetails.length > 0 ? (
<div className="mt-0.5 space-y-0.5 pl-4">
{visibleDetails.map((detail, index) => (
<p
key={`${check.providerId}:${index}:${detail}`}
className={`text-[10px] ${getDetailColorClass(
detail,
check.status,
check.providerId
)}`}
>
{detail}
</p>
))}
</div>
) : null}
{settingsActionLabel ? (
<div className="mt-1 pl-4">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={() => onOpenProviderSettings?.(check.providerId)}
>
<SlidersHorizontal className="size-3" />
{settingsActionLabel}
</button>
</div>
) : null}
{supportDiagnosticsPayload ? (
<div className="mt-1 pl-4">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={() =>
void copySupportDiagnostics(supportDiagnosticsKey, supportDiagnosticsPayload)
}
>
{copiedDiagnostics ? <Check className="size-3" /> : <Copy className="size-3" />}
{copiedDiagnostics
? t('provisioning.providerStatus.copied')
: t('provisioning.providerStatus.copyDiagnostics')}
</button>
</div>
) : null}
</div>
);
})}
</div>
);
};
export function getProvisioningFailureHint(
message: string | null | undefined,
checks: ProvisioningProviderCheck[],
t?: TeamTranslator
): string {
const failedOpenCodeChecks = checks.filter(
(check) => check.providerId === 'opencode' && check.status === 'failed'
);
const hasFailedNonOpenCodeCheck = checks.some(
(check) => check.providerId !== 'opencode' && check.status === 'failed'
);
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
);
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
);
const normalizedMessage = message?.trim() ?? '';
const hasOpenCodeAccessDeniedMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeAccessDenied') ??
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
}
const hasOpenCodeBridgeNoOutputMessage =
failedOpenCodeChecks.length > 0 &&
!hasFailedNonOpenCodeCheck &&
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeBridgeNoOutput') ??
'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'
);
}
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
.join('\n')
.toLowerCase();
if (combined.includes('working directory does not exist:')) {
return (
t?.('provisioning.providerStatus.failureHints.workingDirectoryMissing') ??
'Choose an existing working directory, then reopen this dialog.'
);
}
if (combined.includes('not authenticated') || combined.includes('not logged in')) {
return (
t?.('provisioning.providerStatus.failureHints.authenticationRequired') ??
'Authenticate the required provider in Claude CLI, then reopen this dialog.'
);
}
if (combined.includes('provider is not configured for runtime use')) {
return (
t?.('provisioning.providerStatus.failureHints.runtimeProviderNotConfigured') ??
'Configure the selected provider runtime, then reopen this dialog.'
);
}
if (
combined.includes('opencode cli not detected on path') ||
combined.includes('opencode cli not found') ||
combined.includes('opencode runtime binary is not installed')
) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeRuntimeMissing') ??
'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
);
}
if (
combined.includes('opencode app mcp is unreachable') ||
(combined.includes('unable to connect') &&
(combined.includes('/experimental/tool') || combined.includes('mcp_unavailable')))
) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeAppMcpUnreachable') ??
'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
);
}
if (
combined.includes('spawn ') ||
combined.includes(' enoent') ||
combined.includes('eacces') ||
combined.includes('enoexec') ||
combined.includes('bad cpu type in executable') ||
combined.includes('image not found')
) {
return (
t?.('provisioning.providerStatus.failureHints.cliBinaryMissing') ??
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
}
return (
t?.('provisioning.providerStatus.failureHints.default') ??
'Resolve the issue above, then reopen this dialog.'
);
}