feat: refine team provisioning and task log UX
This commit is contained in:
parent
55b369de96
commit
bc2e1e43d8
46 changed files with 6111 additions and 717 deletions
|
|
@ -642,8 +642,7 @@ export class TeamGraphAdapter {
|
|||
reviewerName: isReviewCycle ? reviewerName : null,
|
||||
reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined,
|
||||
reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined,
|
||||
changePresence:
|
||||
task.changePresence === 'needs_attention' ? 'has_changes' : task.changePresence,
|
||||
changePresence: task.changePresence === 'needs_attention' ? 'unknown' : task.changePresence,
|
||||
displayId: task.displayId ?? undefined,
|
||||
ownerId: ownerMemberId,
|
||||
needsClarification: task.needsClarification ?? null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||
import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper';
|
||||
import { getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||
import type {
|
||||
|
|
@ -16,11 +17,15 @@ function selectPreferredWorktree(worktrees: readonly Worktree[]): Worktree | und
|
|||
}
|
||||
|
||||
function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
||||
if (!repo.worktrees.length || !repo.mostRecentSession) {
|
||||
const selectableWorktrees = repo.worktrees.filter(
|
||||
(worktree) => !isEphemeralProjectPath(worktree.path)
|
||||
);
|
||||
|
||||
if (!selectableWorktrees.length || !repo.mostRecentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredWorktree = selectPreferredWorktree(repo.worktrees);
|
||||
const preferredWorktree = selectPreferredWorktree(selectableWorktrees);
|
||||
if (!preferredWorktree) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -29,7 +34,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
|||
identity: repo.identity?.id ?? `path:${normalizeIdentityPath(preferredWorktree.path)}`,
|
||||
displayName: repo.name,
|
||||
primaryPath: preferredWorktree.path,
|
||||
associatedPaths: repo.worktrees.map((worktree) => worktree.path),
|
||||
associatedPaths: selectableWorktrees.map((worktree) => worktree.path),
|
||||
lastActivityAt: repo.mostRecentSession,
|
||||
providerIds: ['anthropic'],
|
||||
sourceKind: 'claude',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
import path from 'path';
|
||||
|
||||
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||
|
|
@ -186,7 +187,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
|
||||
async #toCandidate(thread: CodexThreadSummary): Promise<RecentProjectCandidate | null> {
|
||||
const cwd = thread.cwd?.trim();
|
||||
if (!cwd) {
|
||||
if (!cwd || isEphemeralProjectPath(cwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -44,8 +45,16 @@ export function useOpenRecentProject(): {
|
|||
const openSyntheticPath = useCallback(
|
||||
async (path: string, associatedPaths: readonly string[]): Promise<void> => {
|
||||
const candidatePaths = associatedPaths.length > 0 ? associatedPaths : [path];
|
||||
const selectableCandidatePaths = candidatePaths.filter(
|
||||
(candidatePath) => !isEphemeralProjectPath(candidatePath)
|
||||
);
|
||||
|
||||
const initialMatch = findMatchingWorktree(repositoryGroups, candidatePaths);
|
||||
if (selectableCandidatePaths.length === 0) {
|
||||
logger.warn('Skipped ephemeral recent project path', { path });
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMatch = findMatchingWorktree(repositoryGroups, selectableCandidatePaths);
|
||||
if (initialMatch) {
|
||||
navigateToMatch(initialMatch);
|
||||
return;
|
||||
|
|
@ -53,12 +62,17 @@ export function useOpenRecentProject(): {
|
|||
|
||||
await fetchRepositoryGroups();
|
||||
const refreshedGroups = useStore.getState().repositoryGroups;
|
||||
const refreshedMatch = findMatchingWorktree(refreshedGroups, candidatePaths);
|
||||
const refreshedMatch = findMatchingWorktree(refreshedGroups, selectableCandidatePaths);
|
||||
if (refreshedMatch) {
|
||||
navigateToMatch(refreshedMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEphemeralProjectPath(path)) {
|
||||
logger.warn('Skipped adding ephemeral recent project path', { path });
|
||||
return;
|
||||
}
|
||||
|
||||
await api.config.addCustomProjectPath(path);
|
||||
|
||||
useStore.setState((state) => ({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
|
||||
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
||||
|
|
@ -24,6 +26,9 @@ function normalizeHistoryPath(projectPath: string): string | null {
|
|||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
if (isEphemeralProjectPath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) {
|
||||
while (normalizedPath.endsWith('/')) {
|
||||
normalizedPath = normalizedPath.slice(0, -1);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ function createPrimaryLaneMemberState(params: {
|
|||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export class CliProviderModelAvailabilityService {
|
|||
const { env, providerArgs } = await entry.cliEnvPromise;
|
||||
const { stdout } = await execCli(
|
||||
context.binaryPath,
|
||||
[...buildProviderModelProbeArgs(modelId), ...providerArgs],
|
||||
[...providerArgs, ...buildProviderModelProbeArgs(modelId)],
|
||||
{
|
||||
timeout: getProviderModelProbeTimeoutMs(context.provider.providerId),
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -395,6 +395,22 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
const members = params.members ?? {};
|
||||
const launchPhase = params.launchPhase ?? 'active';
|
||||
|
||||
for (const name of expectedMembers) {
|
||||
if (members[name]) {
|
||||
continue;
|
||||
}
|
||||
members[name] = {
|
||||
name,
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: updatedAt,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
// When the launch is over (finished/reconciled), members still in 'starting' state
|
||||
// (never spawned — agentToolAccepted is false) are unreachable and should be marked
|
||||
// as failed. Without this, they stay as 'pending' forever, causing the UI to show
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ const RATE_LIMITED_TOKENS = [
|
|||
'cooling down',
|
||||
];
|
||||
const AUTH_ERROR_TOKENS = [
|
||||
'auth_unavailable',
|
||||
'no auth available',
|
||||
'authentication_failed',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'authentication',
|
||||
'api key',
|
||||
'does not have access',
|
||||
'please run /login',
|
||||
];
|
||||
const NETWORK_ERROR_TOKENS = [
|
||||
'timeout',
|
||||
|
|
@ -295,8 +300,11 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
if (start > 0) {
|
||||
lines.shift();
|
||||
}
|
||||
const now = Date.now();
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const advisory = this.extractApiRetryAdvisory(lines[index]?.trim() ?? '');
|
||||
const line = lines[index]?.trim() ?? '';
|
||||
const advisory =
|
||||
this.extractApiRetryAdvisory(line, now) ?? this.extractApiErrorAdvisory(line, now);
|
||||
if (advisory) {
|
||||
return advisory;
|
||||
}
|
||||
|
|
@ -309,7 +317,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
}
|
||||
}
|
||||
|
||||
private extractApiRetryAdvisory(line: string): MemberRuntimeAdvisory | null {
|
||||
private extractApiRetryAdvisory(line: string, now = Date.now()): MemberRuntimeAdvisory | null {
|
||||
if (
|
||||
!line ||
|
||||
(!line.includes('"subtype":"api_error"') && !line.includes('"subtype": "api_error"'))
|
||||
|
|
@ -351,7 +359,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
}
|
||||
|
||||
const retryUntil = observedAt + retryInMs;
|
||||
if (retryUntil <= Date.now()) {
|
||||
if (retryUntil <= now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -373,4 +381,71 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractApiErrorAdvisory(line: string, now = Date.now()): MemberRuntimeAdvisory | null {
|
||||
if (
|
||||
!line ||
|
||||
(!line.includes('"isApiErrorMessage":true') &&
|
||||
!line.includes('"isApiErrorMessage": true') &&
|
||||
!line.includes('"error":"authentication_failed"') &&
|
||||
!line.includes('"error": "authentication_failed"'))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line) as {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
error?: string;
|
||||
isApiErrorMessage?: boolean;
|
||||
message?: {
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
if (parsed.type !== 'assistant') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const observedAt =
|
||||
typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
|
||||
if (!Number.isFinite(observedAt) || observedAt < now - LOOKBACK_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = this.extractAssistantText(parsed.message?.content);
|
||||
if (!parsed.isApiErrorMessage && parsed.error !== 'authentication_failed') {
|
||||
return null;
|
||||
}
|
||||
if (!message && parsed.error !== 'authentication_failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusMatch = /^API Error:\s*(\d{3})/.exec(message);
|
||||
return {
|
||||
kind: 'api_error',
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
reasonCode: classifyRetryReason(message || parsed.error),
|
||||
...(message ? { message } : {}),
|
||||
...(statusMatch ? { statusCode: Number(statusMatch[1]) } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractAssistantText(
|
||||
content: Array<{ type?: string; text?: string }> | undefined
|
||||
): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
return content
|
||||
.filter((item) => item.type === 'text' && typeof item.text === 'string')
|
||||
.map((item) => item.text?.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,12 +101,9 @@ import {
|
|||
} from '../runtime/geminiRuntimeAuth';
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
import {
|
||||
buildProviderModelProbeArgs,
|
||||
buildProviderPreflightPingArgs,
|
||||
classifyProviderModelProbeFailure,
|
||||
getProviderModelProbeExpectedOutput,
|
||||
getProviderModelProbeTimeoutMs,
|
||||
isProviderModelProbeSuccessOutput,
|
||||
normalizeProviderModelProbeFailureReason,
|
||||
} from '../runtime/providerModelProbe';
|
||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||
|
|
@ -438,7 +435,6 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
|||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2;
|
||||
const PROVIDER_MODEL_PROBE_CONCURRENCY = 2;
|
||||
|
||||
function applyDistinctProvisioningMemberColors<
|
||||
T extends { name: string; color?: string; removedAt?: number },
|
||||
|
|
@ -521,6 +517,10 @@ function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
|||
return getProviderModelProbeTimeoutMs(providerId);
|
||||
}
|
||||
|
||||
function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): string[] {
|
||||
return [...providerArgs, ...args];
|
||||
}
|
||||
|
||||
interface ProviderModelListCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
|
|
@ -536,6 +536,12 @@ interface RuntimeStatusCommandResponse {
|
|||
providers?: Record<string, Partial<CliProviderStatus>>;
|
||||
}
|
||||
|
||||
interface AuthStatusCommandResponse {
|
||||
loggedIn?: boolean;
|
||||
authMethod?: string | null;
|
||||
providers?: Record<string, Partial<CliProviderStatus>>;
|
||||
}
|
||||
|
||||
interface RuntimeProviderLaunchFacts {
|
||||
defaultModel: string | null;
|
||||
modelIds: Set<string>;
|
||||
|
|
@ -701,21 +707,6 @@ function isProbeTimeoutMessage(message: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isTransientModelProbeMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('500') ||
|
||||
lower.includes('502') ||
|
||||
lower.includes('503') ||
|
||||
lower.includes('504')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequestedLaunchModel(params: {
|
||||
providerId: TeamProviderId;
|
||||
selectedModel?: string;
|
||||
|
|
@ -1821,6 +1812,18 @@ function isMissingCwdSpawnError(message: string): boolean {
|
|||
return lower.includes('spawn ') && lower.includes(' enoent');
|
||||
}
|
||||
|
||||
async function pathExistsAsDirectory(candidatePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(candidatePath);
|
||||
return stat.isDirectory();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
|
||||
const wrapInAgentBlock = wrapAgentBlock;
|
||||
|
||||
|
|
@ -3389,6 +3392,8 @@ function isTransientProbeWarning(warning: string): boolean {
|
|||
return (
|
||||
lower.includes('timeout running:') ||
|
||||
lower.includes('did not complete') ||
|
||||
lower.includes('runtime status was unavailable') ||
|
||||
lower.includes('runtime status check did not complete') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('etimedout') ||
|
||||
lower.includes('econnreset') ||
|
||||
|
|
@ -3396,14 +3401,6 @@ function isTransientProbeWarning(warning: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isRecoverableGenericPreflightWarning(warning: string): boolean {
|
||||
const lower = warning.toLowerCase();
|
||||
return (
|
||||
lower.includes('preflight check failed') ||
|
||||
lower.includes('preflight ping completed but did not return the expected pong')
|
||||
);
|
||||
}
|
||||
|
||||
function isBinaryProbeWarning(warning: string): boolean {
|
||||
const lower = warning.toLowerCase();
|
||||
return (
|
||||
|
|
@ -3570,7 +3567,13 @@ export class TeamProvisioningService {
|
|||
const providerArgs = params.providerArgs ?? [];
|
||||
const modelListPromise = execCli(
|
||||
params.claudePath,
|
||||
['model', 'list', '--json', '--provider', params.providerId, ...providerArgs],
|
||||
buildProviderCliCommandArgs(providerArgs, [
|
||||
'model',
|
||||
'list',
|
||||
'--json',
|
||||
'--provider',
|
||||
params.providerId,
|
||||
]),
|
||||
{
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
|
|
@ -3581,7 +3584,13 @@ export class TeamProvisioningService {
|
|||
params.providerId === 'codex' || params.providerId === 'anthropic'
|
||||
? execCli(
|
||||
params.claudePath,
|
||||
['runtime', 'status', '--json', '--provider', params.providerId, ...providerArgs],
|
||||
buildProviderCliCommandArgs(providerArgs, [
|
||||
'runtime',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
params.providerId,
|
||||
]),
|
||||
{
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
|
|
@ -7425,8 +7434,39 @@ export class TeamProvisioningService {
|
|||
blockingMessages.push(...modelVerification.blockingMessages);
|
||||
};
|
||||
|
||||
const appendOneShotDiagnostic = async (): Promise<void> => {
|
||||
if (opts?.modelVerificationMode !== 'deep') {
|
||||
return;
|
||||
}
|
||||
const envResolution = await this.buildProvisioningEnv(providerId);
|
||||
if (envResolution.warning) {
|
||||
warnings.push(
|
||||
providerIds.length > 1
|
||||
? `${providerLabel}: ${envResolution.warning}`
|
||||
: envResolution.warning
|
||||
);
|
||||
return;
|
||||
}
|
||||
const diagnostic = await this.runProviderOneShotDiagnostic(
|
||||
probeResult.claudePath,
|
||||
targetCwd,
|
||||
envResolution.env,
|
||||
providerId,
|
||||
envResolution.providerArgs
|
||||
);
|
||||
if (diagnostic.warning) {
|
||||
warnings.push(
|
||||
providerIds.length > 1 ? `${providerLabel}: ${diagnostic.warning}` : diagnostic.warning
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!probeResult.warning) {
|
||||
const blockingCountBeforeModelChecks = blockingMessages.length;
|
||||
await appendSelectedModelVerification();
|
||||
if (blockingMessages.length === blockingCountBeforeModelChecks) {
|
||||
await appendOneShotDiagnostic();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -7455,13 +7495,15 @@ export class TeamProvisioningService {
|
|||
} else {
|
||||
// Preflight warnings (including timeouts) should not block provisioning.
|
||||
warnings.push(prefixedWarning);
|
||||
const blockingCountBeforeModelChecks = blockingMessages.length;
|
||||
if (!isBlockingPreflightWarning && selectedModelIds.length > 0) {
|
||||
await appendSelectedModelVerification();
|
||||
}
|
||||
if (
|
||||
!isBlockingPreflightWarning &&
|
||||
(isTransientProbeWarning(probeResult.warning) ||
|
||||
isRecoverableGenericPreflightWarning(probeResult.warning)) &&
|
||||
selectedModelIds.length > 0
|
||||
blockingMessages.length === blockingCountBeforeModelChecks
|
||||
) {
|
||||
await appendSelectedModelVerification();
|
||||
await appendOneShotDiagnostic();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7826,6 +7868,89 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private resolveProviderCompatibilityModel(params: {
|
||||
providerId: TeamProviderId;
|
||||
requestedModelId: string;
|
||||
runtimeFacts: RuntimeProviderLaunchFacts;
|
||||
limitContext: boolean;
|
||||
}):
|
||||
| { kind: 'available'; resolvedModelId: string | null }
|
||||
| { kind: 'compatible'; reason: string }
|
||||
| { kind: 'unavailable'; reason: string } {
|
||||
const trimmedModelId = params.requestedModelId.trim();
|
||||
if (!trimmedModelId) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'Selected model id is empty.',
|
||||
};
|
||||
}
|
||||
|
||||
if (isDefaultProviderModelSelection(trimmedModelId)) {
|
||||
return {
|
||||
kind: 'available',
|
||||
resolvedModelId: params.runtimeFacts.defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
const availableModels = params.runtimeFacts.modelIds;
|
||||
let resolvedModelId: string | null = availableModels.has(trimmedModelId)
|
||||
? trimmedModelId
|
||||
: null;
|
||||
|
||||
if (!resolvedModelId && params.providerId === 'anthropic') {
|
||||
resolvedModelId =
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: trimmedModelId,
|
||||
limitContext: params.limitContext,
|
||||
availableLaunchModels: availableModels,
|
||||
defaultLaunchModel: params.runtimeFacts.defaultModel,
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
if (!resolvedModelId && !trimmedModelId.includes('/')) {
|
||||
const scopedMatches = Array.from(availableModels).filter(
|
||||
(candidate) => candidate.split('/').at(-1) === trimmedModelId
|
||||
);
|
||||
if (scopedMatches.length === 1) {
|
||||
resolvedModelId = scopedMatches[0];
|
||||
} else if (scopedMatches.length > 1) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason:
|
||||
`Selected model ${trimmedModelId} matched multiple live provider models: ` +
|
||||
scopedMatches.join(', '),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedModelId && (availableModels.size === 0 || availableModels.has(resolvedModelId))) {
|
||||
return {
|
||||
kind: 'available',
|
||||
resolvedModelId,
|
||||
};
|
||||
}
|
||||
|
||||
const dynamicCatalog = params.runtimeFacts.runtimeCapabilities?.modelCatalog?.dynamic === true;
|
||||
const hasAuthoritativeCatalog =
|
||||
availableModels.size > 0 ||
|
||||
params.runtimeFacts.modelCatalog != null ||
|
||||
params.runtimeFacts.runtimeCapabilities?.modelCatalog?.dynamic === false;
|
||||
|
||||
if (dynamicCatalog || !hasAuthoritativeCatalog) {
|
||||
return {
|
||||
kind: 'compatible',
|
||||
reason: dynamicCatalog
|
||||
? 'Runtime catalog allows dynamic model launch.'
|
||||
: 'Runtime model catalog was unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: `Selected model ${trimmedModelId} was not found in the live provider catalog.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async verifySelectedProviderModels({
|
||||
claudePath,
|
||||
cwd,
|
||||
|
|
@ -7861,39 +7986,28 @@ export class TeamProvisioningService {
|
|||
providerArgs,
|
||||
limitContext,
|
||||
});
|
||||
const probeOutcomeByResolvedModelId = new Map<
|
||||
string,
|
||||
{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||
>();
|
||||
let resolvedDefaultModelId: string | null | undefined;
|
||||
const plannedModels: (
|
||||
| { requestedModelId: string; targetModelId: string }
|
||||
| {
|
||||
requestedModelId: string;
|
||||
immediateOutcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string };
|
||||
}
|
||||
)[] = [];
|
||||
|
||||
const recordOutcome = (
|
||||
requestedModelId: string,
|
||||
outcome: { kind: 'ready' | 'warning' | 'unavailable'; reason?: string }
|
||||
outcome:
|
||||
| { kind: 'available'; resolvedModelId: string | null }
|
||||
| { kind: 'compatible'; reason: string }
|
||||
| { kind: 'unavailable'; reason: string }
|
||||
): void => {
|
||||
if (outcome.kind === 'ready') {
|
||||
details.push(`Selected model ${requestedModelId} verified for launch.`);
|
||||
if (outcome.kind === 'available') {
|
||||
details.push(`Selected model ${requestedModelId} is available for launch.`);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'unavailable') {
|
||||
blockingMessages.push(
|
||||
`Selected model ${requestedModelId} is unavailable. ${outcome.reason ?? 'Model verification failed'}`
|
||||
if (outcome.kind === 'compatible') {
|
||||
details.push(
|
||||
`Selected model ${requestedModelId} is compatible. Deep verification pending.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
warnings.push(
|
||||
`Selected model ${requestedModelId} could not be verified. ${outcome.reason ?? 'Model verification failed'}`
|
||||
);
|
||||
blockingMessages.push(`Selected model ${requestedModelId} is unavailable. ${outcome.reason}`);
|
||||
};
|
||||
|
||||
appendPreflightDebugLog('provider_model_probe_batch_start', {
|
||||
appendPreflightDebugLog('provider_model_catalog_check_start', {
|
||||
providerId,
|
||||
cwd,
|
||||
modelIds,
|
||||
|
|
@ -7905,162 +8019,23 @@ export class TeamProvisioningService {
|
|||
continue;
|
||||
}
|
||||
|
||||
let targetModelId = label;
|
||||
if (isDefaultProviderModelSelection(label)) {
|
||||
if (resolvedDefaultModelId === undefined) {
|
||||
resolvedDefaultModelId = runtimeFacts.defaultModel;
|
||||
if (!resolvedDefaultModelId) {
|
||||
try {
|
||||
resolvedDefaultModelId = await this.resolveProviderDefaultModel(
|
||||
claudePath,
|
||||
cwd,
|
||||
providerId,
|
||||
env,
|
||||
providerArgs,
|
||||
limitContext
|
||||
);
|
||||
} catch {
|
||||
resolvedDefaultModelId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resolvedDefaultModelId) {
|
||||
plannedModels.push({
|
||||
requestedModelId: label,
|
||||
immediateOutcome: {
|
||||
kind: 'warning',
|
||||
reason: 'Could not resolve the runtime default model',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
targetModelId = resolvedDefaultModelId;
|
||||
} else if (providerId === 'anthropic') {
|
||||
const resolvedAnthropicModel = resolveAnthropicLaunchModel({
|
||||
selectedModel: label,
|
||||
recordOutcome(
|
||||
label,
|
||||
this.resolveProviderCompatibilityModel({
|
||||
providerId,
|
||||
requestedModelId: label,
|
||||
runtimeFacts,
|
||||
limitContext,
|
||||
availableLaunchModels: runtimeFacts.modelIds,
|
||||
defaultLaunchModel: runtimeFacts.defaultModel,
|
||||
});
|
||||
if (resolvedAnthropicModel) {
|
||||
targetModelId = resolvedAnthropicModel;
|
||||
}
|
||||
}
|
||||
|
||||
plannedModels.push({
|
||||
requestedModelId: label,
|
||||
targetModelId,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueTargetModelIds = Array.from(
|
||||
new Set(
|
||||
plannedModels.flatMap((entry) => ('targetModelId' in entry ? [entry.targetModelId] : []))
|
||||
)
|
||||
);
|
||||
|
||||
const runProbeForModel = async (
|
||||
targetModelId: string
|
||||
): Promise<{ kind: 'ready' | 'warning' | 'unavailable'; reason?: string }> => {
|
||||
const cachedOutcome = probeOutcomeByResolvedModelId.get(targetModelId);
|
||||
if (cachedOutcome) {
|
||||
return cachedOutcome;
|
||||
}
|
||||
|
||||
const probeStartedAt = Date.now();
|
||||
try {
|
||||
const result = await this.spawnProbe(
|
||||
claudePath,
|
||||
[...buildProviderModelProbeArgs(targetModelId), ...providerArgs],
|
||||
cwd,
|
||||
env,
|
||||
getProviderModelProbeTimeoutMs(providerId),
|
||||
{
|
||||
resolveOnOutputMatch: ({ stdout, stderr }) =>
|
||||
isProviderModelProbeSuccessOutput(`${stdout}\n${stderr}`),
|
||||
}
|
||||
);
|
||||
const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim();
|
||||
const outcome =
|
||||
result.exitCode === 0 && isProviderModelProbeSuccessOutput(combinedOutput)
|
||||
? ({ kind: 'ready' } as const)
|
||||
: classifyProviderModelProbeFailure(
|
||||
combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`
|
||||
) === 'unavailable'
|
||||
? ({
|
||||
kind: 'unavailable',
|
||||
reason: normalizeProviderModelProbeFailureReason(
|
||||
combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`
|
||||
),
|
||||
} as const)
|
||||
: ({
|
||||
kind: 'warning',
|
||||
reason: normalizeProviderModelProbeFailureReason(
|
||||
combinedOutput || `Probe exited with code ${result.exitCode ?? 'unknown'}.`
|
||||
),
|
||||
} as const);
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
appendPreflightDebugLog('provider_model_probe_result', {
|
||||
providerId,
|
||||
cwd,
|
||||
targetModelId,
|
||||
durationMs: Date.now() - probeStartedAt,
|
||||
outcome,
|
||||
});
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.trim() : String(error).trim();
|
||||
const normalizedMessage = normalizeProviderModelProbeFailureReason(message);
|
||||
const outcome =
|
||||
classifyProviderModelProbeFailure(message) === 'unavailable' &&
|
||||
!isTransientModelProbeMessage(message)
|
||||
? ({ kind: 'unavailable', reason: normalizedMessage } as const)
|
||||
: ({ kind: 'warning', reason: normalizedMessage } as const);
|
||||
probeOutcomeByResolvedModelId.set(targetModelId, outcome);
|
||||
appendPreflightDebugLog('provider_model_probe_result', {
|
||||
providerId,
|
||||
cwd,
|
||||
targetModelId,
|
||||
durationMs: Date.now() - probeStartedAt,
|
||||
outcome,
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(PROVIDER_MODEL_PROBE_CONCURRENCY, uniqueTargetModelIds.length);
|
||||
let nextProbeIndex = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = nextProbeIndex;
|
||||
nextProbeIndex += 1;
|
||||
if (currentIndex >= uniqueTargetModelIds.length) {
|
||||
return;
|
||||
}
|
||||
await runProbeForModel(uniqueTargetModelIds[currentIndex]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const entry of plannedModels) {
|
||||
if ('immediateOutcome' in entry) {
|
||||
recordOutcome(entry.requestedModelId, entry.immediateOutcome);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outcome = probeOutcomeByResolvedModelId.get(entry.targetModelId) ?? {
|
||||
kind: 'warning' as const,
|
||||
reason: 'Model verification failed',
|
||||
};
|
||||
recordOutcome(entry.requestedModelId, outcome);
|
||||
}
|
||||
|
||||
appendPreflightDebugLog('provider_model_probe_batch_complete', {
|
||||
appendPreflightDebugLog('provider_model_catalog_check_complete', {
|
||||
providerId,
|
||||
cwd,
|
||||
modelIds,
|
||||
durationMs: Date.now() - startedAt,
|
||||
modelCount: runtimeFacts.modelIds.size,
|
||||
details,
|
||||
warnings,
|
||||
blockingMessages,
|
||||
|
|
@ -8079,7 +8054,7 @@ export class TeamProvisioningService {
|
|||
): Promise<string | null> {
|
||||
const { stdout } = await execCli(
|
||||
claudePath,
|
||||
['model', 'list', '--json', '--provider', 'all', ...providerArgs],
|
||||
buildProviderCliCommandArgs(providerArgs, ['model', 'list', '--json', '--provider', 'all']),
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
|
|
@ -11877,6 +11852,48 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private filterRemovedMembersFromLaunchSnapshot(
|
||||
snapshot: PersistedTeamLaunchSnapshot,
|
||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>
|
||||
): PersistedTeamLaunchSnapshot {
|
||||
const removedNames = new Set(
|
||||
metaMembers
|
||||
.filter((member) => Boolean(member.removedAt))
|
||||
.map((member) => member.name?.trim().toLowerCase() ?? '')
|
||||
.filter((name) => name.length > 0)
|
||||
);
|
||||
if (removedNames.size === 0) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const isRemoved = (name: string | undefined): boolean => {
|
||||
const normalized = name?.trim().toLowerCase() ?? '';
|
||||
return normalized.length > 0 && removedNames.has(normalized);
|
||||
};
|
||||
const expectedMembers = this.getPersistedLaunchMemberNames(snapshot).filter(
|
||||
(name) => !isRemoved(name)
|
||||
);
|
||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
for (const [memberName, member] of Object.entries(snapshot.members)) {
|
||||
if (isRemoved(memberName) || isRemoved(member.name)) {
|
||||
continue;
|
||||
}
|
||||
members[memberName] = { ...member };
|
||||
}
|
||||
|
||||
return createPersistedLaunchSnapshot({
|
||||
teamName: snapshot.teamName,
|
||||
expectedMembers,
|
||||
bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers?.filter(
|
||||
(name) => !isRemoved(name)
|
||||
),
|
||||
leadSessionId: snapshot.leadSessionId,
|
||||
launchPhase: snapshot.launchPhase,
|
||||
members,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private findEffectiveRunMemberModel(
|
||||
run: ProvisioningRun | null,
|
||||
memberName: string
|
||||
|
|
@ -13234,25 +13251,39 @@ export class TeamProvisioningService {
|
|||
}> {
|
||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName);
|
||||
const persisted = await this.launchStateStore.read(teamName);
|
||||
const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []);
|
||||
const recoveredMixedSnapshot = await this.recoverStaleMixedSecondaryLaunchSnapshot(
|
||||
teamName,
|
||||
bootstrapSnapshot,
|
||||
persisted
|
||||
);
|
||||
if (recoveredMixedSnapshot) {
|
||||
const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(
|
||||
recoveredMixedSnapshot,
|
||||
metaMembers
|
||||
);
|
||||
return {
|
||||
snapshot: recoveredMixedSnapshot,
|
||||
statuses: snapshotToMemberSpawnStatuses(recoveredMixedSnapshot),
|
||||
snapshot: filteredSnapshot,
|
||||
statuses: snapshotToMemberSpawnStatuses(filteredSnapshot),
|
||||
};
|
||||
}
|
||||
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, persisted);
|
||||
if (preferredSnapshot && preferredSnapshot === bootstrapSnapshot) {
|
||||
const filteredBootstrapSnapshot = bootstrapSnapshot
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
||||
: null;
|
||||
const filteredPersisted = persisted
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers)
|
||||
: null;
|
||||
const preferredSnapshot = choosePreferredLaunchSnapshot(
|
||||
filteredBootstrapSnapshot,
|
||||
filteredPersisted
|
||||
);
|
||||
if (preferredSnapshot && preferredSnapshot === filteredBootstrapSnapshot) {
|
||||
return {
|
||||
snapshot: preferredSnapshot,
|
||||
statuses: snapshotToMemberSpawnStatuses(preferredSnapshot),
|
||||
};
|
||||
}
|
||||
if (!persisted) {
|
||||
if (!filteredPersisted) {
|
||||
return { snapshot: null, statuses: {} };
|
||||
}
|
||||
|
||||
|
|
@ -13287,8 +13318,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const liveAgentNames = await this.getLiveTeamAgentNames(teamName);
|
||||
const nextMembers = { ...persisted.members };
|
||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(persisted);
|
||||
const nextMembers = { ...filteredPersisted.members };
|
||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
|
||||
const now = nowIso();
|
||||
for (const expected of persistedMemberNames) {
|
||||
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
||||
|
|
@ -13431,8 +13462,8 @@ export class TeamProvisioningService {
|
|||
const reconciled = createPersistedLaunchSnapshot({
|
||||
teamName,
|
||||
expectedMembers: persistedMemberNames,
|
||||
leadSessionId: persisted.leadSessionId,
|
||||
launchPhase: persisted.launchPhase === 'active' ? 'active' : 'reconciled',
|
||||
leadSessionId: filteredPersisted.leadSessionId,
|
||||
launchPhase: filteredPersisted.launchPhase === 'active' ? 'active' : 'reconciled',
|
||||
members: nextMembers,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
@ -18482,11 +18513,11 @@ export class TeamProvisioningService {
|
|||
|
||||
/**
|
||||
* Two-stage preflight check:
|
||||
* 1. `claude --version` — verifies binary is executable and returns version info.
|
||||
* (currently disabled for speed; keep commented for debugging)
|
||||
* 2. `claude -p "ping"` — verifies that `-p` mode is actually authenticated.
|
||||
* This catches the common case where interactive `claude` works (OAuth/keychain)
|
||||
* but `-p` mode fails with "Not logged in" due to missing env vars.
|
||||
* 1. `claude --version` verifies the binary is executable.
|
||||
* 2. Runtime control-plane commands verify provider auth/team-launch readiness.
|
||||
*
|
||||
* Do not use `-p` here: full print mode can initialize MCP/plugin/LSP startup context
|
||||
* before the first response, which makes Create Team preflight slow and flaky.
|
||||
*/
|
||||
private async probeClaudeRuntime(
|
||||
claudePath: string,
|
||||
|
|
@ -18497,6 +18528,12 @@ export class TeamProvisioningService {
|
|||
): Promise<{ warning?: string }> {
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const cliCommandLabel = getConfiguredCliCommandLabel();
|
||||
if (!(await pathExistsAsDirectory(cwd))) {
|
||||
return {
|
||||
warning: `Working directory does not exist: ${cwd}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
|
|
@ -18515,7 +18552,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isMissingCwdSpawnError(message)) {
|
||||
if (isMissingCwdSpawnError(message) && !(await pathExistsAsDirectory(cwd))) {
|
||||
return {
|
||||
warning: `Working directory does not exist: ${cwd}`,
|
||||
};
|
||||
|
|
@ -18537,30 +18574,221 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
// Stage 1: verify binary works (awaited first for clearer errors)
|
||||
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
||||
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
||||
// const versionProbe = await this.spawnProbe(
|
||||
// claudePath,
|
||||
// ['--version'],
|
||||
// cwd,
|
||||
// env,
|
||||
// CLI_PREPARE_TIMEOUT_MS
|
||||
// );
|
||||
// if (versionProbe.exitCode !== 0) {
|
||||
// const errorText =
|
||||
// buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
||||
// `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||
// throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
|
||||
// }
|
||||
if (resolvedProviderId === 'anthropic' || resolvedProviderId === 'codex') {
|
||||
return await this.probeProviderRuntimeControlPlane({
|
||||
claudePath,
|
||||
cwd,
|
||||
env,
|
||||
providerId: resolvedProviderId,
|
||||
providerArgs,
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private buildRuntimeProviderReadinessWarning(
|
||||
providerId: TeamProviderId,
|
||||
providerStatus: Partial<CliProviderStatus> | null | undefined
|
||||
): string | null {
|
||||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const detail = [providerStatus?.statusMessage?.trim(), providerStatus?.detailMessage?.trim()]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(' ');
|
||||
|
||||
if (!providerStatus) {
|
||||
return `${providerLabel} provider is not configured for runtime use. Runtime status did not include this provider.`;
|
||||
}
|
||||
if (providerStatus.supported === false) {
|
||||
return `${providerLabel} provider is not configured for runtime use.${
|
||||
detail ? ` ${detail}` : ''
|
||||
}`;
|
||||
}
|
||||
if (providerStatus.authenticated === false) {
|
||||
return `${providerLabel} provider is not authenticated.${detail ? ` ${detail}` : ''}`;
|
||||
}
|
||||
if (providerStatus.capabilities?.teamLaunch === false) {
|
||||
return `${providerLabel} provider is not configured for runtime use. Team launch is unavailable.${
|
||||
detail ? ` ${detail}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractAuthStatusReadiness(
|
||||
providerId: TeamProviderId,
|
||||
parsed: AuthStatusCommandResponse
|
||||
): {
|
||||
authenticated: boolean | null;
|
||||
providerStatus: Partial<CliProviderStatus> | null;
|
||||
} {
|
||||
const providerStatus = parsed.providers?.[providerId] ?? null;
|
||||
if (typeof providerStatus?.authenticated === 'boolean') {
|
||||
return {
|
||||
authenticated: providerStatus.authenticated,
|
||||
providerStatus,
|
||||
};
|
||||
}
|
||||
if (typeof parsed.loggedIn === 'boolean') {
|
||||
return {
|
||||
authenticated: parsed.loggedIn,
|
||||
providerStatus,
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: null,
|
||||
providerStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private async probeProviderRuntimeControlPlane({
|
||||
claudePath,
|
||||
cwd,
|
||||
env,
|
||||
providerId,
|
||||
providerArgs,
|
||||
}: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
providerId: TeamProviderId;
|
||||
providerArgs: string[];
|
||||
}): Promise<{ warning?: string }> {
|
||||
const cliCommandLabel = getConfiguredCliCommandLabel();
|
||||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
|
||||
try {
|
||||
const runtimeStatus = await execCli(
|
||||
claudePath,
|
||||
buildProviderCliCommandArgs(providerArgs, [
|
||||
'runtime',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
providerId,
|
||||
]),
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeout: 8_000,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObjectFromCli<RuntimeStatusCommandResponse>(runtimeStatus.stdout);
|
||||
const providerStatus = parsed.providers?.[providerId] ?? null;
|
||||
const warning = this.buildRuntimeProviderReadinessWarning(providerId, providerStatus);
|
||||
appendPreflightDebugLog('provider_runtime_control_plane_status', {
|
||||
providerId,
|
||||
cwd,
|
||||
ready: !warning,
|
||||
authenticated: providerStatus?.authenticated,
|
||||
teamLaunch: providerStatus?.capabilities?.teamLaunch,
|
||||
oneShot: providerStatus?.capabilities?.oneShot,
|
||||
warning,
|
||||
});
|
||||
return warning ? { warning } : {};
|
||||
} catch (runtimeStatusError) {
|
||||
const runtimeStatusMessage =
|
||||
runtimeStatusError instanceof Error
|
||||
? runtimeStatusError.message
|
||||
: String(runtimeStatusError);
|
||||
try {
|
||||
const authStatus = await execCli(
|
||||
claudePath,
|
||||
buildProviderCliCommandArgs(providerArgs, [
|
||||
'auth',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
providerId,
|
||||
]),
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeout: 8_000,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObjectFromCli<AuthStatusCommandResponse>(authStatus.stdout);
|
||||
const authReadiness = this.extractAuthStatusReadiness(providerId, parsed);
|
||||
const readinessWarning = authReadiness.providerStatus
|
||||
? this.buildRuntimeProviderReadinessWarning(providerId, authReadiness.providerStatus)
|
||||
: null;
|
||||
if (authReadiness.authenticated === false || readinessWarning) {
|
||||
const authWarning =
|
||||
readinessWarning ??
|
||||
`${providerLabel} provider is not authenticated. Runtime auth status reported logged out.`;
|
||||
appendPreflightDebugLog('provider_runtime_control_plane_auth_fallback', {
|
||||
providerId,
|
||||
cwd,
|
||||
ready: false,
|
||||
runtimeStatusError: runtimeStatusMessage,
|
||||
warning: authWarning,
|
||||
});
|
||||
return { warning: authWarning };
|
||||
}
|
||||
if (authReadiness.authenticated === true) {
|
||||
const warning =
|
||||
`${cliCommandLabel} runtime status was unavailable, but auth status passed. ` +
|
||||
`Proceeding with catalog checks. Details: ${runtimeStatusMessage}`;
|
||||
appendPreflightDebugLog('provider_runtime_control_plane_auth_fallback', {
|
||||
providerId,
|
||||
cwd,
|
||||
ready: true,
|
||||
runtimeStatusError: runtimeStatusMessage,
|
||||
warning,
|
||||
});
|
||||
return { warning };
|
||||
}
|
||||
} catch (authStatusError) {
|
||||
const authStatusMessage =
|
||||
authStatusError instanceof Error ? authStatusError.message : String(authStatusError);
|
||||
appendPreflightDebugLog('provider_runtime_control_plane_auth_fallback', {
|
||||
providerId,
|
||||
cwd,
|
||||
ready: false,
|
||||
runtimeStatusError: runtimeStatusMessage,
|
||||
authStatusError: authStatusMessage,
|
||||
});
|
||||
return {
|
||||
warning:
|
||||
`${cliCommandLabel} runtime status check did not complete. ` +
|
||||
`Proceeding with catalog checks. Details: ${runtimeStatusMessage}; auth status failed: ${authStatusMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
warning:
|
||||
`${cliCommandLabel} runtime status was unavailable and auth status did not report ${providerLabel} authentication. ` +
|
||||
`Proceeding with catalog checks. Details: ${runtimeStatusMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async runProviderOneShotDiagnostic(
|
||||
claudePath: string,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId | undefined = 'anthropic',
|
||||
providerArgs: string[] = []
|
||||
): Promise<{ warning?: string }> {
|
||||
const cliCommandLabel = getConfiguredCliCommandLabel();
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
|
||||
if (!(await pathExistsAsDirectory(cwd))) {
|
||||
appendPreflightDebugLog('provider_one_shot_diagnostic_skipped', {
|
||||
providerId: resolvedProviderId,
|
||||
cwd,
|
||||
reason: 'missing_cwd',
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
// Stage 2: verify `-p` mode auth actually works (with retry for stale locks after Ctrl+C)
|
||||
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
|
||||
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
|
||||
try {
|
||||
pingProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
[...getPreflightPingArgs(providerId), ...providerArgs],
|
||||
buildProviderCliCommandArgs(providerArgs, getPreflightPingArgs(providerId)),
|
||||
cwd,
|
||||
env,
|
||||
getPreflightTimeoutMs(providerId),
|
||||
|
|
@ -18575,16 +18803,19 @@ export class TeamProvisioningService {
|
|||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`One-shot diagnostic failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
const normalizedMessage = normalizeProviderModelProbeFailureReason(message);
|
||||
return {
|
||||
warning:
|
||||
`Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
|
||||
`Proceeding anyway. Details: ${message}`,
|
||||
(isProbeTimeoutMessage(message)
|
||||
? 'One-shot diagnostic timed out after runtime readiness passed. '
|
||||
: 'One-shot diagnostic did not complete after runtime readiness passed. ') +
|
||||
`This does not mark selected models unavailable. Details: ${normalizedMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -18593,8 +18824,8 @@ export class TeamProvisioningService {
|
|||
|
||||
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Preflight auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms — likely stale locks from interrupted process`
|
||||
`One-shot diagnostic auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms - likely stale locks from interrupted process`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
|
||||
continue;
|
||||
|
|
@ -18617,7 +18848,11 @@ export class TeamProvisioningService {
|
|||
: normalizedOutput
|
||||
? `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}). Details: ${normalizedOutput}`
|
||||
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
return {
|
||||
warning:
|
||||
'One-shot diagnostic failed after runtime readiness passed. ' +
|
||||
`This does not mark selected models unavailable. Details: ${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
|
||||
|
|
@ -18627,14 +18862,15 @@ export class TeamProvisioningService {
|
|||
if (!isPong) {
|
||||
return {
|
||||
warning:
|
||||
'Preflight ping completed but did not return the expected PONG. ' +
|
||||
'One-shot diagnostic completed but did not return the expected PONG. ' +
|
||||
'This does not mark selected models unavailable. ' +
|
||||
`Output: ${combinedOutput || '(empty)'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (attempt > 1) {
|
||||
logger.info(
|
||||
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`
|
||||
`One-shot diagnostic succeeded on attempt ${attempt} (previous attempt had auth failure)`
|
||||
);
|
||||
}
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -6,22 +6,66 @@ import { ConfirmDialog } from './components/common/ConfirmDialog';
|
|||
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||
import { type SplashSceneHandle, startSplashScene } from './components/splash/splashScene';
|
||||
import { ToolApprovalSheet } from './components/team/ToolApprovalSheet';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { api } from './api';
|
||||
import { useStore } from './store';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__claudeTeamsSplashEnhancedStartedAt?: number;
|
||||
__claudeTeamsSplashScene?: SplashSceneHandle;
|
||||
__claudeTeamsSplashStartedAt?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const SPLASH_MIN_DURATION_MS = 1600;
|
||||
const SPLASH_ENHANCED_HOLD_MS = 600;
|
||||
const SPLASH_FADE_MS = 480;
|
||||
const SPLASH_REDUCED_MIN_DURATION_MS = 320;
|
||||
const SPLASH_REDUCED_HOLD_MS = 120;
|
||||
const SPLASH_REDUCED_FADE_MS = 180;
|
||||
|
||||
export const App = (): React.JSX.Element => {
|
||||
// Initialize theme on app load
|
||||
useTheme();
|
||||
|
||||
// Dismiss splash screen once React is ready
|
||||
// Upgrade the static preload splash, then dismiss it after the scene is visible.
|
||||
useEffect(() => {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.style.opacity = '0';
|
||||
setTimeout(() => splash.remove(), 300);
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const scene = window.__claudeTeamsSplashScene ?? startSplashScene(splash, { reducedMotion });
|
||||
const startedAt = window.__claudeTeamsSplashStartedAt ?? performance.now();
|
||||
const enhancedStartedAt = window.__claudeTeamsSplashEnhancedStartedAt ?? performance.now();
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const enhancedElapsed = performance.now() - enhancedStartedAt;
|
||||
const minDuration = reducedMotion ? SPLASH_REDUCED_MIN_DURATION_MS : SPLASH_MIN_DURATION_MS;
|
||||
const enhancedHold = reducedMotion ? SPLASH_REDUCED_HOLD_MS : SPLASH_ENHANCED_HOLD_MS;
|
||||
const fadeDuration = reducedMotion ? SPLASH_REDUCED_FADE_MS : SPLASH_FADE_MS;
|
||||
const exitDelay = Math.max(minDuration - elapsed, enhancedHold - enhancedElapsed, 0);
|
||||
let removeTimer: number | undefined;
|
||||
|
||||
const exitTimer = window.setTimeout(() => {
|
||||
splash.classList.add('splash-exiting');
|
||||
removeTimer = window.setTimeout(() => {
|
||||
scene.stop();
|
||||
window.__claudeTeamsSplashScene = undefined;
|
||||
window.__claudeTeamsSplashEnhancedStartedAt = undefined;
|
||||
splash.remove();
|
||||
}, fadeDuration);
|
||||
}, exitDelay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(exitTimer);
|
||||
if (removeTimer !== undefined) {
|
||||
window.clearTimeout(removeTimer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Initialize context system lazily when SSH connection state changes.
|
||||
|
|
|
|||
897
src/renderer/components/splash/splashScene.ts
Normal file
897
src/renderer/components/splash/splashScene.ts
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
export interface SplashSceneHandle {
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export interface SplashSceneOptions {
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__claudeTeamsSplashEnhancedStartedAt?: number;
|
||||
__claudeTeamsSplashScene?: SplashSceneHandle;
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface RobotNode extends Point {
|
||||
teamIndex: number;
|
||||
robotIndex: number;
|
||||
color: string;
|
||||
size: number;
|
||||
bob: number;
|
||||
}
|
||||
|
||||
interface TeamNode {
|
||||
index: number;
|
||||
center: Point;
|
||||
color: string;
|
||||
radius: number;
|
||||
robots: RobotNode[];
|
||||
}
|
||||
|
||||
interface DepthParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
phase: number;
|
||||
alpha: number;
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
isLight: boolean;
|
||||
centerGlow: string;
|
||||
teamColors: string[];
|
||||
teamLineAlpha: number;
|
||||
robotBody: string;
|
||||
robotShade: string;
|
||||
robotEye: string;
|
||||
messageAccent: string;
|
||||
particle: string;
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const TEAM_MEMBER_COUNTS = [4, 3, 5] as const;
|
||||
const MAX_DPR = 2;
|
||||
|
||||
export function startSplashScene(
|
||||
splash: HTMLElement,
|
||||
options: SplashSceneOptions = {}
|
||||
): SplashSceneHandle {
|
||||
const existingScene = window.__claudeTeamsSplashScene;
|
||||
if (existingScene && splash.querySelector('#splash-enhanced-canvas')) {
|
||||
return existingScene;
|
||||
}
|
||||
|
||||
const previousCanvas = splash.querySelector<HTMLCanvasElement>('#splash-enhanced-canvas');
|
||||
previousCanvas?.remove();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'splash-enhanced-canvas';
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
splash.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) {
|
||||
const emptyHandle = {
|
||||
stop: () => {
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
return emptyHandle;
|
||||
}
|
||||
|
||||
const reducedMotion =
|
||||
options.reducedMotion ?? window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const state = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
dpr: 1,
|
||||
particles: [] as DepthParticle[],
|
||||
running: true,
|
||||
frameId: 0,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
|
||||
const resize = (): void => {
|
||||
const rect = splash.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(rect.width));
|
||||
const height = Math.max(1, Math.round(rect.height));
|
||||
const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
|
||||
|
||||
if (state.width === width && state.height === height && state.dpr === dpr) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
state.dpr = dpr;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
state.particles = createDepthParticles(width, height);
|
||||
};
|
||||
|
||||
const render = (now: number): void => {
|
||||
if (!state.running) return;
|
||||
|
||||
resize();
|
||||
const time = (now - state.startedAt) / 1000;
|
||||
drawScene(ctx, state.width, state.height, time, state.particles, reducedMotion);
|
||||
|
||||
if (!reducedMotion) {
|
||||
state.frameId = window.requestAnimationFrame(render);
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = (): void => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
resize();
|
||||
render(performance.now());
|
||||
|
||||
const handle: SplashSceneHandle = {
|
||||
stop: () => {
|
||||
state.running = false;
|
||||
window.cancelAnimationFrame(state.frameId);
|
||||
window.removeEventListener('resize', onResize);
|
||||
canvas.remove();
|
||||
if (window.__claudeTeamsSplashScene === handle) {
|
||||
window.__claudeTeamsSplashScene = undefined;
|
||||
window.__claudeTeamsSplashEnhancedStartedAt = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
window.__claudeTeamsSplashScene = handle;
|
||||
window.__claudeTeamsSplashEnhancedStartedAt = performance.now();
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
function drawScene(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
time: number,
|
||||
particles: DepthParticle[],
|
||||
reducedMotion: boolean
|
||||
): void {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const palette = resolvePalette();
|
||||
const mobile = width < 560 || height < 620;
|
||||
const sceneTime = reducedMotion ? 1.2 : time;
|
||||
const teams = buildTeams(width, height, sceneTime, mobile, palette);
|
||||
const center = getCenter(width, height, mobile);
|
||||
|
||||
drawAmbientField(ctx, width, height, sceneTime, particles, palette, mobile);
|
||||
drawCenterAura(ctx, center, sceneTime, palette, mobile);
|
||||
drawCrossTeamGuides(ctx, teams, center, sceneTime, palette, mobile);
|
||||
|
||||
for (const team of teams) {
|
||||
drawTeamHalo(ctx, team, sceneTime, palette);
|
||||
}
|
||||
|
||||
drawMessages(ctx, teams, center, sceneTime, palette, mobile);
|
||||
|
||||
for (const team of teams) {
|
||||
drawTeamLinks(ctx, team, palette);
|
||||
}
|
||||
|
||||
for (const team of teams) {
|
||||
for (const robot of team.robots) {
|
||||
drawRobot(ctx, robot, sceneTime, palette);
|
||||
}
|
||||
}
|
||||
|
||||
clearCentralContentReserve(ctx, center, mobile);
|
||||
}
|
||||
|
||||
function resolvePalette(): Palette {
|
||||
const isLight = document.documentElement.classList.contains('light');
|
||||
return isLight
|
||||
? {
|
||||
isLight,
|
||||
centerGlow: '#4f46e5',
|
||||
teamColors: ['#0284c7', '#059669', '#d97706'],
|
||||
teamLineAlpha: 0.34,
|
||||
robotBody: '#eef2ff',
|
||||
robotShade: '#c7d2fe',
|
||||
robotEye: '#ffffff',
|
||||
messageAccent: '#db2777',
|
||||
particle: '#312e81',
|
||||
}
|
||||
: {
|
||||
isLight,
|
||||
centerGlow: '#818cf8',
|
||||
teamColors: ['#38bdf8', '#34d399', '#f59e0b'],
|
||||
teamLineAlpha: 0.42,
|
||||
robotBody: '#111827',
|
||||
robotShade: '#27324a',
|
||||
robotEye: '#e0f2fe',
|
||||
messageAccent: '#f472b6',
|
||||
particle: '#c4b5fd',
|
||||
};
|
||||
}
|
||||
|
||||
function getCenter(width: number, height: number, mobile: boolean): Point {
|
||||
return {
|
||||
x: width / 2,
|
||||
y: height * (mobile ? 0.47 : 0.49),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTeams(
|
||||
width: number,
|
||||
height: number,
|
||||
time: number,
|
||||
mobile: boolean,
|
||||
palette: Palette
|
||||
): TeamNode[] {
|
||||
const center = getCenter(width, height, mobile);
|
||||
const spreadX = mobile ? Math.min(width * 0.3, 126) : Math.min(width * 0.26, 320);
|
||||
const spreadY = mobile ? Math.min(height * 0.17, 132) : Math.min(height * 0.19, 190);
|
||||
const teamRadius = mobile
|
||||
? clamp(Math.min(width, height) * 0.09, 30, 40)
|
||||
: clamp(Math.min(width, height) * 0.075, 44, 62);
|
||||
const robotSize = mobile ? 11 : 14;
|
||||
const centers: Point[] = [
|
||||
{
|
||||
x: center.x - spreadX,
|
||||
y: center.y - spreadY * (mobile ? 0.6 : 0.45),
|
||||
},
|
||||
{
|
||||
x: center.x + spreadX,
|
||||
y: center.y - spreadY * (mobile ? 0.6 : 0.45),
|
||||
},
|
||||
{
|
||||
x: center.x,
|
||||
y: center.y + spreadY * (mobile ? 1.22 : 0.95),
|
||||
},
|
||||
];
|
||||
|
||||
return centers.map((teamCenter, teamIndex) => {
|
||||
const drift = Math.sin(time * 0.75 + teamIndex * 1.7) * (mobile ? 3 : 6);
|
||||
const centerWithDrift = {
|
||||
x: teamCenter.x + Math.cos(teamIndex * 2.1 + time * 0.35) * (mobile ? 2 : 4),
|
||||
y: teamCenter.y + drift,
|
||||
};
|
||||
const color = palette.teamColors[teamIndex % palette.teamColors.length] ?? palette.centerGlow;
|
||||
const memberCount = TEAM_MEMBER_COUNTS[teamIndex] ?? 3;
|
||||
const robots = Array.from({ length: memberCount }, (_, robotIndex) => {
|
||||
const baseAngle =
|
||||
-Math.PI / 2 + robotIndex * (TAU / memberCount) + (teamIndex === 2 ? TAU / 20 : 0);
|
||||
const orbit = baseAngle + Math.sin(time * 0.55 + teamIndex + robotIndex) * 0.1;
|
||||
const orbitRadius =
|
||||
teamRadius * (0.88 + (memberCount > 4 ? 0.08 : 0) + 0.05 * Math.sin(time + robotIndex));
|
||||
return {
|
||||
teamIndex,
|
||||
robotIndex,
|
||||
color,
|
||||
size: memberCount > 4 ? robotSize * 0.88 : robotSize,
|
||||
bob: Math.sin(time * 2.2 + teamIndex * 0.8 + robotIndex * 1.1),
|
||||
x: centerWithDrift.x + Math.cos(orbit) * orbitRadius,
|
||||
y: centerWithDrift.y + Math.sin(orbit) * orbitRadius,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
index: teamIndex,
|
||||
center: centerWithDrift,
|
||||
color,
|
||||
radius: teamRadius,
|
||||
robots,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function drawAmbientField(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
time: number,
|
||||
particles: DepthParticle[],
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const visibleParticles = mobile ? Math.floor(particles.length * 0.6) : particles.length;
|
||||
for (let i = 0; i < visibleParticles; i++) {
|
||||
const particle = particles[i];
|
||||
if (!particle) continue;
|
||||
const y = (particle.y + time * particle.speed) % (height + 24);
|
||||
const x = particle.x + Math.sin(time * 0.45 + particle.phase) * 8;
|
||||
const pulse = 0.78 + Math.sin(time * 1.8 + particle.phase) * 0.22;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = withAlpha(palette.particle, particle.alpha * pulse);
|
||||
ctx.arc(x, y - 12, particle.size, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawCenterAura(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
center: Point,
|
||||
time: number,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const radius = mobile ? 86 : 128;
|
||||
const glow = ctx.createRadialGradient(center.x, center.y, 20, center.x, center.y, radius);
|
||||
glow.addColorStop(0, withAlpha(palette.centerGlow, palette.isLight ? 0.13 : 0.2));
|
||||
glow.addColorStop(0.48, withAlpha(palette.messageAccent, palette.isLight ? 0.07 : 0.11));
|
||||
glow.addColorStop(1, withAlpha(palette.centerGlow, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center.x, center.y, radius, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ringRadius = radius * (0.42 + i * 0.18) + Math.sin(time * 1.1 + i) * 3;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = withAlpha(palette.centerGlow, 0.1 - i * 0.018);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([8 + i * 2, 12 + i * 3]);
|
||||
ctx.lineDashOffset = -time * (18 + i * 8);
|
||||
ctx.arc(center.x, center.y, ringRadius, 0, TAU);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawCrossTeamGuides(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
teams: TeamNode[],
|
||||
center: Point,
|
||||
time: number,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
const from = teams[i];
|
||||
const to = teams[(i + 1) % teams.length];
|
||||
if (!from || !to) continue;
|
||||
const anchor = getCrossTeamAnchor(center, i, mobile);
|
||||
const cp1 = mix(from.center, anchor, 0.62);
|
||||
const cp2 = mix(to.center, anchor, 0.62);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.center.x, from.center.y);
|
||||
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, to.center.x, to.center.y);
|
||||
ctx.strokeStyle = withAlpha(palette.messageAccent, palette.isLight ? 0.16 : 0.2);
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.setLineDash([2, 13]);
|
||||
ctx.lineDashOffset = -time * 28;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawTeamHalo(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
team: TeamNode,
|
||||
time: number,
|
||||
palette: Palette
|
||||
): void {
|
||||
const pulse = 1 + Math.sin(time * 1.8 + team.index) * 0.035;
|
||||
const radiusX = team.radius * 1.56 * pulse;
|
||||
const radiusY = team.radius * 1.14 * pulse;
|
||||
const glow = ctx.createRadialGradient(
|
||||
team.center.x,
|
||||
team.center.y,
|
||||
team.radius * 0.35,
|
||||
team.center.x,
|
||||
team.center.y,
|
||||
team.radius * 2
|
||||
);
|
||||
glow.addColorStop(0, withAlpha(team.color, palette.isLight ? 0.08 : 0.12));
|
||||
glow.addColorStop(1, withAlpha(team.color, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(team.center.x, team.center.y, team.radius * 2, team.radius * 1.56, 0, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(team.center.x, team.center.y, radiusX, radiusY, time * 0.08, 0, TAU);
|
||||
ctx.strokeStyle = withAlpha(team.color, palette.isLight ? 0.28 : 0.34);
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.setLineDash([10, 8]);
|
||||
ctx.lineDashOffset = -time * (22 + team.index * 4);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawTeamLinks(ctx: CanvasRenderingContext2D, team: TeamNode, palette: Palette): void {
|
||||
const pairs = getTeamConnectionPairs(team.robots.length);
|
||||
|
||||
for (const [fromIndex, toIndex] of pairs) {
|
||||
const from = team.robots[fromIndex];
|
||||
const to = team.robots[toIndex];
|
||||
if (!from || !to) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(team.color, palette.teamLineAlpha);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawMessages(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
teams: TeamNode[],
|
||||
center: Point,
|
||||
time: number,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
for (const team of teams) {
|
||||
drawLocalMessages(ctx, team, time, palette, mobile);
|
||||
}
|
||||
drawCrossTeamMessages(ctx, teams, center, time, palette, mobile);
|
||||
}
|
||||
|
||||
function drawLocalMessages(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
team: TeamNode,
|
||||
time: number,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const pairs = getLocalMessagePairs(team.index, team.robots.length);
|
||||
const activeWindow = 0.76;
|
||||
const period = 2.15 + team.index * 0.12;
|
||||
|
||||
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
||||
const [fromIndex, toIndex] = pairs[pairIndex] ?? [0, 1];
|
||||
const from = team.robots[fromIndex];
|
||||
const to = team.robots[toIndex];
|
||||
if (!from || !to) continue;
|
||||
const raw = positiveModulo(time + team.index * 0.7 + pairIndex * 0.36, period) / period;
|
||||
if (raw > activeWindow) continue;
|
||||
const progress = easeInOutCubic(raw / activeWindow);
|
||||
const curve = makeLocalCurve(from, to, team.center, team.radius * 0.42);
|
||||
drawMessageFlight(ctx, curve, progress, team.color, time, mobile ? 5.5 : 7, palette);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCrossTeamMessages(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
teams: TeamNode[],
|
||||
center: Point,
|
||||
time: number,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const activeWindow = 0.64;
|
||||
const period = 4.25;
|
||||
const routes = [
|
||||
{ fromTeam: 0, fromRobot: 3, toTeam: 1, toRobot: 1, delay: 0, anchor: 0 },
|
||||
{ fromTeam: 2, fromRobot: 4, toTeam: 0, toRobot: 1, delay: 0.82, anchor: 2 },
|
||||
{ fromTeam: 1, fromRobot: 2, toTeam: 2, toRobot: 0, delay: 1.68, anchor: 1, accent: true },
|
||||
{ fromTeam: 0, fromRobot: 0, toTeam: 2, toRobot: 3, delay: 2.54, anchor: 2 },
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
const fromTeam = teams[route.fromTeam];
|
||||
const toTeam = teams[route.toTeam];
|
||||
if (!fromTeam || !toTeam) continue;
|
||||
const raw = positiveModulo(time + route.delay, period) / period;
|
||||
if (raw > activeWindow) continue;
|
||||
|
||||
const from = fromTeam.robots[route.fromRobot % fromTeam.robots.length];
|
||||
const to = toTeam.robots[route.toRobot % toTeam.robots.length];
|
||||
if (!from || !to) continue;
|
||||
const progress = easeInOutCubic(raw / activeWindow);
|
||||
const curve = makeCrossCurve(from, to, center, route.anchor, mobile);
|
||||
drawMessageFlight(
|
||||
ctx,
|
||||
curve,
|
||||
progress,
|
||||
route.accent ? palette.messageAccent : fromTeam.color,
|
||||
time,
|
||||
mobile ? 6 : 8.5,
|
||||
palette,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMessageFlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
curve: [Point, Point, Point, Point],
|
||||
progress: number,
|
||||
color: string,
|
||||
time: number,
|
||||
size: number,
|
||||
palette: Palette,
|
||||
crossTeam = false
|
||||
): void {
|
||||
const [p0, p1, p2, p3] = curve;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
|
||||
ctx.strokeStyle = withAlpha(color, crossTeam ? 0.24 : 0.18);
|
||||
ctx.lineWidth = crossTeam ? 1.25 : 1;
|
||||
ctx.setLineDash(crossTeam ? [8, 10] : [4, 8]);
|
||||
ctx.lineDashOffset = -time * (crossTeam ? 52 : 34);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
for (let i = 7; i >= 1; i--) {
|
||||
const t = progress - i * 0.036;
|
||||
if (t <= 0) continue;
|
||||
const point = cubicPoint(p0, p1, p2, p3, t);
|
||||
const alpha = (1 - i / 8) * (palette.isLight ? 0.22 : 0.32);
|
||||
ctx.fillStyle = withAlpha(color, alpha);
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, size * (0.18 + i * 0.025), 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const position = cubicPoint(p0, p1, p2, p3, progress);
|
||||
const tangent = cubicTangent(p0, p1, p2, p3, progress);
|
||||
const angle = Math.atan2(tangent.y, tangent.x);
|
||||
drawMessageBubble(ctx, position, angle, size, color, palette, crossTeam);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMessageBubble(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
position: Point,
|
||||
angle: number,
|
||||
size: number,
|
||||
color: string,
|
||||
palette: Palette,
|
||||
crossTeam: boolean
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.translate(position.x, position.y);
|
||||
ctx.rotate(angle * 0.14);
|
||||
ctx.shadowColor = withAlpha(color, palette.isLight ? 0.22 : 0.5);
|
||||
ctx.shadowBlur = crossTeam ? 18 : 12;
|
||||
|
||||
const width = size * (crossTeam ? 2.5 : 2.25);
|
||||
const height = size * 1.62;
|
||||
roundRectPath(ctx, -width / 2, -height / 2, width, height, size * 0.45);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-width * 0.24, height * 0.42);
|
||||
ctx.lineTo(-width * 0.36, height * 0.78);
|
||||
ctx.lineTo(-width * 0.05, height * 0.44);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = palette.robotEye;
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(i * size * 0.43, -size * 0.02, size * 0.12, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRobot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
robot: RobotNode,
|
||||
time: number,
|
||||
palette: Palette
|
||||
): void {
|
||||
const size = robot.size;
|
||||
const x = robot.x;
|
||||
const y = robot.y + robot.bob * 1.6;
|
||||
const tilt = Math.sin(time * 1.5 + robot.teamIndex + robot.robotIndex * 0.8) * 0.08;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(tilt);
|
||||
ctx.shadowColor = withAlpha(robot.color, palette.isLight ? 0.2 : 0.42);
|
||||
ctx.shadowBlur = size * 1.6;
|
||||
|
||||
ctx.strokeStyle = withAlpha(robot.color, palette.isLight ? 0.64 : 0.82);
|
||||
ctx.lineWidth = Math.max(1, size * 0.11);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-size * 0.78, size * 0.22);
|
||||
ctx.lineTo(-size * 1.12, size * 0.55);
|
||||
ctx.moveTo(size * 0.78, size * 0.22);
|
||||
ctx.lineTo(size * 1.12, size * 0.55);
|
||||
ctx.stroke();
|
||||
|
||||
const bodyGradient = ctx.createLinearGradient(0, -size, 0, size);
|
||||
bodyGradient.addColorStop(0, mixColor(robot.color, palette.robotBody, 0.28));
|
||||
bodyGradient.addColorStop(1, mixColor(robot.color, palette.robotShade, 0.62));
|
||||
roundRectPath(ctx, -size * 0.78, -size * 0.74, size * 1.56, size * 1.48, size * 0.42);
|
||||
ctx.fillStyle = bodyGradient;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = withAlpha(robot.color, palette.isLight ? 0.74 : 0.9);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = withAlpha(robot.color, 0.75);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -size * 0.76);
|
||||
ctx.lineTo(0, -size * 1.18);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = robot.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -size * 1.25, size * 0.16, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = palette.robotEye;
|
||||
ctx.beginPath();
|
||||
ctx.arc(-size * 0.3, -size * 0.2, size * 0.16, 0, TAU);
|
||||
ctx.arc(size * 0.3, -size * 0.2, size * 0.16, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = withAlpha(palette.robotEye, 0.72);
|
||||
ctx.lineWidth = Math.max(1, size * 0.09);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-size * 0.36, size * 0.24);
|
||||
ctx.quadraticCurveTo(0, size * 0.5, size * 0.36, size * 0.24);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = withAlpha(robot.color, palette.isLight ? 0.58 : 0.82);
|
||||
ctx.fillRect(-size * 0.42, size * 0.82, size * 0.28, size * 0.22);
|
||||
ctx.fillRect(size * 0.14, size * 0.82, size * 0.28, size * 0.22);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getTeamConnectionPairs(memberCount: number): [number, number][] {
|
||||
if (memberCount <= 3) {
|
||||
return [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
];
|
||||
}
|
||||
|
||||
const pairs: [number, number][] = [];
|
||||
for (let index = 0; index < memberCount; index++) {
|
||||
pairs.push([index, (index + 1) % memberCount]);
|
||||
}
|
||||
if (memberCount >= 4) pairs.push([0, 2]);
|
||||
if (memberCount >= 5) pairs.push([1, 4]);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function getLocalMessagePairs(teamIndex: number, memberCount: number): [number, number][] {
|
||||
const routeMap: [number, number][][] = [
|
||||
[
|
||||
[0, 2],
|
||||
[3, 1],
|
||||
[1, 0],
|
||||
],
|
||||
[
|
||||
[2, 0],
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
[
|
||||
[4, 1],
|
||||
[0, 3],
|
||||
[2, 4],
|
||||
[3, 0],
|
||||
],
|
||||
];
|
||||
return (routeMap[teamIndex] ?? routeMap[0]).filter(
|
||||
([fromIndex, toIndex]) => fromIndex < memberCount && toIndex < memberCount
|
||||
);
|
||||
}
|
||||
|
||||
function makeLocalCurve(
|
||||
from: Point,
|
||||
to: Point,
|
||||
center: Point,
|
||||
lift: number
|
||||
): [Point, Point, Point, Point] {
|
||||
const mid = mix(from, to, 0.5);
|
||||
const away = normalize({ x: mid.x - center.x, y: mid.y - center.y });
|
||||
const control = {
|
||||
x: mid.x + away.x * lift,
|
||||
y: mid.y + away.y * lift,
|
||||
};
|
||||
return [from, mix(from, control, 0.72), mix(to, control, 0.72), to];
|
||||
}
|
||||
|
||||
function makeCrossCurve(
|
||||
from: Point,
|
||||
to: Point,
|
||||
center: Point,
|
||||
index: number,
|
||||
mobile: boolean
|
||||
): [Point, Point, Point, Point] {
|
||||
const anchor = getCrossTeamAnchor(center, index, mobile);
|
||||
const curveLift = 0.32 + index * 0.06;
|
||||
const cp1 = mix(from, anchor, curveLift);
|
||||
const cp2 = mix(to, anchor, curveLift);
|
||||
const normal = normalize({ x: to.y - from.y, y: from.x - to.x });
|
||||
const offset = mobile ? 22 + index * 6 : 42 + index * 12;
|
||||
return [
|
||||
from,
|
||||
{ x: cp1.x + normal.x * offset, y: cp1.y + normal.y * offset },
|
||||
{ x: cp2.x + normal.x * offset, y: cp2.y + normal.y * offset },
|
||||
to,
|
||||
];
|
||||
}
|
||||
|
||||
function getCrossTeamAnchor(center: Point, index: number, mobile: boolean): Point {
|
||||
const horizontalOffset = mobile ? 108 : 178;
|
||||
const topOffset = mobile ? 94 : 138;
|
||||
const lowerOffset = mobile ? 106 : 112;
|
||||
if (index === 0) {
|
||||
return {
|
||||
x: center.x,
|
||||
y: center.y - topOffset,
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
return {
|
||||
x: center.x + horizontalOffset,
|
||||
y: center.y + lowerOffset,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: center.x - horizontalOffset,
|
||||
y: center.y + lowerOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function clearCentralContentReserve(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
center: Point,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const width = mobile ? 260 : 330;
|
||||
const height = mobile ? 166 : 184;
|
||||
const y = center.y + (mobile ? 12 : 10);
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
roundRectPath(ctx, center.x - width / 2, y - height / 2, width, height, mobile ? 32 : 40);
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.98)';
|
||||
ctx.fill();
|
||||
|
||||
const glow = ctx.createRadialGradient(center.x, y, 8, center.x, y, width * 0.62);
|
||||
glow.addColorStop(0, 'rgba(0, 0, 0, 0.96)');
|
||||
glow.addColorStop(0.68, 'rgba(0, 0, 0, 0.9)');
|
||||
glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.fillStyle = glow;
|
||||
roundRectPath(ctx, center.x - width / 2, y - height / 2, width, height, mobile ? 32 : 40);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function createDepthParticles(width: number, height: number): DepthParticle[] {
|
||||
const count = width < 560 ? 46 : 78;
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const seed = index * 97.13;
|
||||
return {
|
||||
x: pseudoRandom(seed) * width,
|
||||
y: pseudoRandom(seed + 12.4) * (height + 24),
|
||||
size: 0.45 + pseudoRandom(seed + 22.8) * 1.15,
|
||||
speed: 8 + pseudoRandom(seed + 31.2) * 18,
|
||||
phase: pseudoRandom(seed + 48.7) * TAU,
|
||||
alpha: 0.06 + pseudoRandom(seed + 72.1) * 0.16,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pseudoRandom(seed: number): number {
|
||||
const value = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return value - Math.floor(value);
|
||||
}
|
||||
|
||||
function cubicPoint(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const clamped = clamp(t, 0, 1);
|
||||
const mt = 1 - clamped;
|
||||
const mt2 = mt * mt;
|
||||
const t2 = clamped * clamped;
|
||||
return {
|
||||
x: mt2 * mt * p0.x + 3 * mt2 * clamped * p1.x + 3 * mt * t2 * p2.x + t2 * clamped * p3.x,
|
||||
y: mt2 * mt * p0.y + 3 * mt2 * clamped * p1.y + 3 * mt * t2 * p2.y + t2 * clamped * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cubicTangent(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const clamped = clamp(t, 0, 1);
|
||||
const mt = 1 - clamped;
|
||||
return {
|
||||
x:
|
||||
3 * mt * mt * (p1.x - p0.x) +
|
||||
6 * mt * clamped * (p2.x - p1.x) +
|
||||
3 * clamped * clamped * (p3.x - p2.x),
|
||||
y:
|
||||
3 * mt * mt * (p1.y - p0.y) +
|
||||
6 * mt * clamped * (p2.y - p1.y) +
|
||||
3 * clamped * clamped * (p3.y - p2.y),
|
||||
};
|
||||
}
|
||||
|
||||
function mix(from: Point, to: Point, amount: number): Point {
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * amount,
|
||||
y: from.y + (to.y - from.y) * amount,
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(point: Point): Point {
|
||||
const length = Math.hypot(point.x, point.y) || 1;
|
||||
return {
|
||||
x: point.x / length,
|
||||
y: point.y / length,
|
||||
};
|
||||
}
|
||||
|
||||
function easeInOutCubic(value: number): number {
|
||||
const t = clamp(value, 0, 1);
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
function positiveModulo(value: number, divisor: number): number {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function roundRectPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number
|
||||
): void {
|
||||
const r = Math.min(radius, width / 2, height / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + width - r, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
||||
ctx.lineTo(x + width, y + height - r);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
||||
ctx.lineTo(x + r, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
const normalized = normalizeHex(hex);
|
||||
const r = Number.parseInt(normalized.slice(1, 3), 16);
|
||||
const g = Number.parseInt(normalized.slice(3, 5), 16);
|
||||
const b = Number.parseInt(normalized.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
|
||||
}
|
||||
|
||||
function mixColor(hexA: string, hexB: string, amount: number): string {
|
||||
const a = hexToRgb(normalizeHex(hexA));
|
||||
const b = hexToRgb(normalizeHex(hexB));
|
||||
const t = clamp(amount, 0, 1);
|
||||
return `rgb(${Math.round(a.r + (b.r - a.r) * t)}, ${Math.round(
|
||||
a.g + (b.g - a.g) * t
|
||||
)}, ${Math.round(a.b + (b.b - a.b) * t)})`;
|
||||
}
|
||||
|
||||
function normalizeHex(hex: string): string {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) return hex;
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
|
||||
}
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
|
@ -26,7 +26,6 @@ import {
|
|||
normalizeProviderForMode,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -53,18 +52,18 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import {
|
||||
applyStoredCreateTeamMemberRuntimePreferences,
|
||||
getStoredCreateTeamMemberRuntimePreferences,
|
||||
getStoredCreateTeamEffort,
|
||||
getStoredCreateTeamFastMode as getStoredTeamFastMode,
|
||||
getStoredCreateTeamLimitContext,
|
||||
getStoredCreateTeamMemberRuntimePreferences,
|
||||
getStoredCreateTeamModel as getStoredTeamModel,
|
||||
getStoredCreateTeamProvider as getStoredTeamProvider,
|
||||
getStoredCreateTeamSkipPermissions,
|
||||
migrateLegacyCreateTeamPreferences,
|
||||
setStoredCreateTeamMemberRuntimePreferences,
|
||||
setStoredCreateTeamEffort,
|
||||
setStoredCreateTeamFastMode,
|
||||
setStoredCreateTeamLimitContext,
|
||||
setStoredCreateTeamMemberRuntimePreferences,
|
||||
setStoredCreateTeamModel,
|
||||
setStoredCreateTeamProvider,
|
||||
setStoredCreateTeamSkipPermissions,
|
||||
|
|
@ -80,6 +79,7 @@ import {
|
|||
normalizeExplicitTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -99,15 +99,15 @@ import {
|
|||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import {
|
||||
buildProviderPrepareMembersSignature,
|
||||
buildProviderPrepareRequestSignature,
|
||||
buildProviderPrepareRuntimeStatusSignature,
|
||||
} from './providerPrepareRequestSignature';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||
import {
|
||||
deriveEffectiveProvisioningPrepareState,
|
||||
|
|
@ -124,6 +124,8 @@ import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
|||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'blue',
|
||||
'green',
|
||||
|
|
@ -146,15 +148,6 @@ import type {
|
|||
TeamProvisioningMemberInput,
|
||||
} from '@shared/types';
|
||||
|
||||
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
|
||||
const normalized = normalizePath(projectPath ?? '').toLowerCase();
|
||||
return (
|
||||
normalized.includes('rendered_mcp_') ||
|
||||
normalized.includes('rendered_mcp_config') ||
|
||||
normalized.includes('/portable-mcp-live')
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
|
@ -523,7 +516,10 @@ export const CreateTeamDialog = ({
|
|||
[members]
|
||||
);
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
|
||||
? ''
|
||||
: selectedProjectPath.trim();
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
||||
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
||||
/** All taken names: existing teams + teams currently being provisioned. */
|
||||
const allTakenTeamNames = useMemo(
|
||||
|
|
@ -913,7 +909,9 @@ export const CreateTeamDialog = ({
|
|||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const nextProjects = await api.getProjects();
|
||||
const nextProjects = (await api.getProjects()).filter(
|
||||
(project) => !isEphemeralProjectPath(project.path)
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -921,10 +919,14 @@ export const CreateTeamDialog = ({
|
|||
// If defaultProjectPath is set but not in the fetched list (e.g. new project
|
||||
// without Claude sessions), add it as a synthetic entry so the Combobox can
|
||||
// display and select it.
|
||||
const normalizedDefaultProjectPath = defaultProjectPath
|
||||
? normalizePath(defaultProjectPath)
|
||||
: null;
|
||||
if (
|
||||
defaultProjectPath &&
|
||||
!isEphemeralRenderedProjectPath(defaultProjectPath) &&
|
||||
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
|
||||
normalizedDefaultProjectPath &&
|
||||
!isEphemeralProjectPath(defaultProjectPath) &&
|
||||
!nextProjects.some((p) => normalizePath(p.path) === normalizedDefaultProjectPath)
|
||||
) {
|
||||
const folderName =
|
||||
defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath;
|
||||
|
|
@ -1066,24 +1068,31 @@ export const CreateTeamDialog = ({
|
|||
if (cwdMode !== 'project') {
|
||||
return;
|
||||
}
|
||||
if (selectedProjectPath || projects.length === 0) {
|
||||
if (selectedProjectPath) {
|
||||
return;
|
||||
}
|
||||
if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) {
|
||||
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
|
||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
||||
if (selectableProjects.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||
const match = selectableProjects.find(
|
||||
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
|
||||
);
|
||||
if (match) {
|
||||
setSelectedProjectPath(match.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedProjectPath(projects[0].path);
|
||||
setSelectedProjectPath(selectableProjects[0].path);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||
return;
|
||||
}
|
||||
if (!isEphemeralRenderedProjectPath(selectedProjectPath)) {
|
||||
if (!isEphemeralProjectPath(selectedProjectPath)) {
|
||||
return;
|
||||
}
|
||||
setSelectedProjectPath('');
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import {
|
|||
normalizeExplicitTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -104,15 +105,15 @@ import {
|
|||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import {
|
||||
buildProviderPrepareModelChecksSignature,
|
||||
buildProviderPrepareRequestSignature,
|
||||
buildProviderPrepareRuntimeStatusSignature,
|
||||
} from './providerPrepareRequestSignature';
|
||||
import {
|
||||
getShortLivedProviderPrepareModelResults,
|
||||
storeShortLivedProviderPrepareModelResults,
|
||||
} from './providerPrepareShortLivedCache';
|
||||
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
||||
import {
|
||||
deriveEffectiveProvisioningPrepareState,
|
||||
|
|
@ -1206,7 +1207,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// Launch-only effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
|
||||
? ''
|
||||
: selectedProjectPath.trim();
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
|
||||
const prepareRuntimeStatusSignature = useMemo(
|
||||
() =>
|
||||
buildProviderPrepareRuntimeStatusSignature(
|
||||
|
|
@ -1445,14 +1449,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const apiProjects = await api.getProjects();
|
||||
const apiProjects = (await api.getProjects()).filter(
|
||||
(project) => !isEphemeralProjectPath(project.path)
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
const pathSet = new Set(apiProjects.map((p) => p.path));
|
||||
const extras: Project[] = [];
|
||||
for (const repo of repositoryGroups) {
|
||||
for (const wt of repo.worktrees) {
|
||||
if (!pathSet.has(wt.path)) {
|
||||
if (!isEphemeralProjectPath(wt.path) && !pathSet.has(wt.path)) {
|
||||
pathSet.add(wt.path);
|
||||
extras.push({
|
||||
id: wt.id,
|
||||
|
|
@ -1485,17 +1491,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return;
|
||||
if (defaultProjectPath) {
|
||||
const match = projects.find((p) => p.path === defaultProjectPath);
|
||||
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
|
||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
||||
if (selectableProjects.length === 0) return;
|
||||
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||
const match = selectableProjects.find(
|
||||
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
|
||||
);
|
||||
if (match) {
|
||||
setSelectedProjectPath(match.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedProjectPath(projects[0].path);
|
||||
setSelectedProjectPath(selectableProjects[0].path);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||
return;
|
||||
}
|
||||
if (!isEphemeralProjectPath(selectedProjectPath)) {
|
||||
return;
|
||||
}
|
||||
setSelectedProjectPath('');
|
||||
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
|
||||
|
||||
// Pre-warm file list cache so @-mention file search is instant
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
|
||||
|
|
@ -141,6 +140,7 @@ type ProvisioningDetailSummary =
|
|||
| 'Runtime provider is not configured'
|
||||
| 'CLI preflight failed'
|
||||
| 'Selected model compatibility pending'
|
||||
| 'Selected model available'
|
||||
| 'Selected model verified'
|
||||
| 'Selected model unavailable'
|
||||
| 'Selected model verification timed out'
|
||||
|
|
@ -148,6 +148,25 @@ type ProvisioningDetailSummary =
|
|||
| 'Ready with notes'
|
||||
| 'Needs attention';
|
||||
|
||||
function isSelectedModelDetail(lower: string): boolean {
|
||||
return lower.includes('selected model');
|
||||
}
|
||||
|
||||
function isFormattedModelDetail(lower: string): boolean {
|
||||
return (
|
||||
lower.includes(' - checking...') ||
|
||||
lower.includes(' - verified') ||
|
||||
lower.includes(' - available for launch') ||
|
||||
lower.includes(' - compatible, deep verification pending') ||
|
||||
lower.includes(' - unavailable') ||
|
||||
lower.includes(' - check failed')
|
||||
);
|
||||
}
|
||||
|
||||
function isModelDetail(lower: string): boolean {
|
||||
return isSelectedModelDetail(lower) || isFormattedModelDetail(lower);
|
||||
}
|
||||
|
||||
function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
|
|
@ -199,32 +218,38 @@ function summarizeDetail(
|
|||
if (lower.includes('claude cli preflight check failed')) {
|
||||
return 'CLI preflight failed';
|
||||
}
|
||||
if (lower.includes('compatible, deep verification pending')) {
|
||||
if (isModelDetail(lower) && lower.includes('compatible, deep verification pending')) {
|
||||
return 'Selected model compatibility pending';
|
||||
}
|
||||
if (lower.includes('selected model') && lower.includes('verified for launch')) {
|
||||
if (isSelectedModelDetail(lower) && lower.includes('verified for launch')) {
|
||||
return 'Selected model verified';
|
||||
}
|
||||
if (lower.includes('selected model') && lower.includes('is unavailable')) {
|
||||
if (isSelectedModelDetail(lower) && lower.includes('available for launch')) {
|
||||
return 'Selected model available';
|
||||
}
|
||||
if (isSelectedModelDetail(lower) && lower.includes('is unavailable')) {
|
||||
return 'Selected model unavailable';
|
||||
}
|
||||
if (
|
||||
lower.includes('selected model') &&
|
||||
isSelectedModelDetail(lower) &&
|
||||
lower.includes('could not be verified') &&
|
||||
lower.includes('timed out')
|
||||
) {
|
||||
return 'Selected model verification timed out';
|
||||
}
|
||||
if (lower.includes('selected model') && lower.includes('could not be verified')) {
|
||||
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
|
||||
return 'Selected model check failed';
|
||||
}
|
||||
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('timed out')) {
|
||||
if (lower.includes(' - check failed') && lower.includes('timed out')) {
|
||||
return 'Selected model verification timed out';
|
||||
}
|
||||
if (lower.includes(' - check failed -')) {
|
||||
|
|
@ -242,6 +267,7 @@ function summarizeDetail(
|
|||
|
||||
function getModelDetailSummary(details: string[]): string | null {
|
||||
let compatibilityPendingCount = 0;
|
||||
let availableCount = 0;
|
||||
let verifiedCount = 0;
|
||||
let unavailableCount = 0;
|
||||
let timedOutCount = 0;
|
||||
|
|
@ -250,23 +276,46 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
|
||||
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(' - verified')) {
|
||||
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 -')) {
|
||||
if (
|
||||
lower.includes(' - unavailable -') ||
|
||||
(isSelectedModelDetail(lower) && lower.includes('is unavailable'))
|
||||
) {
|
||||
unavailableCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.includes('timed out')) {
|
||||
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 -')) {
|
||||
if (
|
||||
lower.includes(' - check failed -') ||
|
||||
(isSelectedModelDetail(lower) && lower.includes('could not be verified'))
|
||||
) {
|
||||
checkFailedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -291,6 +340,9 @@ function getModelDetailSummary(details: string[]): string | null {
|
|||
if (checkingCount > 0) {
|
||||
parts.push(`${checkingCount} checking`);
|
||||
}
|
||||
if (availableCount > 0) {
|
||||
parts.push(`${availableCount} available`);
|
||||
}
|
||||
if (verifiedCount > 0) {
|
||||
parts.push(`${verifiedCount} verified`);
|
||||
}
|
||||
|
|
@ -337,7 +389,7 @@ function getDetailTone(
|
|||
status: ProvisioningProviderCheckStatus
|
||||
): 'success' | 'failure' | 'checking' | 'neutral' {
|
||||
const summary = summarizeDetail(detail, status);
|
||||
if (summary === 'Selected model verified') {
|
||||
if (summary === 'Selected model verified' || summary === 'Selected model available') {
|
||||
return 'success';
|
||||
}
|
||||
if (summary === 'Selected model verification timed out') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
|
||||
|
||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
import type { Project } from '@shared/types';
|
||||
|
|
@ -24,6 +25,10 @@ export function buildProjectPathOptions(
|
|||
const normalizedPreferredPath = preferredPath ? normalizePath(preferredPath) : null;
|
||||
|
||||
for (const project of projects) {
|
||||
if (isEphemeralProjectPath(project.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedProjectPath = normalizePath(project.path);
|
||||
const existingIndex = optionIndexByNormalizedPath.get(normalizedProjectPath);
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ function buildModelSuccessLine(providerId: TeamProviderId, modelId: string): str
|
|||
return `${getModelLabel(providerId, modelId)} - verified`;
|
||||
}
|
||||
|
||||
function buildModelAvailableLine(providerId: TeamProviderId, modelId: string): string {
|
||||
return `${getModelLabel(providerId, modelId)} - available for launch`;
|
||||
}
|
||||
|
||||
function buildModelCompatibilityPendingLine(providerId: TeamProviderId, modelId: string): string {
|
||||
return `${getModelLabel(providerId, modelId)} - compatible, deep verification pending...`;
|
||||
}
|
||||
|
|
@ -132,6 +136,11 @@ function stripSelectedModelPrefix(modelId: string, message: string): string {
|
|||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} could not be verified\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} verified for launch\\.\\s*`, 'i'),
|
||||
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
|
||||
new RegExp(
|
||||
`^Selected model ${escapeRegExp(modelId)} is compatible\\. Deep verification pending\\.\\s*`,
|
||||
'i'
|
||||
),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(trimmed)) {
|
||||
|
|
@ -389,6 +398,28 @@ function resolveModelResultFromBatch(
|
|||
};
|
||||
}
|
||||
|
||||
const hasAvailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is available for launch\./i.test(entry)
|
||||
);
|
||||
if (hasAvailableLine) {
|
||||
return {
|
||||
status: 'ready',
|
||||
line: buildModelAvailableLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
const hasCompatibilityLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is compatible\. deep verification pending\./i.test(entry)
|
||||
);
|
||||
if (hasCompatibilityLine) {
|
||||
return {
|
||||
status: 'notes',
|
||||
line: buildModelCompatibilityPendingLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is unavailable\./i.test(entry)
|
||||
);
|
||||
|
|
@ -421,16 +452,10 @@ function resolveModelResultFromBatch(
|
|||
}
|
||||
|
||||
if (result.ready && (result.warnings?.length ?? 0) > 0 && !hasModelScopedEntries) {
|
||||
const line = buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
'Verification did not complete after runtime preflight warning'
|
||||
);
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
line: buildModelCompatibilityPendingLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -474,6 +499,20 @@ function resolveModelResultFromCompatibilityBatch(
|
|||
return { kind: 'compatible' };
|
||||
}
|
||||
|
||||
const hasAvailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is available for launch\./i.test(entry)
|
||||
);
|
||||
if (hasAvailableLine) {
|
||||
return {
|
||||
kind: 'terminal',
|
||||
result: {
|
||||
status: 'ready',
|
||||
line: buildModelAvailableLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is unavailable\./i.test(entry)
|
||||
);
|
||||
|
|
@ -856,30 +895,31 @@ export async function runProviderPrepareDiagnostics({
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
const batchedModelResult = await prepareProvisioning(
|
||||
const compatibilityResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
uncachedModelIds,
|
||||
limitContext
|
||||
limitContext,
|
||||
'compatibility'
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
|
||||
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = [...(batchedModelResult.warnings ?? [])].filter(
|
||||
runtimeWarnings = [...(compatibilityResult.warnings ?? [])].filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
|
||||
const hasModelScopedEntries = uncachedModelIds.some(
|
||||
(modelId) => getModelScopedEntries(modelId, batchedModelResult).length > 0
|
||||
(modelId) => getModelScopedEntries(modelId, compatibilityResult).length > 0
|
||||
);
|
||||
const hasNonModelScopedDiagnostics =
|
||||
runtimeDetailLines.length > 0 || runtimeWarnings.length > 0;
|
||||
const hasSingleModelFallbackReason =
|
||||
uncachedModelIds.length === 1 &&
|
||||
looksLikeSingleModelBatchFailure(uncachedModelIds[0], batchedModelResult);
|
||||
looksLikeSingleModelBatchFailure(uncachedModelIds[0], compatibilityResult);
|
||||
if (
|
||||
!batchedModelResult.ready &&
|
||||
!compatibilityResult.ready &&
|
||||
!hasModelScopedEntries &&
|
||||
(uncachedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))
|
||||
|
|
@ -888,7 +928,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
status: 'failed',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...(batchedModelResult.message ? [batchedModelResult.message] : []),
|
||||
...(compatibilityResult.message ? [compatibilityResult.message] : []),
|
||||
],
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
|
|
@ -905,11 +945,46 @@ export async function runProviderPrepareDiagnostics({
|
|||
resolveModelResultFromBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
compatibilityResult,
|
||||
uncachedModelIds.length === 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
emitProgress();
|
||||
|
||||
if (!hasFailure) {
|
||||
try {
|
||||
const deepResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
undefined,
|
||||
limitContext,
|
||||
'deep'
|
||||
);
|
||||
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
runtimeWarnings = [...(deepResult.warnings ?? [])].filter(
|
||||
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
|
||||
);
|
||||
if (
|
||||
!deepResult.ready &&
|
||||
runtimeDetailLines.length === 0 &&
|
||||
runtimeWarnings.length === 0
|
||||
) {
|
||||
runtimeWarnings = deepResult.message ? [deepResult.message] : [];
|
||||
}
|
||||
} catch (deepError) {
|
||||
hasNotes = true;
|
||||
runtimeWarnings = [
|
||||
normalizeModelReason(
|
||||
deepError instanceof Error ? deepError.message.trim() : String(deepError).trim()
|
||||
) ?? 'One-shot diagnostic failed',
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export const MemberCard = ({
|
|||
const dotClass = launchPresentation.dotClass;
|
||||
const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel;
|
||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||
const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone;
|
||||
const presenceLabel = launchPresentation.presenceLabel;
|
||||
const spawnCardClass = launchPresentation.cardClass;
|
||||
const launchVisualState = launchPresentation.launchVisualState;
|
||||
|
|
@ -236,12 +237,22 @@ export const MemberCard = ({
|
|||
) : null}
|
||||
{!activityTask && isAwaitingReply ? (
|
||||
<>
|
||||
<Loader2
|
||||
className={`size-3 shrink-0 animate-spin ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
|
||||
style={runtimeAdvisoryLabel ? undefined : { color: colors.border }}
|
||||
/>
|
||||
{runtimeAdvisoryTone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0 text-red-400" />
|
||||
) : (
|
||||
<Loader2
|
||||
className={`size-3 shrink-0 animate-spin ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
|
||||
style={runtimeAdvisoryLabel ? undefined : { color: colors.border }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`shrink-0 text-[10px] ${runtimeAdvisoryLabel ? 'text-amber-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
className={`shrink-0 text-[10px] ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'text-red-300'
|
||||
: runtimeAdvisoryLabel
|
||||
? 'text-amber-300'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle ?? 'Message sent, awaiting reply'}
|
||||
>
|
||||
{runtimeAdvisoryLabel ?? 'awaiting reply'}
|
||||
|
|
@ -308,10 +319,18 @@ export const MemberCard = ({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-amber-400" />
|
||||
<AlertTriangle
|
||||
className={`size-3.5 shrink-0 ${
|
||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-amber-300"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'bg-amber-500/15 text-amber-300'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{runtimeAdvisoryLabel}
|
||||
|
|
|
|||
|
|
@ -85,11 +85,15 @@ export const MemberDetailHeader = ({
|
|||
const launchVisualState = launchPresentation.launchVisualState;
|
||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||
const dotClass = launchPresentation.dotClass;
|
||||
const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel;
|
||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||
const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone;
|
||||
const badgeLabel =
|
||||
launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
|
||||
const canEditRole =
|
||||
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||
|
|
@ -147,7 +151,11 @@ export const MemberDetailHeader = ({
|
|||
<>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
className={`px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{badgeLabel}
|
||||
|
|
|
|||
|
|
@ -124,11 +124,15 @@ export const MemberHoverCard = ({
|
|||
const launchVisualState = launchPresentation.launchVisualState;
|
||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||
const dotClass = launchPresentation.dotClass;
|
||||
const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel;
|
||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||
const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone;
|
||||
const badgeLabel =
|
||||
launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
|
|
@ -173,9 +177,18 @@ export const MemberHoverCard = ({
|
|||
className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
|
||||
title={runtimeAdvisoryTitle}
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: getThemedText(colors, isLight),
|
||||
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||
backgroundColor:
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'rgba(239, 68, 68, 0.16)'
|
||||
: getThemedBadge(colors, isLight),
|
||||
color:
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'rgb(252, 165, 165)'
|
||||
: getThemedText(colors, isLight),
|
||||
border:
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? '1px solid rgba(248, 113, 113, 0.35)'
|
||||
: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||
}}
|
||||
>
|
||||
{badgeLabel}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronDown, Clock, FileSearch, X } from 'lucide-react';
|
||||
|
||||
import { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
|
||||
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
|
|
@ -67,6 +67,41 @@ function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
|
|||
return 'scope' in cs;
|
||||
}
|
||||
|
||||
const TaskChangesEmptyState = ({
|
||||
changeSet,
|
||||
}: {
|
||||
changeSet: TaskChangeSetV2 | null;
|
||||
}): React.ReactElement => {
|
||||
const warnings = changeSet?.warnings ?? [];
|
||||
const hasWarnings = warnings.length > 0;
|
||||
const Icon = hasWarnings ? AlertTriangle : FileSearch;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-6">
|
||||
<div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center">
|
||||
<Icon
|
||||
className={cn('mx-auto mb-2 size-5', hasWarnings ? 'text-amber-300' : 'text-text-muted')}
|
||||
/>
|
||||
<div className="text-sm font-medium text-text">
|
||||
{hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-text-muted">
|
||||
{hasWarnings
|
||||
? 'The task ledger did not expose any safe file diff for this task. The diagnostics below explain why.'
|
||||
: 'The task ledger has no file events for this task.'}
|
||||
</p>
|
||||
{warnings.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-left text-xs text-amber-200">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={`${warning}:${index}`}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChangeReviewDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -1213,6 +1248,16 @@ export const ChangeReviewDialog = ({
|
|||
resetAllReviewState,
|
||||
]);
|
||||
|
||||
const taskChangeSet =
|
||||
activeChangeSet && isTaskChangeSetV2(activeChangeSet) ? activeChangeSet : null;
|
||||
const hasReviewFiles = (activeChangeSet?.files.length ?? 0) > 0;
|
||||
const shouldShowScopeBanner =
|
||||
mode === 'task' &&
|
||||
!!taskChangeSet &&
|
||||
(taskChangeSet.provenance?.sourceKind !== 'ledger' ||
|
||||
taskChangeSet.warnings.length > 0 ||
|
||||
taskChangeSet.scope.confidence.tier > 1);
|
||||
|
||||
// Active file for timeline (derived from scroll-spy)
|
||||
const activeFile = useMemo(() => {
|
||||
if (!activeChangeSet || !activeFilePath) return null;
|
||||
|
|
@ -1224,7 +1269,7 @@ export const ChangeReviewDialog = ({
|
|||
const task = taskId ? globalTasks.find((t) => t.id === taskId) : undefined;
|
||||
const shortId = task?.displayId ?? taskId?.slice(0, 8) ?? '?';
|
||||
const subject = task?.subject;
|
||||
return subject ? `Changes for task #${shortId} — ${subject}` : `Changes for task #${shortId}`;
|
||||
return subject ? `Changes for task #${shortId} - ${subject}` : `Changes for task #${shortId}`;
|
||||
}, [mode, memberName, taskId, globalTasks]);
|
||||
|
||||
const isMacElectron =
|
||||
|
|
@ -1272,33 +1317,31 @@ export const ChangeReviewDialog = ({
|
|||
/>
|
||||
|
||||
{/* Review toolbar */}
|
||||
{!changeSetLoading &&
|
||||
!changeSetError &&
|
||||
activeChangeSet &&
|
||||
activeChangeSet.files.length > 0 && (
|
||||
<ReviewToolbar
|
||||
stats={reviewStats}
|
||||
changeStats={changeStats}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
onAutoViewedChange={setAutoViewed}
|
||||
onAcceptAll={handleAcceptAll}
|
||||
onRejectAll={handleRejectAll}
|
||||
onApply={handleApply}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
instantApply={REVIEW_INSTANT_APPLY}
|
||||
editedCount={editedCount}
|
||||
canUndo={reviewUndoStack.length > 0}
|
||||
onUndo={handleUndoBulk}
|
||||
/>
|
||||
)}
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet && hasReviewFiles && (
|
||||
<ReviewToolbar
|
||||
stats={reviewStats}
|
||||
changeStats={changeStats}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
onAutoViewedChange={setAutoViewed}
|
||||
onAcceptAll={handleAcceptAll}
|
||||
onRejectAll={handleRejectAll}
|
||||
onApply={handleApply}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
instantApply={REVIEW_INSTANT_APPLY}
|
||||
editedCount={editedCount}
|
||||
canUndo={reviewUndoStack.length > 0}
|
||||
onUndo={handleUndoBulk}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scope info / warnings + confidence badge */}
|
||||
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
|
||||
{shouldShowScopeBanner && taskChangeSet && (
|
||||
<ScopeWarningBanner
|
||||
warnings={activeChangeSet.warnings}
|
||||
confidence={activeChangeSet.scope.confidence}
|
||||
warnings={taskChangeSet.warnings}
|
||||
confidence={taskChangeSet.scope.confidence}
|
||||
sourceKind={taskChangeSet.provenance?.sourceKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -1319,7 +1362,7 @@ export const ChangeReviewDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet && (
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet && hasReviewFiles && (
|
||||
<>
|
||||
{/* File tree */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
|
||||
|
|
@ -1425,10 +1468,8 @@ export const ChangeReviewDialog = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet?.files.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-text-muted">
|
||||
No file changes detected
|
||||
</div>
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet && !hasReviewFiles && (
|
||||
<TaskChangesEmptyState changeSet={taskChangeSet} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { TaskScopeConfidence } from '@shared/types';
|
|||
interface ConfidenceBadgeProps {
|
||||
confidence: TaskScopeConfidence;
|
||||
showTooltip?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const TIER_COLORS: Record<number, string> = {
|
||||
|
|
@ -19,13 +20,17 @@ const TIER_LABELS: Record<number, string> = {
|
|||
4: 'Best effort',
|
||||
};
|
||||
|
||||
export const ConfidenceBadge = ({ confidence, showTooltip = true }: ConfidenceBadgeProps) => {
|
||||
export const ConfidenceBadge = ({
|
||||
confidence,
|
||||
showTooltip = true,
|
||||
label,
|
||||
}: ConfidenceBadgeProps) => {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded border px-2 py-0.5 text-xs ${TIER_COLORS[confidence.tier] ?? TIER_COLORS[4]}`}
|
||||
title={showTooltip ? confidence.reason : undefined}
|
||||
>
|
||||
{TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]}
|
||||
{label ?? TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export const ContinuousScrollView = ({
|
|||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
No file changes detected
|
||||
No reviewable file changes
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export const FileSectionDiff = ({
|
|||
|
||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
|
||||
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
|
||||
const hasLedgerManualAction = file.snippets.some(
|
||||
(snippet) =>
|
||||
!!snippet.ledger &&
|
||||
|
|
@ -143,11 +144,13 @@ export const FileSectionDiff = ({
|
|||
<div className="overflow-auto">
|
||||
<OversizedDiffNotice
|
||||
message={
|
||||
canRenderCodeMirror && !canRenderSnippetPreview
|
||||
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
||||
: canRenderCodeMirror
|
||||
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
|
||||
: 'Diff preview skipped because the available change data is too large to render safely.'
|
||||
hasLedgerManualAction || isContentUnavailable
|
||||
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
|
||||
: canRenderCodeMirror && !canRenderSnippetPreview
|
||||
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
||||
: canRenderCodeMirror
|
||||
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
|
||||
: 'Diff preview skipped because the available change data is too large to render safely.'
|
||||
}
|
||||
/>
|
||||
{canRenderSnippetPreview ? <ReviewDiffContent file={file} /> : null}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
|||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
||||
'ledger-exact': 'Ledger Exact',
|
||||
'ledger-exact': 'Task Ledger',
|
||||
'ledger-snapshot': 'Ledger Snapshot',
|
||||
'file-history': 'File History',
|
||||
'snippet-reconstruction': 'Reconstructed',
|
||||
'disk-current': 'Current Disk',
|
||||
'git-fallback': 'Git Fallback',
|
||||
unavailable: 'Missing on disk',
|
||||
unavailable: 'Content unavailable',
|
||||
};
|
||||
|
||||
interface FileSectionHeaderProps {
|
||||
|
|
@ -58,7 +58,8 @@ export const FileSectionHeader = ({
|
|||
onRejectFile,
|
||||
}: FileSectionHeaderProps): React.ReactElement => {
|
||||
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
|
||||
const isPreviewOnly = isMissingOnDisk || fileContent?.contentSource === 'unavailable';
|
||||
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
|
||||
const isPreviewOnly = isMissingOnDisk || isContentUnavailable;
|
||||
const requiresManualLedgerReview = file.snippets.some(
|
||||
(snippet) =>
|
||||
!!snippet.ledger &&
|
||||
|
|
@ -76,7 +77,12 @@ export const FileSectionHeader = ({
|
|||
if (writeSnippets.length === 0) return null;
|
||||
return writeSnippets[writeSnippets.length - 1].newString;
|
||||
})();
|
||||
const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null;
|
||||
const canRestore =
|
||||
!!onRestoreMissingFile &&
|
||||
isMissingOnDisk &&
|
||||
!isContentUnavailable &&
|
||||
!hasEdits &&
|
||||
restoreContent != null;
|
||||
const externalChangeLabel =
|
||||
externalChange?.type === 'unlink'
|
||||
? 'Deleted on disk'
|
||||
|
|
@ -147,13 +153,26 @@ export const FileSectionHeader = ({
|
|||
isPreviewOnly ? 'bg-red-500/20 text-red-300' : 'bg-surface-raised text-text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
{isPreviewOnly
|
||||
? 'Missing on disk'
|
||||
: (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
|
||||
{isContentUnavailable
|
||||
? 'Content unavailable'
|
||||
: isMissingOnDisk
|
||||
? 'Missing on disk'
|
||||
: (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{isPreviewOnly ? (
|
||||
{isContentUnavailable ? (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-text">Text content is unavailable</div>
|
||||
<div className="text-text-muted">
|
||||
The ledger recorded metadata for this change, but full text content is not
|
||||
available. This usually means binary, large, or hash-only content.
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
Automatic accept/reject is disabled for this file to avoid unsafe disk writes.
|
||||
</div>
|
||||
</div>
|
||||
) : isMissingOnDisk ? (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-text">File is missing on disk</div>
|
||||
<div className="text-text-muted">
|
||||
|
|
@ -269,7 +288,9 @@ export const FileSectionHeader = ({
|
|||
</TooltipTrigger>
|
||||
{isPreviewOnly && (
|
||||
<TooltipContent side="bottom">
|
||||
Accept/Reject is disabled while the file is missing on disk.
|
||||
{isContentUnavailable
|
||||
? 'Accept/Reject is disabled because full text content is unavailable.'
|
||||
: 'Accept/Reject is disabled while the file is missing on disk.'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
|
@ -296,7 +317,9 @@ export const FileSectionHeader = ({
|
|||
<TooltipContent side="bottom">
|
||||
{requiresManualLedgerReview
|
||||
? 'Reject is disabled because this ledger change has binary, large, or unavailable content.'
|
||||
: 'Accept/Reject is disabled while the file is missing on disk.'}
|
||||
: isContentUnavailable
|
||||
? 'Reject is disabled because full text content is unavailable.'
|
||||
: 'Accept/Reject is disabled while the file is missing on disk.'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const FullDiffLoadingBanner = ({
|
|||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
|
||||
<FileDiff className="size-3.5" strokeWidth={1.8} />
|
||||
{snippetCount} snippet{snippetCount === 1 ? '' : 's'} ready
|
||||
{snippetCount} preview{snippetCount === 1 ? '' : 's'} ready
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
|
||||
<Clock3 className="size-3.5" strokeWidth={1.8} />
|
||||
|
|
@ -91,8 +91,8 @@ export const FullDiffLoadingBanner = ({
|
|||
</div>
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
{showFileProgress
|
||||
? `${readyFilesCount} ready, ${loadingFilesCount} still loading. Snippet previews stay visible below while the remaining baselines are reconstructed.`
|
||||
: 'Snippet previews stay visible below while the exact baseline is reconstructed.'}
|
||||
? `${readyFilesCount} ready, ${loadingFilesCount} still loading. Preview diffs stay visible below while the remaining baselines are resolved.`
|
||||
: 'Preview diffs stay visible below while the exact baseline is resolved.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export const ReviewToolbar = ({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Actions — hidden when all hunks are already decided */}
|
||||
{/* Actions hidden when all hunks are already decided */}
|
||||
{stats.pending > 0 && (
|
||||
<>
|
||||
<Tooltip>
|
||||
|
|
@ -206,10 +206,12 @@ export const ReviewToolbar = ({
|
|||
) : (
|
||||
<GitMerge className="size-3" />
|
||||
)}
|
||||
{applying ? 'Applying...' : 'Apply All Changes'}
|
||||
{applying ? 'Applying...' : 'Apply Rejections'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
Apply rejected hunks to disk; accepted changes are kept as-is
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { FC } from 'react';
|
|||
interface ScopeWarningBannerProps {
|
||||
warnings: string[];
|
||||
confidence: TaskScopeConfidence;
|
||||
sourceKind?: 'ledger' | 'legacy';
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ interface TierConfig {
|
|||
accentColor: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
badgeLabel?: string;
|
||||
}
|
||||
|
||||
const TIER_CONFIGS: Record<number, TierConfig> = {
|
||||
|
|
@ -31,7 +33,7 @@ const TIER_CONFIGS: Record<number, TierConfig> = {
|
|||
accentColor: 'text-emerald-400',
|
||||
title: 'Task scope determined precisely',
|
||||
detail:
|
||||
'Both start and completion markers found in the session log. The diff includes only changes made during this specific task — other tasks that modified the same files are excluded.',
|
||||
'Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded.',
|
||||
},
|
||||
2: {
|
||||
Icon: Info,
|
||||
|
|
@ -40,7 +42,7 @@ const TIER_CONFIGS: Record<number, TierConfig> = {
|
|||
accentColor: 'text-blue-400',
|
||||
title: 'End boundary estimated',
|
||||
detail:
|
||||
'Only the start marker was found — the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.',
|
||||
'Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.',
|
||||
},
|
||||
3: {
|
||||
Icon: AlertTriangle,
|
||||
|
|
@ -49,7 +51,7 @@ const TIER_CONFIGS: Record<number, TierConfig> = {
|
|||
accentColor: 'text-orange-400',
|
||||
title: 'Start boundary estimated',
|
||||
detail:
|
||||
'Only the completion marker was found — the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.',
|
||||
'Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.',
|
||||
},
|
||||
4: {
|
||||
Icon: AlertTriangle,
|
||||
|
|
@ -58,17 +60,56 @@ const TIER_CONFIGS: Record<number, TierConfig> = {
|
|||
accentColor: 'text-red-400',
|
||||
title: 'Showing all session changes',
|
||||
detail:
|
||||
'No task markers found in the session log. Cannot isolate this task — all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.',
|
||||
'No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.',
|
||||
},
|
||||
};
|
||||
|
||||
export const ScopeWarningBanner = ({
|
||||
warnings,
|
||||
confidence,
|
||||
sourceKind = 'legacy',
|
||||
onDismiss,
|
||||
}: ScopeWarningBannerProps): JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||
const ledgerConfig: TierConfig | null =
|
||||
sourceKind === 'ledger'
|
||||
? {
|
||||
Icon: confidence.tier <= 1 ? ShieldCheck : confidence.tier === 2 ? Info : AlertTriangle,
|
||||
border:
|
||||
confidence.tier <= 1
|
||||
? 'border-emerald-500/15'
|
||||
: confidence.tier === 2
|
||||
? 'border-blue-500/15'
|
||||
: 'border-orange-500/20',
|
||||
bg:
|
||||
confidence.tier <= 1
|
||||
? 'bg-emerald-500/5'
|
||||
: confidence.tier === 2
|
||||
? 'bg-blue-500/5'
|
||||
: 'bg-orange-500/5',
|
||||
accentColor:
|
||||
confidence.tier <= 1
|
||||
? 'text-emerald-400'
|
||||
: confidence.tier === 2
|
||||
? 'text-blue-400'
|
||||
: 'text-orange-400',
|
||||
title:
|
||||
confidence.tier <= 1
|
||||
? 'Changes captured by task ledger'
|
||||
: 'Changes captured with limited reviewability',
|
||||
detail:
|
||||
confidence.tier <= 1
|
||||
? 'The orchestrator captured these file changes while the agent was working on this task.'
|
||||
: 'The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.',
|
||||
badgeLabel:
|
||||
confidence.tier <= 1
|
||||
? 'Ledger exact'
|
||||
: confidence.tier === 2
|
||||
? 'Mixed reviewability'
|
||||
: 'Needs review',
|
||||
}
|
||||
: null;
|
||||
const config = ledgerConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||
const { Icon } = config;
|
||||
|
||||
return (
|
||||
|
|
@ -86,7 +127,7 @@ export const ScopeWarningBanner = ({
|
|||
|
||||
<div className="flex-1" />
|
||||
|
||||
<ConfidenceBadge confidence={confidence} />
|
||||
<ConfidenceBadge confidence={confidence} label={config.badgeLabel} />
|
||||
|
||||
{onDismiss && (
|
||||
<button onClick={onDismiss} className="text-text-muted hover:text-text">
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogSt
|
|||
};
|
||||
}
|
||||
|
||||
function buildStableSegmentRenderKey(segment: BoardTaskLogSegment): string {
|
||||
const firstChunkId = segment.chunks[0]?.id;
|
||||
if (firstChunkId) {
|
||||
return `${segment.participantKey}:${firstChunkId}`;
|
||||
}
|
||||
return `${segment.participantKey}:${segment.startTimestamp}`;
|
||||
}
|
||||
|
||||
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
|
||||
if (stream?.source === 'opencode_runtime_attribution') {
|
||||
return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.';
|
||||
|
|
@ -357,7 +365,11 @@ export const TaskLogStreamSection = ({
|
|||
) : (
|
||||
<div className="space-y-6">
|
||||
{visibleSegments.map((segment) => (
|
||||
<SegmentBlock key={segment.id} segment={segment} showHeader={showSegmentHeaders} />
|
||||
<SegmentBlock
|
||||
key={buildStableSegmentRenderKey(segment)}
|
||||
segment={segment}
|
||||
showHeader={showSegmentHeaders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,310 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>Agent Teams UI</title>
|
||||
<style>
|
||||
/* Splash: animated gradient background */
|
||||
#splash {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#0c0d13 0%,
|
||||
#1a1535 20%,
|
||||
#1e1245 35%,
|
||||
#231740 50%,
|
||||
#1a1535 65%,
|
||||
#151230 80%,
|
||||
#0c0d13 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
animation: splash-bg 6s ease infinite;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
@keyframes splash-bg {
|
||||
0% { background-position: 50% 0%; }
|
||||
50% { background-position: 50% 100%; }
|
||||
100% { background-position: 50% 0%; }
|
||||
}
|
||||
#splash-noise {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
opacity: 0.03; pointer-events: none;
|
||||
}
|
||||
#splash-logo {
|
||||
margin-bottom: 18px;
|
||||
animation: splash-breathe 3s ease-in-out infinite, splash-glow 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes splash-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
@keyframes splash-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 12px rgba(129,140,248,0.4)) drop-shadow(0 0 28px rgba(167,139,250,0.18)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(129,140,248,0.6)) drop-shadow(0 0 42px rgba(167,139,250,0.3)); }
|
||||
}
|
||||
#splash-text {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 15px; font-weight: 500; letter-spacing: 0.05em;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Logo node breathing — cycles through 3 agent nodes */
|
||||
@keyframes splash-node {
|
||||
0%, 100% { opacity: 0.18; }
|
||||
12%, 28% { opacity: 1; }
|
||||
45% { opacity: 0.18; }
|
||||
}
|
||||
.splash-node {
|
||||
animation: splash-node 3s cubic-bezier(0.4, 0, 0.2, 1) infinite both;
|
||||
}
|
||||
.splash-edge { transition: opacity 0.3s; }
|
||||
|
||||
/* Light theme splash overrides */
|
||||
:root.light #splash {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#e0e7ff 0%,
|
||||
#c7d2fe 18%,
|
||||
#ddd6fe 36%,
|
||||
#f0abfc 50%,
|
||||
#ddd6fe 64%,
|
||||
#c7d2fe 82%,
|
||||
#e0e7ff 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
}
|
||||
:root.light #splash-text { color: #52525b; }
|
||||
:root.light #splash-noise { opacity: 0.02; }
|
||||
:root.light #splash-logo {
|
||||
animation: splash-breathe 3s ease-in-out infinite, splash-glow-light 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes splash-glow-light {
|
||||
0%, 100% { filter: drop-shadow(0 0 10px rgba(79,70,229,0.3)) drop-shadow(0 0 24px rgba(139,92,246,0.15)); }
|
||||
50% { filter: drop-shadow(0 0 16px rgba(79,70,229,0.45)) drop-shadow(0 0 36px rgba(139,92,246,0.25)); }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Flash prevention: Apply cached theme before React loads
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('claude-devtools-theme-cache');
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light');
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>Agent Teams UI</title>
|
||||
<style>
|
||||
/* Splash: animated gradient background */
|
||||
#splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#0c0d13 0%,
|
||||
#1a1535 20%,
|
||||
#1e1245 35%,
|
||||
#231740 50%,
|
||||
#1a1535 65%,
|
||||
#151230 80%,
|
||||
#0c0d13 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
animation: splash-bg 6s ease infinite;
|
||||
transition:
|
||||
opacity 0.42s ease-out,
|
||||
filter 0.42s ease-out;
|
||||
}
|
||||
#splash.splash-exiting {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
@keyframes splash-bg {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="splash">
|
||||
<!-- SVG noise texture — matte paper grain -->
|
||||
<svg id="splash-noise" xmlns="http://www.w3.org/2000/svg">
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)"/>
|
||||
</svg>
|
||||
<!-- Logo with animated agent nodes -->
|
||||
<svg id="splash-logo" viewBox="0 0 56 56" width="56" height="56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect class="splash-logo-bg" width="56" height="56" rx="14" fill="#151620"/>
|
||||
<!-- Edges connecting nodes -->
|
||||
<line class="splash-edge" x1="19" y1="19" x2="37" y2="19" stroke="#818cf8" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
|
||||
<line class="splash-edge" x1="37" y1="19" x2="28" y2="37" stroke="#a78bfa" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
|
||||
<line class="splash-edge" x1="28" y1="37" x2="19" y2="19" stroke="#c084fc" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
|
||||
<!-- Agent nodes -->
|
||||
<circle class="splash-node splash-node-fill" style="animation-delay:0s" cx="19" cy="19" r="5.5" fill="#818cf8"/>
|
||||
<circle class="splash-node splash-node-fill" style="animation-delay:1s" cx="37" cy="19" r="5.5" fill="#a78bfa"/>
|
||||
<circle class="splash-node splash-node-fill" style="animation-delay:2s" cx="28" cy="37" r="6" fill="#c084fc"/>
|
||||
<!-- Core highlights -->
|
||||
<circle class="splash-node splash-core-fill" style="animation-delay:0s" cx="19" cy="19" r="2" fill="#e0e7ff"/>
|
||||
<circle class="splash-node splash-core-fill" style="animation-delay:1s" cx="37" cy="19" r="2" fill="#ede9fe"/>
|
||||
<circle class="splash-node splash-core-fill" style="animation-delay:2s" cx="28" cy="37" r="2.2" fill="#f3e8ff"/>
|
||||
</svg>
|
||||
<div id="splash-text">Agent Teams UI</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
50% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
}
|
||||
#splash-noise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
#splash-enhanced-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
#splash-logo,
|
||||
#splash-text {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
#splash-logo {
|
||||
margin-bottom: 18px;
|
||||
animation:
|
||||
splash-breathe 3s ease-in-out infinite,
|
||||
splash-glow 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes splash-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
@keyframes splash-glow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 12px rgba(129, 140, 248, 0.4))
|
||||
drop-shadow(0 0 28px rgba(167, 139, 250, 0.18));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 20px rgba(129, 140, 248, 0.6))
|
||||
drop-shadow(0 0 42px rgba(167, 139, 250, 0.3));
|
||||
}
|
||||
}
|
||||
#splash-text {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
color: #a1a1aa;
|
||||
text-shadow: 0 0 22px rgba(129, 140, 248, 0.26);
|
||||
}
|
||||
|
||||
/* Logo node breathing — cycles through 3 agent nodes */
|
||||
@keyframes splash-node {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
12%,
|
||||
28% {
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
}
|
||||
.splash-node {
|
||||
animation: splash-node 3s cubic-bezier(0.4, 0, 0.2, 1) infinite both;
|
||||
}
|
||||
.splash-edge {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* Light theme splash overrides */
|
||||
:root.light #splash {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#e0e7ff 0%,
|
||||
#c7d2fe 18%,
|
||||
#ddd6fe 36%,
|
||||
#f0abfc 50%,
|
||||
#ddd6fe 64%,
|
||||
#c7d2fe 82%,
|
||||
#e0e7ff 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
}
|
||||
:root.light #splash-text {
|
||||
color: #52525b;
|
||||
}
|
||||
:root.light #splash-noise {
|
||||
opacity: 0.02;
|
||||
}
|
||||
:root.light #splash-logo {
|
||||
animation:
|
||||
splash-breathe 3s ease-in-out infinite,
|
||||
splash-glow-light 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes splash-glow-light {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 10px rgba(79, 70, 229, 0.3))
|
||||
drop-shadow(0 0 24px rgba(139, 92, 246, 0.15));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 16px rgba(79, 70, 229, 0.45))
|
||||
drop-shadow(0 0 36px rgba(139, 92, 246, 0.25));
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash,
|
||||
#splash-logo,
|
||||
.splash-node {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Flash prevention: Apply cached theme before React loads
|
||||
(function () {
|
||||
try {
|
||||
window.__claudeTeamsSplashStartedAt = performance.now();
|
||||
var theme = localStorage.getItem('claude-devtools-theme-cache');
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="splash">
|
||||
<!-- SVG noise texture — matte paper grain -->
|
||||
<svg id="splash-noise" xmlns="http://www.w3.org/2000/svg">
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.65"
|
||||
numOctaves="3"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||
</svg>
|
||||
<!-- Logo with animated agent nodes -->
|
||||
<svg
|
||||
id="splash-logo"
|
||||
viewBox="0 0 56 56"
|
||||
width="56"
|
||||
height="56"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect class="splash-logo-bg" width="56" height="56" rx="14" fill="#151620" />
|
||||
<!-- Edges connecting nodes -->
|
||||
<line
|
||||
class="splash-edge"
|
||||
x1="19"
|
||||
y1="19"
|
||||
x2="37"
|
||||
y2="19"
|
||||
stroke="#818cf8"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<line
|
||||
class="splash-edge"
|
||||
x1="37"
|
||||
y1="19"
|
||||
x2="28"
|
||||
y2="37"
|
||||
stroke="#a78bfa"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<line
|
||||
class="splash-edge"
|
||||
x1="28"
|
||||
y1="37"
|
||||
x2="19"
|
||||
y2="19"
|
||||
stroke="#c084fc"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<!-- Agent nodes -->
|
||||
<circle
|
||||
class="splash-node splash-node-fill"
|
||||
style="animation-delay: 0s"
|
||||
cx="19"
|
||||
cy="19"
|
||||
r="5.5"
|
||||
fill="#818cf8"
|
||||
/>
|
||||
<circle
|
||||
class="splash-node splash-node-fill"
|
||||
style="animation-delay: 1s"
|
||||
cx="37"
|
||||
cy="19"
|
||||
r="5.5"
|
||||
fill="#a78bfa"
|
||||
/>
|
||||
<circle
|
||||
class="splash-node splash-node-fill"
|
||||
style="animation-delay: 2s"
|
||||
cx="28"
|
||||
cy="37"
|
||||
r="6"
|
||||
fill="#c084fc"
|
||||
/>
|
||||
<!-- Core highlights -->
|
||||
<circle
|
||||
class="splash-node splash-core-fill"
|
||||
style="animation-delay: 0s"
|
||||
cx="19"
|
||||
cy="19"
|
||||
r="2"
|
||||
fill="#e0e7ff"
|
||||
/>
|
||||
<circle
|
||||
class="splash-node splash-core-fill"
|
||||
style="animation-delay: 1s"
|
||||
cx="37"
|
||||
cy="19"
|
||||
r="2"
|
||||
fill="#ede9fe"
|
||||
/>
|
||||
<circle
|
||||
class="splash-node splash-core-fill"
|
||||
style="animation-delay: 2s"
|
||||
cx="28"
|
||||
cy="37"
|
||||
r="2.2"
|
||||
fill="#f3e8ff"
|
||||
/>
|
||||
</svg>
|
||||
<div id="splash-text">Agent Teams UI</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import { startSplashScene } from './components/splash/splashScene.ts';
|
||||
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash && !window.__claudeTeamsSplashScene) {
|
||||
window.__claudeTeamsSplashScene = startSplashScene(splash);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -312,6 +312,26 @@ function formatRuntimeAdvisoryBaseLabel(
|
|||
providerId: TeamProviderId | undefined
|
||||
): string {
|
||||
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
|
||||
if (advisory.kind === 'api_error') {
|
||||
switch (advisory.reasonCode) {
|
||||
case 'quota_exhausted':
|
||||
return providerLabel ? `${providerLabel} quota error` : 'Quota error';
|
||||
case 'rate_limited':
|
||||
return providerLabel ? `${providerLabel} rate limit` : 'Rate limit';
|
||||
case 'auth_error':
|
||||
return providerLabel ? `${providerLabel} auth error` : 'Auth error';
|
||||
case 'network_error':
|
||||
return 'Network error';
|
||||
case 'provider_overloaded':
|
||||
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
return providerLabel ? `${providerLabel} API error` : 'API error';
|
||||
default:
|
||||
return 'API error';
|
||||
}
|
||||
}
|
||||
|
||||
switch (advisory.reasonCode) {
|
||||
case 'quota_exhausted':
|
||||
return providerLabel ? `${providerLabel} quota retry` : 'Quota retry';
|
||||
|
|
@ -336,6 +356,41 @@ function formatRuntimeAdvisoryTitle(
|
|||
providerId: TeamProviderId | undefined
|
||||
): string {
|
||||
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
|
||||
if (advisory.kind === 'api_error') {
|
||||
switch (advisory.reasonCode) {
|
||||
case 'quota_exhausted':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} quota exhausted.`,
|
||||
advisory.message
|
||||
);
|
||||
case 'rate_limited':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} rate limited the request.`,
|
||||
advisory.message
|
||||
);
|
||||
case 'auth_error':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} authentication error.`,
|
||||
advisory.message
|
||||
);
|
||||
case 'network_error':
|
||||
return appendRuntimeAdvisoryRawMessage('Network or connectivity error.', advisory.message);
|
||||
case 'provider_overloaded':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'Provider is temporarily overloaded.',
|
||||
advisory.message
|
||||
);
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} API error.`,
|
||||
advisory.message
|
||||
);
|
||||
default:
|
||||
return advisory.message?.trim() || 'Provider API error.';
|
||||
}
|
||||
}
|
||||
|
||||
switch (advisory.reasonCode) {
|
||||
case 'quota_exhausted':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
|
|
@ -381,11 +436,17 @@ export function getMemberRuntimeAdvisoryLabel(
|
|||
providerId?: TeamProviderId,
|
||||
nowMs = Date.now()
|
||||
): string | null {
|
||||
if (advisory?.kind !== 'sdk_retrying') {
|
||||
if (!advisory) {
|
||||
return null;
|
||||
}
|
||||
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
||||
const retryUntilMs = Date.parse(advisory.retryUntil);
|
||||
if (advisory.kind === 'api_error') {
|
||||
return baseLabel;
|
||||
}
|
||||
if (advisory.kind !== 'sdk_retrying') {
|
||||
return null;
|
||||
}
|
||||
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
|
||||
if (!Number.isFinite(retryUntilMs)) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
|
@ -400,12 +461,21 @@ export function getMemberRuntimeAdvisoryTitle(
|
|||
advisory: MemberRuntimeAdvisory | undefined,
|
||||
providerId?: TeamProviderId
|
||||
): string | undefined {
|
||||
if (advisory?.kind !== 'sdk_retrying') {
|
||||
if (!advisory || (advisory.kind !== 'sdk_retrying' && advisory.kind !== 'api_error')) {
|
||||
return undefined;
|
||||
}
|
||||
return formatRuntimeAdvisoryTitle(advisory, providerId);
|
||||
}
|
||||
|
||||
export function getMemberRuntimeAdvisoryTone(
|
||||
advisory: MemberRuntimeAdvisory | undefined
|
||||
): 'error' | 'warning' | null {
|
||||
if (!advisory) {
|
||||
return null;
|
||||
}
|
||||
return advisory.kind === 'api_error' ? 'error' : 'warning';
|
||||
}
|
||||
|
||||
export function getLaunchAwarePresenceLabel(
|
||||
member: ResolvedTeamMember,
|
||||
spawnStatus: MemberSpawnStatus | undefined,
|
||||
|
|
@ -457,6 +527,7 @@ export interface MemberLaunchPresentation {
|
|||
cardClass: string;
|
||||
runtimeAdvisoryLabel: string | null;
|
||||
runtimeAdvisoryTitle?: string;
|
||||
runtimeAdvisoryTone: 'error' | 'warning' | null;
|
||||
launchVisualState: MemberLaunchVisualState;
|
||||
launchStatusLabel: string | null;
|
||||
spawnBadgeLabel: string | null;
|
||||
|
|
@ -536,6 +607,7 @@ export function buildMemberLaunchPresentation({
|
|||
);
|
||||
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId);
|
||||
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
|
||||
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
|
||||
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||
|
||||
let launchVisualState: MemberLaunchVisualState = null;
|
||||
|
|
@ -578,10 +650,11 @@ export function buildMemberLaunchPresentation({
|
|||
|
||||
return {
|
||||
presenceLabel,
|
||||
dotClass,
|
||||
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
||||
cardClass,
|
||||
runtimeAdvisoryLabel,
|
||||
runtimeAdvisoryTitle,
|
||||
runtimeAdvisoryTone,
|
||||
launchVisualState,
|
||||
launchStatusLabel,
|
||||
spawnBadgeLabel,
|
||||
|
|
|
|||
|
|
@ -734,10 +734,10 @@ export interface ResolvedTeamMember {
|
|||
}
|
||||
|
||||
export interface MemberRuntimeAdvisory {
|
||||
kind: 'sdk_retrying';
|
||||
kind: 'sdk_retrying' | 'api_error';
|
||||
observedAt: string;
|
||||
retryUntil: string;
|
||||
retryDelayMs: number;
|
||||
retryUntil?: string;
|
||||
retryDelayMs?: number;
|
||||
reasonCode?:
|
||||
| 'quota_exhausted'
|
||||
| 'rate_limited'
|
||||
|
|
@ -747,6 +747,7 @@ export interface MemberRuntimeAdvisory {
|
|||
| 'backend_error'
|
||||
| 'unknown';
|
||||
message?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface TeamProcess {
|
||||
|
|
|
|||
42
src/shared/utils/__tests__/ephemeralProjectPath.test.ts
Normal file
42
src/shared/utils/__tests__/ephemeralProjectPath.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isEphemeralProjectPath } from '../ephemeralProjectPath';
|
||||
|
||||
function absolutePath(...segments: string[]): string {
|
||||
return `/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
describe('isEphemeralProjectPath', () => {
|
||||
it('detects generated MCP project paths', () => {
|
||||
expect(isEphemeralProjectPath(absolutePath('tmp', 'rendered_mcp_config', 'project'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(isEphemeralProjectPath('/Users/test/rendered_mcp_123/project')).toBe(true);
|
||||
expect(isEphemeralProjectPath(absolutePath('tmp', 'portable-mcp-live', 'project'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Codex appstyle temp workspaces only under temp roots', () => {
|
||||
expect(
|
||||
isEphemeralProjectPath(
|
||||
'/private/var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/codex-agent-teams-appstyle-zudek6i9'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isEphemeralProjectPath(absolutePath('tmp', 'codex-agent-teams-appstyle-zudek6i9'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isEphemeralProjectPath(
|
||||
'C:\\Users\\test\\AppData\\Local\\Temp\\codex-agent-teams-appstyle-zudek6i9'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isEphemeralProjectPath('/Users/test/projects/codex-agent-teams-appstyle-real')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps normal project paths selectable', () => {
|
||||
expect(isEphemeralProjectPath('/Users/test/projects/claude_team')).toBe(false);
|
||||
expect(isEphemeralProjectPath('')).toBe(false);
|
||||
expect(isEphemeralProjectPath(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
41
src/shared/utils/ephemeralProjectPath.ts
Normal file
41
src/shared/utils/ephemeralProjectPath.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const TMP_SEGMENT = 'tmp';
|
||||
const POSIX_TMP_ROOT = `/${TMP_SEGMENT}/`;
|
||||
const PRIVATE_TMP_ROOT = `/private/${TMP_SEGMENT}/`;
|
||||
|
||||
function normalizePathForEphemeralCheck(projectPath: string): string {
|
||||
return projectPath.trim().replace(/\\/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
function getBasename(normalizedPath: string): string {
|
||||
const segments = normalizedPath.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? '';
|
||||
}
|
||||
|
||||
function isKnownTempRoot(normalizedPath: string): boolean {
|
||||
return (
|
||||
normalizedPath.startsWith('/private/var/folders/') ||
|
||||
normalizedPath.startsWith('/var/folders/') ||
|
||||
normalizedPath.startsWith(PRIVATE_TMP_ROOT) ||
|
||||
normalizedPath.startsWith(POSIX_TMP_ROOT) ||
|
||||
normalizedPath.includes('/appdata/local/temp/') ||
|
||||
normalizedPath.includes('/appdata/locallow/temp/')
|
||||
);
|
||||
}
|
||||
|
||||
export function isEphemeralProjectPath(projectPath: string | null | undefined): boolean {
|
||||
const normalizedPath = normalizePathForEphemeralCheck(projectPath ?? '');
|
||||
if (!normalizedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedPath.includes('rendered_mcp_') ||
|
||||
normalizedPath.includes('rendered_mcp_config') ||
|
||||
normalizedPath.includes('/portable-mcp-live')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const basename = getBasename(normalizedPath);
|
||||
return basename.startsWith('codex-agent-teams-appstyle-') && isKnownTempRoot(normalizedPath);
|
||||
}
|
||||
|
|
@ -133,9 +133,12 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
|||
|
||||
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||
expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', {
|
||||
liveCount: 1,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'codex recent-projects recovered with live-only fallback',
|
||||
{
|
||||
liveCount: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not spend extra time on live-only fallback after a full session timeout', async () => {
|
||||
|
|
@ -165,4 +168,45 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
|||
});
|
||||
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
listRecentThreads: vi.fn().mockResolvedValue({
|
||||
live: {
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-temp',
|
||||
cwd: '/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9',
|
||||
source: 'cli',
|
||||
updatedAt: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
archived: {
|
||||
threads: [],
|
||||
},
|
||||
}),
|
||||
listRecentLiveThreads: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
const identityResolver = {
|
||||
resolve: vi.fn(),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
|
||||
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||
appServerClient,
|
||||
identityResolver,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [],
|
||||
degraded: false,
|
||||
});
|
||||
|
||||
expect(identityResolver.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ import {
|
|||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
|
||||
function makeProject(
|
||||
overrides: Partial<DashboardRecentProject> = {}
|
||||
): DashboardRecentProject {
|
||||
function makeProject(overrides: Partial<DashboardRecentProject> = {}): DashboardRecentProject {
|
||||
return {
|
||||
id: 'repo:alpha',
|
||||
name: 'alpha',
|
||||
|
|
@ -130,4 +128,28 @@ describe('recentProjectOpenHistory', () => {
|
|||
)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('does not record generated ephemeral project paths', () => {
|
||||
recordRecentProjectOpenPaths(
|
||||
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
|
||||
10_000
|
||||
);
|
||||
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9',
|
||||
associatedPaths: ['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9'],
|
||||
})
|
||||
)
|
||||
).toBe(0);
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/workspace/opened',
|
||||
associatedPaths: ['/workspace/opened'],
|
||||
})
|
||||
)
|
||||
).toBe(10_000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe('CliProviderModelAvailabilityService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('passes provider launch args into codex model probes', async () => {
|
||||
it('passes provider launch args before codex model probe flags', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
|
||||
|
|
@ -167,10 +167,19 @@ describe('CliProviderModelAvailabilityService', () => {
|
|||
await vi.waitFor(() => {
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/usr/local/bin/claude',
|
||||
expect.arrayContaining([
|
||||
[
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]),
|
||||
'-p',
|
||||
'Output only the single word PONG.',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'gpt-5.4',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: { HOME: '/Users/tester' },
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -120,7 +120,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
JSON.stringify({
|
||||
timestamp: nowIso,
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: nowIso,
|
||||
|
|
@ -152,7 +155,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
|
||||
it.each([
|
||||
['rate_limited', 'Provider returned 429 rate limit for this request.'],
|
||||
['rate_limited', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.'],
|
||||
[
|
||||
'rate_limited',
|
||||
'All credentials for model claude-opus-4-6 are cooling down via provider claude.',
|
||||
],
|
||||
['auth_error', 'Authentication failed due to invalid API key.'],
|
||||
['network_error', 'Fetch failed because the network connection timed out.'],
|
||||
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
|
||||
|
|
@ -192,6 +198,61 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
expect(advisory?.reasonCode).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps terminal API errors visible after retries stop', () => {
|
||||
const service = new TeamMemberRuntimeAdvisoryService({} as never);
|
||||
const observedAt = '2099-04-09T10:00:00.000Z';
|
||||
const advisory = (service as any).extractApiErrorAdvisory(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: observedAt,
|
||||
isApiErrorMessage: true,
|
||||
error: 'unknown',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'API Error: 500 {"error":{"message":"auth_unavailable: no auth available","type":"server_error"}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
Date.parse(observedAt)
|
||||
) as MemberRuntimeAdvisory | null;
|
||||
|
||||
expect(advisory).toMatchObject({
|
||||
kind: 'api_error',
|
||||
reasonCode: 'auth_error',
|
||||
statusCode: 500,
|
||||
});
|
||||
expect(advisory?.retryUntil).toBeUndefined();
|
||||
expect(advisory?.message).toContain('auth_unavailable');
|
||||
});
|
||||
|
||||
it('treats Claude Code account access failures as auth errors', () => {
|
||||
const service = new TeamMemberRuntimeAdvisoryService({} as never);
|
||||
const observedAt = '2099-04-09T10:00:00.000Z';
|
||||
const advisory = (service as any).extractApiErrorAdvisory(
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: observedAt,
|
||||
isApiErrorMessage: true,
|
||||
error: 'authentication_failed',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Your account does not have access to Claude Code. Please run /login.',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
Date.parse(observedAt)
|
||||
) as MemberRuntimeAdvisory | null;
|
||||
|
||||
expect(advisory?.kind).toBe('api_error');
|
||||
expect(advisory?.reasonCode).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('ignores expired retry advisories', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -234,7 +295,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
JSON.stringify({
|
||||
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).' },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const execCliMock = vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
const defaultExecCliMockImplementation = async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
|
|
@ -48,7 +48,11 @@ const execCliMock = vi.fn(async (_binaryPath: string | null, args: string[]) =>
|
|||
},
|
||||
codex: {
|
||||
defaultModel: 'gpt-5.4-mini',
|
||||
models: [{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Codex default' }],
|
||||
models: [
|
||||
{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex selected model' },
|
||||
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'Codex default' },
|
||||
{ id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', description: 'Codex model' },
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
|
|
@ -83,7 +87,8 @@ const execCliMock = vi.fn(async (_binaryPath: string | null, args: string[]) =>
|
|||
}
|
||||
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
});
|
||||
};
|
||||
const execCliMock = vi.fn(defaultExecCliMockImplementation);
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
|
||||
spawnCli: vi.fn(),
|
||||
|
|
@ -258,7 +263,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execCliMock.mockClear();
|
||||
execCliMock.mockReset();
|
||||
execCliMock.mockImplementation(defaultExecCliMockImplementation);
|
||||
addTeamNotificationMock.mockResolvedValue(null);
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
|
|
@ -294,6 +300,85 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(fs.existsSync(missingCwd)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips advisory one-shot diagnostics when the prepare cwd is missing', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const missingCwd = path.join(tempRoot, 'missing-project');
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
|
||||
|
||||
const result = await (svc as any).runProviderOneShotDiagnostic(
|
||||
'/fake/claude',
|
||||
missingCwd,
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add one-shot ENOENT warnings after a missing cwd preflight warning', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const missingCwd = path.join(tempRoot, 'missing-project');
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'codex_runtime',
|
||||
warning: `Working directory does not exist: ${missingCwd}`,
|
||||
});
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
});
|
||||
vi.spyOn(svc as any, 'readRuntimeProviderLaunchFacts').mockResolvedValue({
|
||||
defaultModel: null,
|
||||
modelIds: new Set(['gpt-5.4']),
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: null,
|
||||
providerStatus: null,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
|
||||
|
||||
const result = await svc.prepareForProvisioning(missingCwd, {
|
||||
forceFresh: true,
|
||||
providerId: 'codex',
|
||||
modelIds: ['gpt-5.4'],
|
||||
modelVerificationMode: 'deep',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain('Selected model gpt-5.4 is available for launch.');
|
||||
expect(result.warnings).toEqual([`Working directory does not exist: ${missingCwd}`]);
|
||||
expect(result.warnings?.join('\n')).not.toContain('One-shot diagnostic');
|
||||
expect(result.warnings?.join('\n')).not.toContain('ENOENT');
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not misclassify binary ENOENT as a missing cwd when cwd exists', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue(new Error('spawn /missing/cli ENOENT'));
|
||||
|
||||
const result = await (svc as any).probeClaudeRuntime(
|
||||
'/missing/cli',
|
||||
tempRoot,
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
'codex',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(result.warning).toContain('binary failed to start');
|
||||
expect(result.warning).toContain('spawn /missing/cli ENOENT');
|
||||
expect(result.warning).not.toContain('Working directory does not exist');
|
||||
});
|
||||
|
||||
it('blocks OpenCode prepare without probing the legacy Claude stream-json runtime', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const probeSpy = vi.spyOn(svc as any, 'getCachedOrProbeResult');
|
||||
|
|
@ -506,10 +591,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
])
|
||||
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
|
||||
);
|
||||
expect(maxActiveCount).toBe(2);
|
||||
expect(releases.has('opencode/big-pickle')).toBe(false);
|
||||
|
|
@ -555,10 +637,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
modelId: 'openrouter/minimax-m2.5-free',
|
||||
availableModels: [
|
||||
'opencode/minimax-m2.5-free',
|
||||
'opencode/nemotron-3-super-free',
|
||||
],
|
||||
availableModels: ['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'],
|
||||
opencodeVersion: '1.0.0',
|
||||
installMethod: 'unknown',
|
||||
binaryPath: 'opencode',
|
||||
|
|
@ -677,7 +756,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.details).toEqual(['Selected model opencode/minimax-m2.5-free verified for launch.']);
|
||||
expect(result.details).toEqual([
|
||||
'Selected model opencode/minimax-m2.5-free verified for launch.',
|
||||
]);
|
||||
expect(result.message).toBe(
|
||||
'Selected model opencode/nemotron-3-super-free is unavailable. bridge exploded'
|
||||
);
|
||||
|
|
@ -735,7 +816,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('verifies the selected Codex model during prepare and records a success detail', async () => {
|
||||
it('checks the selected Codex model from the runtime catalog during prepare', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
|
|
@ -762,18 +843,11 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain('Selected model gpt-5.4 verified for launch.');
|
||||
expect(spawnProbe).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
expect.arrayContaining(['--model', 'gpt-5.4']),
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(result.details).toContain('Selected model gpt-5.4 is available for launch.');
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verifies the resolved Codex default model during prepare', async () => {
|
||||
it('checks the Codex default model without running a print-mode probe', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
|
|
@ -802,19 +876,12 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain(
|
||||
`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`
|
||||
);
|
||||
expect(spawnProbe).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
expect.arrayContaining(['--model', 'gpt-5.4-mini']),
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
expect.any(Object)
|
||||
`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} is available for launch.`
|
||||
);
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verifies the resolved Anthropic default model during prepare with limitContext', async () => {
|
||||
it('checks the Anthropic default model during prepare with limitContext without print mode', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
|
|
@ -843,16 +910,49 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain(
|
||||
`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} verified for launch.`
|
||||
`Selected model ${DEFAULT_PROVIDER_MODEL_SELECTION} is available for launch.`
|
||||
);
|
||||
expect(spawnProbe).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
expect.arrayContaining(['--model', 'opus']),
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
expect.any(Object)
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Anthropic selected-model prepare terminal when compatibility mode is requested', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'oauth_token',
|
||||
});
|
||||
const verifySelectedProviderModels = vi
|
||||
.spyOn(svc as any, 'verifySelectedProviderModels')
|
||||
.mockResolvedValue({
|
||||
details: [
|
||||
'Selected model opus verified for launch.',
|
||||
'Selected model sonnet verified for launch.',
|
||||
],
|
||||
warnings: [],
|
||||
blockingMessages: [],
|
||||
});
|
||||
const runProviderOneShotDiagnostic = vi.spyOn(svc as any, 'runProviderOneShotDiagnostic');
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
forceFresh: true,
|
||||
providerId: 'anthropic',
|
||||
modelIds: ['opus', 'sonnet'],
|
||||
modelVerificationMode: 'compatibility',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toEqual([
|
||||
'Selected model opus verified for launch.',
|
||||
'Selected model sonnet verified for launch.',
|
||||
]);
|
||||
expect(result.details?.some((line) => line.includes('compatible'))).toBe(false);
|
||||
expect(verifySelectedProviderModels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: 'anthropic',
|
||||
modelIds: ['opus', 'sonnet'],
|
||||
})
|
||||
);
|
||||
expect(runProviderOneShotDiagnostic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back from an unavailable Anthropic 1M launch id to the base model during prepare', async () => {
|
||||
|
|
@ -902,15 +1002,8 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain('Selected model opus[1m] verified for launch.');
|
||||
expect(spawnProbe).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
expect.arrayContaining(['--model', 'opus']),
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(result.details).toContain('Selected model opus[1m] is available for launch.');
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails prepare when the selected Codex model is unavailable', async () => {
|
||||
|
|
@ -927,11 +1020,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
});
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue(
|
||||
new Error(
|
||||
"The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account."
|
||||
)
|
||||
);
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
forceFresh: true,
|
||||
|
|
@ -941,10 +1030,11 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toContain('Selected model gpt-5.2-codex is unavailable.');
|
||||
expect(result.message).toContain('Not available on this Codex native runtime');
|
||||
expect(result.message).toContain('was not found in the live provider catalog');
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps timed out Codex model verification as a warning with a clean generic reason', async () => {
|
||||
it('keeps timed out Codex one-shot diagnostics as a runtime warning', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
|
|
@ -960,7 +1050,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
});
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue(
|
||||
new Error(
|
||||
'Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence'
|
||||
'Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model haiku --max-turns 1 --no-session-persistence'
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -968,11 +1058,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
forceFresh: true,
|
||||
providerId: 'codex',
|
||||
modelIds: ['gpt-5.3-codex'],
|
||||
modelVerificationMode: 'deep',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Selected model gpt-5.3-codex could not be verified. Model verification timed out'
|
||||
expect(result.details).toContain('Selected model gpt-5.3-codex is available for launch.');
|
||||
expect(result.warnings?.join('\n')).toContain(
|
||||
'One-shot diagnostic timed out after runtime readiness passed'
|
||||
);
|
||||
expect(result.warnings?.join('\n')).not.toContain(
|
||||
'Selected model gpt-5.3-codex could not be verified'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -996,21 +1091,179 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('includes CLI output in generic preflight failures', async () => {
|
||||
it('uses runtime status for codex primary preflight without print mode', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'spawnProbe')
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'orchestrator-cli 1.2.3',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'upstream unavailable',
|
||||
stderr: 'request id: req_123',
|
||||
exitCode: 1,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({
|
||||
stdout: 'orchestrator-cli 1.2.3',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await (svc as any).probeClaudeRuntime(
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
forceFresh: true,
|
||||
providerId: 'codex',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
['runtime', 'status', '--json', '--provider', 'codex'],
|
||||
expect.objectContaining({ cwd: tempRoot })
|
||||
);
|
||||
expect(spawnProbe).toHaveBeenCalledTimes(1);
|
||||
const spawnedArgLists = spawnProbe.mock.calls.map((call) => call[1] as string[]);
|
||||
expect(spawnedArgLists.some((args) => args.includes('-p'))).toBe(false);
|
||||
});
|
||||
|
||||
it('passes provider launch args before codex runtime status subcommands', async () => {
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const result = await (svc as any).probeProviderRuntimeControlPlane({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
providerId: 'codex',
|
||||
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
|
||||
});
|
||||
|
||||
expect(result.warning).toBeUndefined();
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
[
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
'runtime',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
'codex',
|
||||
],
|
||||
expect.objectContaining({ cwd: tempRoot })
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back from runtime status timeout to auth status and still checks selected models', async () => {
|
||||
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === 'runtime' && args[1] === 'status') {
|
||||
throw new Error('Timeout running: orchestrator-cli runtime status --json --provider codex');
|
||||
}
|
||||
if (args[0] === 'auth') {
|
||||
return {
|
||||
stdout: JSON.stringify({ loggedIn: true, authMethod: 'chatgpt' }),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providers: {
|
||||
codex: {
|
||||
defaultModel: 'gpt-5.4-mini',
|
||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({
|
||||
stdout: 'orchestrator-cli 1.2.3',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
forceFresh: true,
|
||||
providerId: 'codex',
|
||||
modelIds: ['gpt-5.4'],
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.details).toContain('Selected model gpt-5.4 is available for launch.');
|
||||
expect(result.warnings?.join('\n')).toContain('runtime status was unavailable');
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
['auth', 'status', '--json', '--provider', 'codex'],
|
||||
expect.objectContaining({ cwd: tempRoot })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes provider launch args before auth status fallback subcommands', async () => {
|
||||
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args.includes('runtime')) {
|
||||
throw new Error('runtime status failed');
|
||||
}
|
||||
if (args.includes('auth')) {
|
||||
return {
|
||||
stdout: JSON.stringify({ loggedIn: true, authMethod: 'chatgpt' }),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const result = await (svc as any).probeProviderRuntimeControlPlane({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
providerId: 'codex',
|
||||
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
|
||||
});
|
||||
|
||||
expect(result.warning).toContain('runtime status was unavailable');
|
||||
expect(execCliMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/fake/claude',
|
||||
[
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
'auth',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
'codex',
|
||||
],
|
||||
expect.objectContaining({ cwd: tempRoot })
|
||||
);
|
||||
});
|
||||
|
||||
it('includes CLI output in advisory one-shot diagnostic failures', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockResolvedValueOnce({
|
||||
stdout: 'upstream unavailable',
|
||||
stderr: 'request id: req_123',
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
const result = await (svc as any).runProviderOneShotDiagnostic(
|
||||
'/fake/claude',
|
||||
tempRoot,
|
||||
{
|
||||
|
|
@ -1020,27 +1273,21 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
'codex'
|
||||
);
|
||||
|
||||
expect(result.warning).toContain('One-shot diagnostic failed after runtime readiness passed');
|
||||
expect(result.warning).toContain('preflight check failed (exit code 1). Details:');
|
||||
expect(result.warning).toContain('upstream unavailable');
|
||||
expect(result.warning).toContain('request id: req_123');
|
||||
});
|
||||
|
||||
it('passes provider launch args into codex preflight ping probes', async () => {
|
||||
it('passes provider launch args before codex advisory one-shot probe flags', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const spawnProbe = vi
|
||||
.spyOn(svc as any, 'spawnProbe')
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'orchestrator-cli 1.2.3',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'PONG',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValueOnce({
|
||||
stdout: 'PONG',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await (svc as any).probeClaudeRuntime(
|
||||
const result = await (svc as any).runProviderOneShotDiagnostic(
|
||||
'/fake/claude',
|
||||
tempRoot,
|
||||
{
|
||||
|
|
@ -1053,12 +1300,21 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
|
||||
expect(result.warning).toBeUndefined();
|
||||
expect(spawnProbe).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
1,
|
||||
'/fake/claude',
|
||||
expect.arrayContaining([
|
||||
[
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]),
|
||||
'-p',
|
||||
'Output only the single word PONG.',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'gpt-5.4-mini',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
],
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
|
|
@ -1132,7 +1388,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes provider launch args into selected codex model probes', async () => {
|
||||
it('passes provider launch args into selected codex catalog checks', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
env: {
|
||||
|
|
@ -1152,11 +1408,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
runtimeCapabilities: null,
|
||||
providerStatus: null,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({
|
||||
stdout: 'PONG',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
|
||||
|
||||
const result = await (svc as any).verifySelectedProviderModels({
|
||||
claudePath: '/fake/claude',
|
||||
|
|
@ -1166,25 +1418,113 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(result.details).toEqual(['Selected model gpt-5.4 verified for launch.']);
|
||||
expect(result.details).toEqual(['Selected model gpt-5.4 is available for launch.']);
|
||||
expect(readRuntimeProviderLaunchFacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
|
||||
})
|
||||
);
|
||||
expect(spawnProbe).toHaveBeenCalledWith(
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes provider launch args before model-list catalog subcommands', async () => {
|
||||
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args.includes('model')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providers: {
|
||||
codex: {
|
||||
defaultModel: 'gpt-5.4-mini',
|
||||
models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (args.includes('runtime')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
codex: {
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: false, source: 'runtime' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
await (svc as any).readRuntimeProviderLaunchFacts({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
providerId: 'codex',
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/fake/claude',
|
||||
expect.arrayContaining([
|
||||
[
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]),
|
||||
tempRoot,
|
||||
expect.any(Object),
|
||||
60_000,
|
||||
expect.any(Object)
|
||||
'model',
|
||||
'list',
|
||||
'--json',
|
||||
'--provider',
|
||||
'codex',
|
||||
],
|
||||
expect.objectContaining({ cwd: tempRoot })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps missing models compatible when the runtime catalog is dynamic', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
authSource: 'codex_runtime',
|
||||
geminiRuntimeAuth: null,
|
||||
});
|
||||
vi.spyOn(svc as any, 'readRuntimeProviderLaunchFacts').mockResolvedValue({
|
||||
defaultModel: null,
|
||||
modelIds: new Set(),
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'runtime' } },
|
||||
providerStatus: null,
|
||||
});
|
||||
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
|
||||
|
||||
const result = await (svc as any).verifySelectedProviderModels({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
providerId: 'codex',
|
||||
modelIds: ['future-model'],
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
details: ['Selected model future-model is compatible. Deep verification pending.'],
|
||||
warnings: [],
|
||||
blockingMessages: [],
|
||||
});
|
||||
expect(spawnProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
status: 'failed',
|
||||
backendSummary: 'Codex native',
|
||||
details: [
|
||||
'5.4 Mini - verified',
|
||||
'5.4 Mini - available for launch',
|
||||
'5.1 Codex Max - unavailable - Not available on this Codex native runtime',
|
||||
],
|
||||
},
|
||||
|
|
@ -65,9 +65,9 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain(
|
||||
'Codex (Codex native): Selected model checks - 1 model unavailable, 1 verified'
|
||||
'Codex (Codex native): Selected model checks - 1 model unavailable, 1 available'
|
||||
);
|
||||
expect(host.textContent).toContain('5.4 Mini - verified');
|
||||
expect(host.textContent).toContain('5.4 Mini - available for launch');
|
||||
expect(host.textContent).toContain(
|
||||
'5.1 Codex Max - unavailable - Not available on this Codex native runtime'
|
||||
);
|
||||
|
|
@ -131,6 +131,43 @@ describe('ProvisioningProviderStatusList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProviderStatusList, {
|
||||
checks: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
status: 'notes',
|
||||
details: [
|
||||
'One-shot diagnostic timed out after runtime readiness passed. This does not mark selected models unavailable. Details: Model verification timed out',
|
||||
'Opus 4.6 - available for launch',
|
||||
'Opus 4.7 - available for launch',
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic: Selected model checks - 2 available');
|
||||
expect(host.textContent).not.toContain('1 model timed out');
|
||||
expect(host.textContent).toContain(
|
||||
'One-shot diagnostic timed out after runtime readiness passed'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('summarizes compatibility-pending OpenCode model checks separately from verified ones', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -65,4 +65,27 @@ describe('buildProjectPathOptions', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes generated ephemeral project paths', () => {
|
||||
const options = buildProjectPathOptions([
|
||||
createProject({
|
||||
id: 'project-temp',
|
||||
name: 'codex-agent-teams-appstyle-zudek6i9',
|
||||
path: '/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9',
|
||||
}),
|
||||
createProject({
|
||||
id: 'project-real',
|
||||
name: 'claude_team',
|
||||
path: '/Users/belief/dev/projects/claude/claude_team',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(options).toEqual([
|
||||
{
|
||||
value: '/Users/belief/dev/projects/claude/claude_team',
|
||||
label: 'claude_team',
|
||||
description: '/Users/belief/dev/projects/claude/claude_team',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,19 +54,21 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
it('returns a failed provider result immediately when runtime preflight fails', async () => {
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>().mockResolvedValue({
|
||||
ready: false,
|
||||
message: 'Codex runtime is not authenticated.',
|
||||
});
|
||||
const prepareProvisioning = vi
|
||||
.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>()
|
||||
.mockResolvedValue({
|
||||
ready: false,
|
||||
message: 'Codex runtime is not authenticated.',
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
|
|
@ -265,8 +267,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
>((_, __, ___, selectedModels) => {
|
||||
return Promise.resolve({
|
||||
ready: false,
|
||||
message:
|
||||
`API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.1-codex-max' model is not supported when using Codex with a ChatGPT account.\\"}"}}`,
|
||||
message: `API Error: 400 {"type":"error","error":{"type":"api_error","message":"Codex API error (400): {\\"detail\\":\\"The 'gpt-5.1-codex-max' model is not supported when using Codex with a ChatGPT account.\\"}"}}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -378,9 +379,95 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
expect(result.details).toEqual(['Default - verified']);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'anthropic', ['anthropic'], [
|
||||
DEFAULT_PROVIDER_MODEL_SELECTION,
|
||||
], true);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'anthropic',
|
||||
['anthropic'],
|
||||
[DEFAULT_PROVIDER_MODEL_SELECTION],
|
||||
true,
|
||||
'compatibility'
|
||||
);
|
||||
});
|
||||
|
||||
it('checks multiple Anthropic selected models without OpenCode compatibility-pending progress', async () => {
|
||||
const progressUpdates: Array<{
|
||||
status: 'checking' | 'ready' | 'notes' | 'failed';
|
||||
details: string[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}> = [];
|
||||
const prepareProvisioning = vi.fn<
|
||||
(
|
||||
cwd?: string,
|
||||
providerId?: TeamProviderId,
|
||||
providerIds?: TeamProviderId[],
|
||||
selectedModels?: string[],
|
||||
limitContext?: boolean,
|
||||
modelVerificationMode?: 'compatibility' | 'deep'
|
||||
) => Promise<TeamProvisioningPrepareResult>
|
||||
>((_, __, ___, selectedModels, ____, modelVerificationMode) => {
|
||||
if (selectedModels) {
|
||||
expect(modelVerificationMode).toBe('compatibility');
|
||||
expect(selectedModels).toEqual(['claude-test-a', 'claude-test-b']);
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
details: [
|
||||
'Selected model claude-test-a verified for launch.',
|
||||
'Selected model claude-test-b verified for launch.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
expect(modelVerificationMode).toBe('deep');
|
||||
return Promise.resolve({
|
||||
ready: true,
|
||||
message: 'CLI is warmed up and ready to launch',
|
||||
details: [],
|
||||
});
|
||||
});
|
||||
|
||||
const result = await runProviderPrepareDiagnostics({
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
selectedModelIds: ['claude-test-a', 'claude-test-b'],
|
||||
prepareProvisioning,
|
||||
onModelProgress: (progress) => progressUpdates.push(progress),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.details).toEqual(['claude-test-a - verified', 'claude-test-b - verified']);
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
status: 'checking',
|
||||
completedCount: 0,
|
||||
totalCount: 2,
|
||||
details: ['claude-test-a - checking...', 'claude-test-b - checking...'],
|
||||
});
|
||||
expect(
|
||||
progressUpdates
|
||||
.flatMap((progress) => progress.details)
|
||||
.some((line) => line.includes('compatible'))
|
||||
).toBe(false);
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'anthropic',
|
||||
['anthropic'],
|
||||
['claude-test-a', 'claude-test-b'],
|
||||
undefined,
|
||||
'compatibility'
|
||||
);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/tmp/project',
|
||||
'anthropic',
|
||||
['anthropic'],
|
||||
undefined,
|
||||
undefined,
|
||||
'deep'
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses cached model results and probes only newly selected models', async () => {
|
||||
|
|
@ -438,9 +525,15 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
'5.2 Codex - unavailable - Not available on this Codex native runtime',
|
||||
]);
|
||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(1, '/tmp/project', 'codex', ['codex'], [
|
||||
'gpt-5.2-codex',
|
||||
], undefined);
|
||||
expect(prepareProvisioning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/tmp/project',
|
||||
'codex',
|
||||
['codex'],
|
||||
['gpt-5.2-codex'],
|
||||
undefined,
|
||||
'compatibility'
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses a timed out runtime preflight note when that same model later verifies', async () => {
|
||||
|
|
@ -501,18 +594,16 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
|
||||
expect(result.status).toBe('notes');
|
||||
expect(result.warnings).toEqual([
|
||||
'5.4 - check failed - Verification did not complete after runtime preflight warning',
|
||||
]);
|
||||
expect(result.warnings).toEqual(['orchestrator-cli preflight check failed (exit code 1).']);
|
||||
expect(result.details).toEqual([
|
||||
'5.4 - check failed - Verification did not complete after runtime preflight warning',
|
||||
'orchestrator-cli preflight check failed (exit code 1).',
|
||||
'5.4 - compatible, deep verification pending...',
|
||||
]);
|
||||
expect(result.modelResultsById).toEqual({
|
||||
'gpt-5.4': {
|
||||
status: 'notes',
|
||||
line: '5.4 - check failed - Verification did not complete after runtime preflight warning',
|
||||
warningLine:
|
||||
'5.4 - check failed - Verification did not complete after runtime preflight warning',
|
||||
line: '5.4 - compatible, deep verification pending...',
|
||||
warningLine: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -530,7 +621,9 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
ready: true,
|
||||
message: 'CLI is ready to launch (see notes)',
|
||||
details: ['Selected model gpt-5.4 verified for launch.'],
|
||||
warnings: ['orchestrator-cli preflight check failed (exit code 1). Details: upstream unavailable'],
|
||||
warnings: [
|
||||
'orchestrator-cli preflight check failed (exit code 1). Details: upstream unavailable',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,12 +33,25 @@ vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({
|
|||
}: {
|
||||
memberName?: string;
|
||||
chunks: { id: string }[];
|
||||
}) =>
|
||||
React.createElement(
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'member-execution-log' },
|
||||
`${memberName ?? 'lead'}:${chunks.length}`
|
||||
),
|
||||
{
|
||||
'data-testid': 'member-execution-log',
|
||||
'data-expanded': expanded ? 'true' : 'false',
|
||||
},
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'data-testid': `member-execution-log-toggle:${memberName ?? 'lead'}`,
|
||||
onClick: () => setExpanded((prev) => !prev),
|
||||
},
|
||||
`${memberName ?? 'lead'}:${chunks.length}`
|
||||
)
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
|
||||
|
|
@ -63,7 +76,9 @@ function buildSegment(args: {
|
|||
memberName: string;
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
chunkIds?: string[];
|
||||
}) {
|
||||
const chunkIds = args.chunkIds ?? [`chunk-${args.id}`];
|
||||
return {
|
||||
id: args.id,
|
||||
participantKey: args.participantKey,
|
||||
|
|
@ -76,7 +91,11 @@ function buildSegment(args: {
|
|||
},
|
||||
startTimestamp: args.startTimestamp,
|
||||
endTimestamp: args.endTimestamp,
|
||||
chunks: [{ id: `chunk-${args.id}`, chunkType: 'user', rawMessages: [] }] as never,
|
||||
chunks: chunkIds.map((chunkId) => ({
|
||||
id: chunkId,
|
||||
chunkType: 'user',
|
||||
rawMessages: [],
|
||||
})) as never,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -499,6 +518,90 @@ describe('TaskLogStreamSection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('preserves expanded state when a live refresh extends the current segment tail', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
||||
apiState.onTeamChange.mockImplementation((callback) => {
|
||||
handler = callback;
|
||||
return () => {
|
||||
handler = null;
|
||||
};
|
||||
});
|
||||
|
||||
apiState.getTaskLogStream
|
||||
.mockResolvedValueOnce({
|
||||
participants: [buildParticipant('member:alice', 'alice')],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'member:alice:chunk-start:chunk-start',
|
||||
participantKey: 'member:alice',
|
||||
memberName: 'alice',
|
||||
startTimestamp: '2026-04-24T10:00:00.000Z',
|
||||
endTimestamp: '2026-04-24T10:01:00.000Z',
|
||||
chunkIds: ['chunk-start'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
participants: [buildParticipant('member:alice', 'alice')],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'member:alice:chunk-start:chunk-next',
|
||||
participantKey: 'member:alice',
|
||||
memberName: 'alice',
|
||||
startTimestamp: '2026-04-24T10:00:00.000Z',
|
||||
endTimestamp: '2026-04-24T10:02:00.000Z',
|
||||
chunkIds: ['chunk-start', 'chunk-next'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const logNodeBefore = host.querySelector('[data-testid="member-execution-log"]');
|
||||
const toggle = host.querySelector(
|
||||
'[data-testid="member-execution-log-toggle:alice"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(logNodeBefore?.getAttribute('data-expanded')).toBe('true');
|
||||
expect(toggle).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="member-execution-log"]')?.getAttribute('data-expanded')
|
||||
).toBe('false');
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const logNodeAfter = host.querySelector('[data-testid="member-execution-log"]');
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
|
||||
expect(logNodeAfter?.getAttribute('data-expanded')).toBe('false');
|
||||
expect(logNodeAfter?.textContent).toBe('alice:2');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not subscribe to live refresh when live mode is disabled', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
|
|
|
|||
|
|
@ -1580,6 +1580,29 @@ describe('TeamGraphAdapter particles', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not project warning-only change presence as file changes', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData({
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-warning-only',
|
||||
displayId: '#6',
|
||||
subject: 'Needs attention without file diff',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
changePresence: 'needs_attention',
|
||||
} as TeamTaskWithKanban,
|
||||
],
|
||||
}),
|
||||
'my-team'
|
||||
);
|
||||
|
||||
expect(findNode(graph, 'task:my-team:task-warning-only')).toMatchObject({
|
||||
changePresence: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
adapter.adapt(createBaseTeamData(), 'my-team');
|
||||
|
|
|
|||
|
|
@ -87,9 +87,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
|
||||
expect(
|
||||
getSpawnAwareDotClass(member, 'spawning', 'starting', false, false, true, false, undefined)
|
||||
).toContain(
|
||||
'bg-amber-400'
|
||||
);
|
||||
).toContain('bg-amber-400');
|
||||
|
||||
expect(getSpawnCardClass('spawning', 'starting', false, false)).toContain(
|
||||
'member-waiting-shimmer'
|
||||
|
|
@ -112,16 +110,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
).toBe('offline');
|
||||
|
||||
expect(
|
||||
getSpawnAwareDotClass(
|
||||
member,
|
||||
'spawning',
|
||||
'starting',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined
|
||||
)
|
||||
getSpawnAwareDotClass(member, 'spawning', 'starting', false, false, false, false, undefined)
|
||||
).toContain('bg-red-400');
|
||||
|
||||
expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe('');
|
||||
|
|
@ -155,9 +144,9 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
)
|
||||
).toContain('bg-zinc-400');
|
||||
|
||||
expect(getSpawnCardClass('online', 'runtime_pending_bootstrap', true, true, true, false)).toContain(
|
||||
'member-waiting-shimmer'
|
||||
);
|
||||
expect(
|
||||
getSpawnCardClass('online', 'runtime_pending_bootstrap', true, true, true, false)
|
||||
).toContain('member-waiting-shimmer');
|
||||
});
|
||||
|
||||
it('shows confirmed teammates as ready instead of idle while launch is still settling', () => {
|
||||
|
|
@ -345,6 +334,59 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
).toContain('Connection timed out while contacting provider.');
|
||||
});
|
||||
|
||||
it('renders terminal API errors as errors instead of retrying status', () => {
|
||||
expect(
|
||||
getMemberRuntimeAdvisoryLabel(
|
||||
{
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-04-07T09:00:00.000Z',
|
||||
reasonCode: 'auth_error',
|
||||
statusCode: 500,
|
||||
message: 'API Error: 500 {"error":{"message":"auth_unavailable: no auth available"}}',
|
||||
},
|
||||
'anthropic',
|
||||
Date.parse('2026-04-07T09:00:00.000Z')
|
||||
)
|
||||
).toBe('Anthropic auth error');
|
||||
|
||||
expect(
|
||||
getMemberRuntimeAdvisoryTitle(
|
||||
{
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-04-07T09:00:00.000Z',
|
||||
reasonCode: 'auth_error',
|
||||
statusCode: 500,
|
||||
message: 'auth_unavailable: no auth available',
|
||||
},
|
||||
'anthropic'
|
||||
)
|
||||
).toContain('Anthropic authentication error');
|
||||
});
|
||||
|
||||
it('marks launch presentation as an error when the runtime has a terminal API error', () => {
|
||||
const presentation = buildMemberLaunchPresentation({
|
||||
member: { ...member, providerId: 'anthropic' },
|
||||
spawnStatus: 'online',
|
||||
spawnLaunchState: 'runtime_pending_bootstrap',
|
||||
spawnLivenessSource: 'process',
|
||||
spawnRuntimeAlive: true,
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-04-07T09:00:00.000Z',
|
||||
reasonCode: 'auth_error',
|
||||
statusCode: 500,
|
||||
message: 'auth_unavailable: no auth available',
|
||||
},
|
||||
isLaunchSettling: false,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
});
|
||||
|
||||
expect(presentation.presenceLabel).toBe('Anthropic auth error');
|
||||
expect(presentation.runtimeAdvisoryTone).toBe('error');
|
||||
expect(presentation.dotClass).toContain('bg-red-400');
|
||||
});
|
||||
|
||||
it('falls back to the existing generic retry wording when no structured reason is present', () => {
|
||||
expect(
|
||||
getMemberRuntimeAdvisoryLabel(
|
||||
|
|
|
|||
Loading…
Reference in a new issue