From bc2e1e43d82c49647666bf9f9fb44738d164912a Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 24 Apr 2026 00:40:11 +0300 Subject: [PATCH] feat: refine team provisioning and task log UX --- .../renderer/adapters/TeamGraphAdapter.ts | 3 +- .../ClaudeRecentProjectsSourceAdapter.ts | 11 +- .../CodexRecentProjectsSourceAdapter.ts | 3 +- .../renderer/hooks/useOpenRecentProject.ts | 18 +- .../utils/recentProjectOpenHistory.ts | 5 + .../buildMixedPersistedLaunchSnapshot.ts | 3 + .../CliProviderModelAvailabilityService.ts | 2 +- .../services/team/TeamLaunchStateEvaluator.ts | 16 + .../team/TeamMemberRuntimeAdvisoryService.ts | 81 +- .../services/team/TeamProvisioningService.ts | 728 +++-- src/renderer/App.tsx | 50 +- src/renderer/components/splash/splashScene.ts | 897 ++++++ .../team/dialogs/CreateTeamDialog.tsx | 59 +- .../team/dialogs/LaunchTeamDialog.tsx | 43 +- .../ProvisioningProviderStatusList.tsx | 80 +- .../team/dialogs/projectPathOptions.ts | 5 + .../dialogs/providerPrepareDiagnostics.ts | 109 +- .../components/team/members/MemberCard.tsx | 33 +- .../team/members/MemberDetailHeader.tsx | 16 +- .../team/members/MemberHoverCard.tsx | 25 +- .../team/review/ChangeReviewDialog.tsx | 103 +- .../team/review/ConfidenceBadge.tsx | 9 +- .../team/review/ContinuousScrollView.tsx | 2 +- .../team/review/FileSectionDiff.tsx | 13 +- .../team/review/FileSectionHeader.tsx | 43 +- .../team/review/FullDiffLoadingBanner.tsx | 6 +- .../components/team/review/ReviewToolbar.tsx | 8 +- .../team/review/ScopeWarningBanner.tsx | 53 +- .../team/taskLogs/TaskLogStreamSection.tsx | 14 +- src/renderer/index.html | 436 ++- src/renderer/utils/memberHelpers.ts | 81 +- src/shared/types/team.ts | 7 +- .../__tests__/ephemeralProjectPath.test.ts | 42 + src/shared/utils/ephemeralProjectPath.ts | 41 + .../CodexRecentProjectsSourceAdapter.test.ts | 50 +- .../utils/recentProjectOpenHistory.test.ts | 28 +- ...liProviderModelAvailabilityService.test.ts | 15 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 2641 ++++++++++++++++- .../TeamMemberRuntimeAdvisoryService.test.ts | 70 +- .../TeamProvisioningServicePrepare.test.ts | 550 +++- .../ProvisioningProviderStatusList.test.ts | 43 +- .../team/dialogs/projectPathOptions.test.ts | 23 + .../providerPrepareDiagnostics.test.ts | 151 +- .../taskLogs/TaskLogStreamSection.test.ts | 115 +- .../agent-graph/TeamGraphAdapter.test.ts | 23 + test/renderer/utils/memberHelpers.test.ts | 74 +- 46 files changed, 6111 insertions(+), 717 deletions(-) create mode 100644 src/renderer/components/splash/splashScene.ts create mode 100644 src/shared/utils/__tests__/ephemeralProjectPath.test.ts create mode 100644 src/shared/utils/ephemeralProjectPath.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 91d9969a..98e6718e 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -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, diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index 700b9122..d1d55a94 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -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', diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index f597fcfe..19c2ad0c 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -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 { const cwd = thread.cwd?.trim(); - if (!cwd) { + if (!cwd || isEphemeralProjectPath(cwd)) { return null; } diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index 8f755deb..a4eb21d7 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -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 => { 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) => ({ diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index 66c4cd3f..28d8b880 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -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); diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 9dc2a71b..651a0640 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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, diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index 62e7f46d..c6d60986 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -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, diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index a4e89493..136d4044 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -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 diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 86ad0465..70ce69e1 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -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(); + } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f71b16b0..30e43f3f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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>; } +interface AuthStatusCommandResponse { + loggedIn?: boolean; + authMethod?: string | null; + providers?: Record>; +} + interface RuntimeProviderLaunchFacts { defaultModel: string | null; modelIds: Set; @@ -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 { + 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 => { + 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 { 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> + ): 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 = {}; + 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 | 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 | 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(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(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 {}; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fc9771c4..9a564a02 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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. diff --git a/src/renderer/components/splash/splashScene.ts b/src/renderer/components/splash/splashScene.ts new file mode 100644 index 00000000..fa419d35 --- /dev/null +++ b/src/renderer/components/splash/splashScene.ts @@ -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('#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), + }; +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 701a58a2..b9307d2e 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(''); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 05536833..d2f64803 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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); diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 710a666e..a092ec38 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -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') { diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index 579f17e8..36540b90 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -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); diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index 26d65df4..18896cd3 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -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( diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 55db083a..552e59b2 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 ? ( <> - + {runtimeAdvisoryTone === 'error' ? ( + + ) : ( + + )} {runtimeAdvisoryLabel ?? 'awaiting reply'} @@ -308,10 +319,18 @@ export const MemberCard = ({ - + {runtimeAdvisoryLabel} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index e159488a..992c9934 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -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 = ({ <> {badgeLabel} diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 2ab37e50..8c85df45 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -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} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 68a00a78..2678550b 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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 ( +
+
+ +
+ {hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'} +
+

+ {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.'} +

+ {warnings.length > 0 && ( +
+ {warnings.map((warning, index) => ( +
{warning}
+ ))} +
+ )} +
+
+ ); +}; + 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 && ( - 0} - onUndo={handleUndoBulk} - /> - )} + {!changeSetLoading && !changeSetError && activeChangeSet && hasReviewFiles && ( + 0} + onUndo={handleUndoBulk} + /> + )} {/* Scope info / warnings + confidence badge */} - {mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && ( + {shouldShowScopeBanner && taskChangeSet && ( )} @@ -1319,7 +1362,7 @@ export const ChangeReviewDialog = ({ )} - {!changeSetLoading && !changeSetError && activeChangeSet && ( + {!changeSetLoading && !changeSetError && activeChangeSet && hasReviewFiles && ( <> {/* File tree */}
@@ -1425,10 +1468,8 @@ export const ChangeReviewDialog = ({ )} - {!changeSetLoading && !changeSetError && activeChangeSet?.files.length === 0 && ( -
- No file changes detected -
+ {!changeSetLoading && !changeSetError && activeChangeSet && !hasReviewFiles && ( + )}
diff --git a/src/renderer/components/team/review/ConfidenceBadge.tsx b/src/renderer/components/team/review/ConfidenceBadge.tsx index 6b902228..a4578061 100644 --- a/src/renderer/components/team/review/ConfidenceBadge.tsx +++ b/src/renderer/components/team/review/ConfidenceBadge.tsx @@ -3,6 +3,7 @@ import type { TaskScopeConfidence } from '@shared/types'; interface ConfidenceBadgeProps { confidence: TaskScopeConfidence; showTooltip?: boolean; + label?: string; } const TIER_COLORS: Record = { @@ -19,13 +20,17 @@ const TIER_LABELS: Record = { 4: 'Best effort', }; -export const ConfidenceBadge = ({ confidence, showTooltip = true }: ConfidenceBadgeProps) => { +export const ConfidenceBadge = ({ + confidence, + showTooltip = true, + label, +}: ConfidenceBadgeProps) => { return ( - {TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]} + {label ?? TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]} ); }; diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index d6fc2ec1..cdd71ba6 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -239,7 +239,7 @@ export const ContinuousScrollView = ({ if (files.length === 0) { return (
- No file changes detected + No reviewable file changes
); } diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index 73d151cd..912cef97 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -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 = ({
{canRenderSnippetPreview ? : null} diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index b6d61c6f..03bb7e0d 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -9,13 +9,13 @@ import type { FileChangeWithContent, HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; const CONTENT_SOURCE_LABELS: Record = { - '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)} - {isPreviewOnly ? ( + {isContentUnavailable ? ( +
+
Text content is unavailable
+
+ The ledger recorded metadata for this change, but full text content is not + available. This usually means binary, large, or hash-only content. +
+
+ Automatic accept/reject is disabled for this file to avoid unsafe disk writes. +
+
+ ) : isMissingOnDisk ? (
File is missing on disk
@@ -269,7 +288,9 @@ export const FileSectionHeader = ({ {isPreviewOnly && ( - 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.'} )} @@ -296,7 +317,9 @@ export const FileSectionHeader = ({ {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.'} )} diff --git a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx index 707ff4c9..9cbca533 100644 --- a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx +++ b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx @@ -57,7 +57,7 @@ export const FullDiffLoadingBanner = ({
- {snippetCount} snippet{snippetCount === 1 ? '' : 's'} ready + {snippetCount} preview{snippetCount === 1 ? '' : 's'} ready @@ -91,8 +91,8 @@ export const FullDiffLoadingBanner = ({

{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.'}

diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx index dde07a11..4424402c 100644 --- a/src/renderer/components/team/review/ReviewToolbar.tsx +++ b/src/renderer/components/team/review/ReviewToolbar.tsx @@ -157,7 +157,7 @@ export const ReviewToolbar = ({ )} - {/* Actions — hidden when all hunks are already decided */} + {/* Actions hidden when all hunks are already decided */} {stats.pending > 0 && ( <> @@ -206,10 +206,12 @@ export const ReviewToolbar = ({ ) : ( )} - {applying ? 'Applying...' : 'Apply All Changes'} + {applying ? 'Applying...' : 'Apply Rejections'} - Apply review decisions across all files + + Apply rejected hunks to disk; accepted changes are kept as-is + )}
diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx index 418f4ff5..79be7bbb 100644 --- a/src/renderer/components/team/review/ScopeWarningBanner.tsx +++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx @@ -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 = { @@ -31,7 +33,7 @@ const TIER_CONFIGS: Record = { 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 = { 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 = { 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 = { 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 = ({
- + {onDismiss && (