diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index 4c6d57df..0076b9c1 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -1,84 +1,90 @@ #!/usr/bin/env node -import fs from 'node:fs' -import path from 'node:path' -import { spawnSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; function runOrExit(cmd, args, options = {}) { const result = spawnSync(cmd, args, { stdio: 'inherit', ...options, - }) + }); if (result.error) { - console.error(`Failed to run ${cmd}: ${result.error.message}`) - process.exit(1) + console.error(`Failed to run ${cmd}: ${result.error.message}`); + process.exit(1); } if (result.status !== 0) { - process.exit(result.status ?? 1) + process.exit(result.status ?? 1); } } function readPackageManagerCommand(repoRoot) { - const packageJsonPath = path.join(repoRoot, 'package.json') - const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8') - const packageJson = JSON.parse(rawPackageJson) - const rawPackageManager = packageJson.packageManager + const packageJsonPath = path.join(repoRoot, 'package.json'); + const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(rawPackageJson); + const rawPackageManager = packageJson.packageManager; if (typeof rawPackageManager !== 'string' || rawPackageManager.trim().length === 0) { - return 'pnpm' + return 'pnpm'; } - const [packageManagerName] = rawPackageManager.trim().split('@', 1) + const [packageManagerName] = rawPackageManager.trim().split('@', 1); if (!packageManagerName) { - return 'pnpm' + return 'pnpm'; } - return packageManagerName + return packageManagerName; } -const scriptDir = path.dirname(fileURLToPath(import.meta.url)) -const uiRepoRoot = path.resolve(scriptDir, '..') +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const uiRepoRoot = path.resolve(scriptDir, '..'); // Keep the dev runtime target explicit. This workspace can contain multiple // sibling repos with the same package name, so auto-discovery is ambiguous and // can silently point the UI at the wrong runtime after branch switches. -const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() +const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); if (!runtimeRepoRoot) { console.error( 'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' + - 'Point it at the runtime repo root you want the UI to use in dev.', - ) - process.exit(1) + 'Point it at the runtime repo root you want the UI to use in dev.' + ); + process.exit(1); } -const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json') +const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json'); if (!fs.existsSync(runtimePackageJsonPath)) { - console.error( - `CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`, - ) - process.exit(1) + console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`); + process.exit(1); } -const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot) +const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot); if (process.argv.includes('--print-runtime-path')) { - process.stdout.write(`${runtimeRepoRoot}\n`) - process.exit(0) + process.stdout.write(`${runtimeRepoRoot}\n`); + process.exit(0); } // Respect the runtime repo's own package manager. The UI repo uses pnpm, but // the runtime may legitimately be a Bun workspace, and forcing pnpm there can // fail before the build even starts. -runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot }) +runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot }); + +const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev'); +const uiEnv = { + ...process.env, + // Dev-only free-code runtime override. Keep it separate from the generic + // CLAUDE_CLI_PATH override so switching the app into Claude CLI mode still + // resolves the real official binary instead of this local cli-dev shim. + CLAUDE_FREE_CODE_CLI_PATH: runtimeCliPath, +}; +// If the parent shell exported a stale generic override, do not let it leak +// into the Electron main process. Claude mode must resolve the real binary. +delete uiEnv.CLAUDE_CLI_PATH; -const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev') runOrExit('pnpm', ['run', 'dev:ui'], { cwd: uiRepoRoot, - env: { - ...process.env, - CLAUDE_CLI_PATH: runtimeCliPath, - }, -}) + env: uiEnv, +}); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 25ac3874..dd99a69a 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -226,7 +226,10 @@ export class ClaudeBinaryResolver { const enrichedPath = buildMergedCliPath(null); const flavor = getConfiguredCliFlavor(); - const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim(); + const overrideRaw = + flavor === 'free-code' + ? (process.env.CLAUDE_FREE_CODE_CLI_PATH?.trim() ?? process.env.CLAUDE_CLI_PATH?.trim()) + : process.env.CLAUDE_CLI_PATH?.trim(); if (overrideRaw) { const looksLikePath = path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/'); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 1b4e1d23..5e23702a 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -70,6 +70,21 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null ); }; +const InstallCompletedNotice = ({ version }: { version: string | null }): React.JSX.Element => ( +
+ + + Successfully installed Claude CLI v{version ?? 'latest'} + +
+); + /** Error display with multi-line support */ const ErrorDisplay = ({ error, @@ -831,6 +846,51 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ? getProviderTerminalCommand(activeTerminalProvider) : getProviderTerminalLogoutCommand(activeTerminalProvider) : null; + const installedAuxiliaryUi = + cliStatus !== null ? ( + <> + provider.providerId === manageProviderId) + ? manageProviderId + : (visibleCliProviders[0]?.providerId ?? 'anthropic') + } + providerStatusLoading={cliProviderStatusLoading} + disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} + onSelectBackend={(providerId, backendId) => { + void handleProviderBackendChange(providerId, backendId); + }} + onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + /> + {providerTerminal && cliStatus.binaryPath && ( + { + setProviderTerminal(null); + recheckAuthState(); + }} + onExit={() => { + recheckAuthState(); + }} + autoCloseOnSuccessMs={3000} + successMessage={ + providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' + } + failureMessage={ + providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' + } + /> + )} + + ) : null; // ── Loading / fetch error state ──────────────────────────────────────── if (!cliStatus && installerState === 'idle') { @@ -998,18 +1058,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } // ── Completed ────────────────────────────────────────────────────────── - if (installerState === 'completed') { - return ( -
- - - Successfully installed Claude CLI v{completedVersion ?? 'latest'} - -
- ); + if (installerState === 'completed' && (!cliStatus || !cliStatus.installed)) { + return ; } // ── Error ────────────────────────────────────────────────────────────── @@ -1075,18 +1125,26 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ) { if (cliStatus.authStatusChecking || isVerifyingAuth) { return ( -
- -

- Verifying authentication... -

-
+ <> + void handleMultimodelToggle(enabled)} + onProviderLogin={handleProviderLogin} + onProviderLogout={handleProviderLogout} + onProviderManage={handleProviderManage} + onProviderRefresh={handleProviderRefresh} + variant={variant} + /> + {installedAuxiliaryUi} + ); } } @@ -1099,6 +1157,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ) { return ( <> + void handleMultimodelToggle(enabled)} + onProviderLogin={handleProviderLogin} + onProviderLogout={handleProviderLogout} + onProviderManage={handleProviderManage} + onProviderRefresh={handleProviderRefresh} + variant={variant} + />
{
)} + {installedAuxiliaryUi} {showLoginTerminal && cliStatus.binaryPath && ( { onProviderRefresh={handleProviderRefresh} variant={variant} /> - {cliStatus && ( - provider.providerId === manageProviderId) - ? manageProviderId - : (visibleCliProviders[0]?.providerId ?? 'anthropic') - } - providerStatusLoading={cliProviderStatusLoading} - disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} - onSelectBackend={(providerId, backendId) => { - void handleProviderBackendChange(providerId, backendId); - }} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} - /> - )} - {providerTerminal && cliStatus.binaryPath && ( - { - setProviderTerminal(null); - recheckAuthState(); - }} - onExit={() => { - recheckAuthState(); - }} - autoCloseOnSuccessMs={3000} - successMessage={ - providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' - } - failureMessage={ - providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' - } - /> - )} + {installedAuxiliaryUi} ); }; diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index e32e1c9d..f378e7ed 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -159,6 +159,7 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): { export const CliStatusSection = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); const appConfig = useStore((s) => s.appConfig); + const openExtensionsTab = useStore((s) => s.openExtensionsTab); const updateConfig = useStore((s) => s.updateConfig); const { cliStatus, @@ -189,6 +190,10 @@ export const CliStatusSection = (): React.JSX.Element | null => { !cliStatus && cliStatusLoading && multimodelEnabled ? createLoadingMultimodelCliStatus() : cliStatus; + const showInstalledControls = + effectiveCliStatus !== null && + (installerState === 'idle' || + (installerState === 'completed' && effectiveCliStatus.installed === true)); useEffect(() => { if (isElectron) { @@ -325,7 +330,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { )} {/* Status display */} - {effectiveCliStatus && installerState === 'idle' && ( + {showInstalledControls && effectiveCliStatus && (
{effectiveCliStatus.installed ? (
@@ -397,18 +402,20 @@ export const CliStatusSection = (): React.JSX.Element | null => { ) : null} {/* Extensions button — right-aligned */} - + {effectiveCliStatus.authLoggedIn && ( + + )}
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (

- {totalGroupCount > 0 ? ( + {data.total > 0 ? ( <> - {filteredGroupCount} of{' '} - {totalGroupCount} groups - {data.total !== totalGroupCount ? ( - <> - {' '} - {data.total}{' '} - raw lines - - ) : null} + {data.total} lines ) : isAlive ? ( 'No logs yet.' diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 8d396dc0..8cf506be 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -4,6 +4,7 @@ import { api } from '@renderer/api'; import { buildMembersFromDrafts, createMemberDraftsFromInputs, + filterEditableMemberInputs, MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; @@ -49,7 +50,7 @@ interface EditTeamDialogProps { } function membersToDrafts(members: ResolvedTeamMember[]) { - return createMemberDraftsFromInputs(members); + return createMemberDraftsFromInputs(filterEditableMemberInputs(members)); } export const EditTeamDialog = ({ diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 5e2cc034..c4dc3bc7 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -7,12 +7,12 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraftsFromInputs, + filterEditableMemberInputs, normalizeMemberDraftForProviderMode, normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; -import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -42,10 +42,7 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; -import { - getCurrentProvisioningProgressForTeam, - isTeamProvisioningActive, -} from '@renderer/store/slices/teamSlice'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -75,6 +72,7 @@ import { type ProvisioningProviderCheck, } from './ProvisioningProviderStatusList'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -219,6 +217,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [selectedTeamName, setSelectedTeamName] = useState(''); const teamByName = useStore((s) => s.teamByName); const openDashboard = useStore((s) => s.openDashboard); + const openTeamTab = useStore((s) => s.openTeamTab); const teamOptions = useMemo( () => Object.values(teamByName) @@ -485,6 +484,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setMaxBudgetUsd(''); }; + const closeDialog = (): void => { + if (isLaunch) { + resetFormState(); + } + onClose(); + }; + // Populate form in schedule edit mode useEffect(() => { if (!open || !isSchedule) return; @@ -551,18 +557,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } if (cancelled) return; - const rawNextProviderId = - savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' - ? savedRequest.providerId - : 'anthropic'; - const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled); - const providerFromSaved = Boolean(savedRequest?.providerId); const nextMembersSource = members.length > 0 ? members : savedRequest?.members && savedRequest.members.length > 0 ? savedRequest.members : []; + const editableMembersSource = filterEditableMemberInputs(nextMembersSource); const storedEffort = localStorage.getItem('team:lastSelectedEffort'); const savedProviderId = savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' @@ -570,35 +571,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : savedRequest?.providerId === 'anthropic' ? 'anthropic' : null; + const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); + const launchPrefill = resolveLaunchDialogPrefill({ + members, + savedRequest, + previousLaunchParams, + multimodelEnabled, + storedProviderId, + storedEffort: storedEffort === null ? 'medium' : storedEffort, + getStoredModel: getStoredTeamModel, + }); setSavedLaunchProviderId(savedProviderId); setMembersDrafts( - createMemberDraftsFromInputs(nextMembersSource).map((member) => + createMemberDraftsFromInputs(editableMembersSource).map((member) => normalizeMemberDraftForProviderMode(member, multimodelEnabled) ) ); setSyncModelsWithLead( - !nextMembersSource.some((member) => member.providerId || member.model || member.effort) - ); - setSelectedProviderIdRaw( - providerFromSaved - ? nextProviderId - : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) - ); - setSelectedModelRaw( - typeof savedRequest?.model === 'string' && - rawNextProviderId !== 'gemini' && - nextProviderId === normalizeProviderForMode(rawNextProviderId, true) - ? savedRequest.model - : getStoredTeamModel( - providerFromSaved - ? nextProviderId - : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) - ) - ); - setSelectedEffortRaw( - savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) + !editableMembersSource.some((member) => member.providerId || member.model || member.effort) ); + setSelectedProviderIdRaw(launchPrefill.providerId); + setSelectedModelRaw(launchPrefill.model); + setSelectedEffortRaw(launchPrefill.effort); setLimitContextRaw( savedRequest?.limitContext === true || localStorage.getItem('team:lastLimitContext') === 'true' @@ -612,7 +607,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]); + }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]); const previousProviderId = useMemo(() => { if (!isLaunch) { @@ -1099,15 +1094,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const provisioningError = isLaunch ? props.provisioningError : null; const activeError = localError ?? provisioningError; - const currentProvisioning = useStore((s) => - isLaunch && effectiveTeamName - ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) - : null - ); const launchInFlight = useStore((s) => isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); + useEffect(() => { + if (!open || !isLaunch || !effectiveTeamName || !launchInFlight) { + return; + } + + openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath); + closeDialog(); + }, [ + closeDialog, + defaultProjectPath, + effectiveCwd, + effectiveTeamName, + isLaunch, + launchInFlight, + open, + openTeamTab, + ]); + // --------------------------------------------------------------------------- // Submit // --------------------------------------------------------------------------- @@ -1161,6 +1169,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }); + openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath); + closeDialog(); } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; @@ -1197,7 +1207,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; await createSchedule(input); } - onClose(); + closeDialog(); } } catch (err) { const message = @@ -1266,8 +1276,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen open={open} onOpenChange={(nextOpen) => { if (!nextOpen) { - if (isLaunch) resetFormState(); - onClose(); + closeDialog(); } }} > @@ -1359,7 +1368,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen type="button" className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500" onClick={() => { - onClose(); + closeDialog(); openDashboard(); }} > @@ -1760,12 +1769,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

) : null} - {isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? ( -
- -
- ) : null} - {/* Launch-only: CLI warm-up status */} {isLaunch ? ( @@ -1824,7 +1827,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null}
- - - -
- {/* Current team option */} - - - {/* Separator */} -
- - {/* Other teams */} - {sortedCrossTeamTargets.map((target) => { - const isSelected = selectedTeam === target.teamName; - return ( - - ); - })} -
- - - - - + This team + + )} + + + + +
+ {/* Current team option */} - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} - {(() => { - const query = recipientSearch.toLowerCase().trim(); - const filtered = query - ? members.filter((m) => m.name.toLowerCase().includes(query)) - : members; - if (filtered.length === 0) { - return ( -
- No results -
- ); - } - const sorted = [...filtered].sort((a, b) => { - const aIsLead = isLeadMember(a) ? 1 : 0; - const bIsLead = isLeadMember(b) ? 1 : 0; - return bIsLead - aIsLead; - }); - return sorted.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; + + {hasCrossTeamOptions ? ( + <> +
+ + {sortedCrossTeamTargets.map((target) => { + const isSelected = selectedTeam === target.teamName; return ( ); - }); - })()} -
- - -
- ) : ( - + })} + + ) : null} +
+
+
+ +
diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 1e13fafa..a33025ab 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -19,7 +19,6 @@ import { setTeamClaudeLogsSidebarUiState, } from './sidebar/teamSidebarUiState'; import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; -import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from './CliLogsRichView'; @@ -60,8 +59,6 @@ export interface ClaudeLogsController { filteredText: string; online: boolean; badge: number | undefined; - totalGroupCount: number; - filteredGroupCount: number; showMoreVisible: boolean; lastLogPreview: LastLogPreview | null; @@ -611,15 +608,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController return filterStreamJsonText(data.lines, searchQuery, filter); }, [data.lines, normalizedText, searchQuery, filter]); - const totalGroupCount = useMemo( - () => parseStreamJsonToGroups(normalizedText).length, - [normalizedText] - ); - const filteredGroupCount = useMemo( - () => parseStreamJsonToGroups(filteredText).length, - [filteredText] - ); - const badge = totalGroupCount > 0 ? totalGroupCount : undefined; + const badge = data.total > 0 ? data.total : undefined; // ── Container ref callback ──────────────────────────────────────────── const containerRefCallback = useCallback((el: HTMLDivElement | null) => { @@ -661,8 +650,6 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController filteredText, online, badge, - totalGroupCount, - filteredGroupCount, showMoreVisible, lastLogPreview, searchQuery, diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 7fbc374e..7b3d2cd0 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -57,7 +57,8 @@ export function useCliInstaller(): { const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); const installCli = useStore((s) => s.installCli); - const isBusy = installerState !== 'idle' && installerState !== 'error'; + const isBusy = + installerState !== 'idle' && installerState !== 'error' && installerState !== 'completed'; return { cliStatus, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index bd219560..b589975a 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -169,6 +169,7 @@ export const useStore = create()((...args) => ({ export function initializeNotificationListeners(): () => void { void cleanupCommentReadState(); const cleanupFns: (() => void)[] = []; + let cliStatusTimer: ReturnType | null = null; useStore.getState().subscribeProvisioningProgress(); cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); @@ -186,6 +187,29 @@ export function initializeNotificationListeners(): () => void { const loadedConfig = useStore.getState().appConfig; syncRendererTelemetry(loadedConfig?.general?.telemetryEnabled ?? true); + if (api.cliInstaller) { + // Resolve the configured CLI flavor after config has loaded to avoid + // bootstrapping multimodel placeholder state in Claude-only mode. + type NavigatorWithUserAgentData = Navigator & { userAgentData?: { platform?: string } }; + const nav: NavigatorWithUserAgentData | null = + typeof navigator !== 'undefined' ? (navigator as NavigatorWithUserAgentData) : null; + // Prefer UA-CH when available; fall back to deprecated-but-still-supported navigator.platform. + // eslint-disable-next-line sonarjs/deprecation -- navigator.platform is deprecated but needed as fallback + const platform: string = + nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? ''; + const isWindows = platform.toLowerCase().includes('win'); + const delayMs = isWindows ? 3000 : 0; + cliStatusTimer = setTimeout(() => { + const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; + if (multimodelEnabled) { + void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + } else { + void useStore.getState().fetchCliStatus(); + } + cliStatusTimer = null; + }, delayMs); + } + // Remaining fetches have no data dependency on each other — run in parallel // to avoid blocking teams/notifications behind a slow repository scan. await Promise.all([ @@ -196,32 +220,6 @@ export function initializeNotificationListeners(): () => void { useStore.getState().fetchSchedules(), ]); })(); - - // CLI status check is non-critical for initial render (spawns child processes - // + iterates PATH directories with stat() calls — heavy on Windows). - // Defer on Windows; run immediately elsewhere so status is available quickly. - let cliStatusTimer: ReturnType | null = null; - if (api.cliInstaller) { - // On macOS/Linux, run immediately so the Dashboard can render status fast. - // On Windows, keep the existing defer to avoid competing with initial scans. - type NavigatorWithUserAgentData = Navigator & { userAgentData?: { platform?: string } }; - const nav: NavigatorWithUserAgentData | null = - typeof navigator !== 'undefined' ? (navigator as NavigatorWithUserAgentData) : null; - // Prefer UA-CH when available; fall back to deprecated-but-still-supported navigator.platform. - // eslint-disable-next-line sonarjs/deprecation -- navigator.platform is deprecated but needed as fallback - const platform: string = nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? ''; - const isWindows = platform.toLowerCase().includes('win'); - const delayMs = isWindows ? 3000 : 0; - cliStatusTimer = setTimeout(() => { - const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; - if (multimodelEnabled) { - void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); - } else { - void useStore.getState().fetchCliStatus(); - } - cliStatusTimer = null; - }, delayMs); - } cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 2622aaa2..11c32053 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -141,37 +141,51 @@ export const createCliInstallerSlice: StateCreator { - try { - const metadata = await api.cliInstaller.getStatus(); + try { + const metadata = await api.cliInstaller.getStatus(); + if (metadata.flavor !== 'free-code') { set((state) => { - if (epoch !== cliStatusEpoch || !state.cliStatus) { + if (epoch !== cliStatusEpoch) { return {}; } return { - cliStatus: { - ...state.cliStatus, - flavor: metadata.flavor, - displayName: metadata.displayName, - supportsSelfUpdate: metadata.supportsSelfUpdate, - showVersionDetails: metadata.showVersionDetails, - showBinaryPath: metadata.showBinaryPath, - installed: metadata.installed, - installedVersion: metadata.installedVersion, - binaryPath: metadata.binaryPath, - latestVersion: metadata.latestVersion, - updateAvailable: metadata.updateAvailable, - authStatusChecking: state.cliStatus.providers.some( - (provider) => provider.statusMessage === 'Checking...' - ), - }, + cliStatus: metadata, + cliStatusLoading: false, + cliProviderStatusLoading: {}, + cliStatusError: state.cliStatusError, }; }); - } catch (error) { - logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error); + return; } - })(); + + set((state) => { + if (epoch !== cliStatusEpoch || !state.cliStatus) { + return {}; + } + + return { + cliStatus: { + ...state.cliStatus, + flavor: metadata.flavor, + displayName: metadata.displayName, + supportsSelfUpdate: metadata.supportsSelfUpdate, + showVersionDetails: metadata.showVersionDetails, + showBinaryPath: metadata.showBinaryPath, + installed: metadata.installed, + installedVersion: metadata.installedVersion, + binaryPath: metadata.binaryPath, + latestVersion: metadata.latestVersion, + updateAvailable: metadata.updateAvailable, + authStatusChecking: state.cliStatus.providers.some( + (provider) => provider.statusMessage === 'Checking...' + ), + }, + }; + }); + } catch (error) { + logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error); + } try { await Promise.allSettled( diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index f322bb65..fff92a36 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -2,8 +2,11 @@ import type { TeamProviderId } from '@shared/types'; export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled'; export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; +export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark'; export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.'; +export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = + 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; export function getTeamModelUiDisabledReason( providerId: TeamProviderId | undefined, @@ -12,6 +15,9 @@ export function getTeamModelUiDisabledReason( if (providerId === 'codex' && model === GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL) { return GPT_5_1_CODEX_MINI_UI_DISABLED_REASON; } + if (providerId === 'codex' && model === GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL) { + return GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON; + } return null; } diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index a63a52ec..a0248081 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -8,9 +8,7 @@ const mockResolveInteractiveShellEnv = vi.fn<() => Promise>() const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>(); const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>(); -const statMock = vi.fn< - (filePath: PathLike) => Promise<{ isFile: () => boolean }> ->(); +const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>(); vi.mock('@main/utils/cliPathMerge', () => ({ buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath), @@ -59,6 +57,7 @@ describe('ClaudeBinaryResolver', () => { }); process.cwd = vi.fn(() => workspaceRoot); delete process.env.CLAUDE_CLI_PATH; + delete process.env.CLAUDE_FREE_CODE_CLI_PATH; }); afterEach(() => { @@ -89,6 +88,44 @@ describe('ClaudeBinaryResolver', () => { expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); }); + it('prefers the dedicated CLAUDE_FREE_CODE_CLI_PATH override in free-code mode', async () => { + const expectedBinary = '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev'; + process.env.CLAUDE_FREE_CODE_CLI_PATH = expectedBinary; + + accessMock.mockImplementation(async (filePath) => { + if (filePath === expectedBinary) { + return; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver'); + ClaudeBinaryResolver.clearCache(); + + await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); + expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + }); + + it('ignores CLAUDE_FREE_CODE_CLI_PATH when Claude flavor is selected', async () => { + process.env.CLAUDE_FREE_CODE_CLI_PATH = + '/Users/belief/dev/projects/claude/free-code-gemini-research/cli-dev'; + mockGetConfiguredCliFlavor.mockReturnValue('claude'); + const expectedBinary = '/usr/local/bin/claude'; + + accessMock.mockImplementation(async (filePath) => { + if (filePath === expectedBinary) { + return; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver'); + ClaudeBinaryResolver.clearCache(); + + await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); + expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); + }); + it('falls back to claude-multimodel on PATH for free-code runtime', async () => { const expectedBinary = '/usr/local/bin/claude-multimodel'; diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts new file mode 100644 index 00000000..3f214cab --- /dev/null +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -0,0 +1,297 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type StoreState = { + cliStatus: Record | null; + cliStatusLoading: boolean; + cliProviderStatusLoading: Record; + cliStatusError: string | null; + cliInstallerState: + | 'idle' + | 'checking' + | 'downloading' + | 'verifying' + | 'installing' + | 'completed' + | 'error'; + cliDownloadProgress: number; + cliDownloadTransferred: number; + cliDownloadTotal: number; + cliInstallerError: string | null; + cliInstallerDetail: string | null; + cliInstallerRawChunks: string[]; + cliCompletedVersion: string | null; + bootstrapCliStatus: ReturnType; + fetchCliStatus: ReturnType; + fetchCliProviderStatus: ReturnType; + invalidateCliStatus: ReturnType; + installCli: ReturnType; + appConfig: { + general: { + multimodelEnabled: boolean; + }; + runtime?: { + providerBackends?: Record; + }; + }; + updateConfig: ReturnType; + openExtensionsTab: ReturnType; +}; + +const storeState = {} as StoreState; + +vi.mock('@renderer/api', () => ({ + api: { + showInFolder: vi.fn(), + }, + isElectronMode: () => true, +})); + +vi.mock('@renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(async () => true), +})); + +vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({ + ProviderRuntimeSettingsDialog: () => null, +})); + +vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({ + getProviderRuntimeBackendSummary: () => null, +})); + +vi.mock('@renderer/components/settings/components', async () => { + const actual = await vi.importActual('@renderer/components/settings/components'); + return { + ...actual, + SettingsToggle: ({ + enabled, + disabled, + onChange, + }: { + enabled: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'multimodel-toggle', + disabled, + onClick: () => onChange(!enabled), + }, + enabled ? 'toggle-on' : 'toggle-off' + ), + }; +}); + +vi.mock('@renderer/components/terminal/TerminalLogPanel', () => ({ + TerminalLogPanel: () => React.createElement('div', null, 'terminal-log'), +})); + +vi.mock('@renderer/components/terminal/TerminalModal', () => ({ + TerminalModal: () => React.createElement('div', { 'data-testid': 'terminal-modal' }, 'terminal'), +})); + +vi.mock('@renderer/store', () => { + const useStore = (selector: (state: StoreState) => unknown) => selector(storeState); + Object.assign(useStore, { + setState: vi.fn(), + }); + return { useStore }; +}); + +import { CliStatusBanner } from '@renderer/components/dashboard/CliStatusBanner'; +import { CliStatusSection } from '@renderer/components/settings/sections/CliStatusSection'; + +function createInstalledCliStatus( + overrides?: Partial> +): Record { + return { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '2.1.100', + binaryPath: '/usr/local/bin/claude', + latestVersion: null, + updateAvailable: false, + authLoggedIn: false, + authStatusChecking: false, + authMethod: null, + providers: [], + ...overrides, + }; +} + +describe('CLI status visibility during completed install state', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + beforeEach(() => { + storeState.cliStatus = createInstalledCliStatus(); + storeState.cliStatusLoading = false; + storeState.cliProviderStatusLoading = {}; + storeState.cliStatusError = null; + storeState.cliInstallerState = 'completed'; + storeState.cliDownloadProgress = 0; + storeState.cliDownloadTransferred = 0; + storeState.cliDownloadTotal = 0; + storeState.cliInstallerError = null; + storeState.cliInstallerDetail = null; + storeState.cliInstallerRawChunks = []; + storeState.cliCompletedVersion = '2.1.100'; + storeState.bootstrapCliStatus = vi.fn(async () => undefined); + storeState.fetchCliStatus = vi.fn(async () => undefined); + storeState.fetchCliProviderStatus = vi.fn(async () => undefined); + storeState.invalidateCliStatus = vi.fn(async () => undefined); + storeState.installCli = vi.fn(); + storeState.appConfig = { + general: { + multimodelEnabled: true, + }, + runtime: { + providerBackends: {}, + }, + }; + storeState.updateConfig = vi.fn(async () => undefined); + storeState.openExtensionsTab = vi.fn(); + }); + + it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Multimodel'); + expect(host.textContent).toContain('Login'); + + const toggle = host.querySelector('[data-testid="multimodel-toggle"]'); + expect(toggle).not.toBeNull(); + expect(toggle?.hasAttribute('disabled')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps authenticated dashboard actions visible after install completion', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: true, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Extensions'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps auth verification inside the main installed banner instead of rendering a second banner', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: false, + authStatusChecking: true, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Checking authentication...'); + expect(host.textContent).not.toContain('Verifying authentication...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps installed controls visible in settings and wires the Extensions button correctly', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: true, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusSection)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Installed v2.1.100'); + expect(host.textContent).toContain('Multimodel'); + expect(host.textContent).toContain('Extensions'); + + const extensionsButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Extensions') + ); + expect(extensionsButton).not.toBeNull(); + + await act(async () => { + extensionsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(storeState.openExtensionsTab).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('hides the settings Extensions button when the runtime is not authenticated yet', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: false, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusSection)); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Extensions'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index 60aaa305..0f17ee7f 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, getTeamModelUiDisabledReason, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; @@ -22,12 +23,16 @@ describe('formatTeamModelSummary', () => { expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe( GPT_5_1_CODEX_MINI_UI_DISABLED_REASON ); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.3-codex-spark')).toBe( + GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON + ); expect(getTeamModelUiDisabledReason('codex', 'gpt-5.4-mini')).toBeNull(); expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull(); }); it('normalizes disabled Codex model selections back to default', () => { expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.3-codex-spark')).toBe(''); expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); }); }); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 185f40a5..57d44b98 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -22,7 +22,10 @@ vi.mock('@renderer/store', () => ({ })); import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; -import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON } from '@renderer/utils/teamModelAvailability'; +import { + GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, +} from '@renderer/utils/teamModelAvailability'; describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { @@ -57,6 +60,34 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('renders GPT-5.3 Codex Spark as disabled with an explanation tooltip', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('GPT-5.3 Codex Spark'); + expect(host.textContent).toContain('Disabled'); + expect(host.textContent).toContain(GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('normalizes a stale disabled selection back to default', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); @@ -83,4 +114,80 @@ describe('TeamModelSelector disabled Codex models', () => { await Promise.resolve(); }); }); + + it('normalizes a stale GPT-5.3 Codex Spark selection back to default', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.3-codex-spark', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows OpenCode as an in-development provider and keeps it non-selectable', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onProviderChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'anthropic', + onProviderChange, + value: '', + onValueChange: () => undefined, + disableGeminiOption: true, + }) + ); + await Promise.resolve(); + }); + + const trigger = host.querySelector('button'); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('OpenCode'); + expect(host.textContent?.match(/In development/g)?.length ?? 0).toBeGreaterThanOrEqual(2); + + const buttons = Array.from(host.querySelectorAll('button')); + const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode')); + expect(openCodeButton).not.toBeNull(); + expect(openCodeButton?.hasAttribute('disabled')).toBe(true); + + await act(async () => { + openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onProviderChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts new file mode 100644 index 00000000..2b1d4d58 --- /dev/null +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveLaunchDialogPrefill } from '@renderer/components/team/dialogs/launchDialogPrefill'; + +import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@shared/types'; + +function createStoredModelGetter(models: Partial>) { + return (providerId: TeamProviderId): string => models[providerId] ?? ''; +} + +describe('resolveLaunchDialogPrefill', () => { + it('prefills from the current lead runtime before localStorage defaults', () => { + const members = [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }, + { + name: 'alice', + agentType: 'reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }, + ] as ResolvedTeamMember[]; + + const result = resolveLaunchDialogPrefill({ + members, + savedRequest: null, + previousLaunchParams: { + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }); + }); + + it('prefers the current lead runtime over a stale saved request', () => { + const members = [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }, + ] as ResolvedTeamMember[]; + + const savedRequest = { + teamName: 'vector-room-2', + cwd: '/tmp/project', + providerId: 'anthropic', + model: 'haiku', + effort: 'low', + members: [], + } as TeamCreateRequest; + + const result = resolveLaunchDialogPrefill({ + members, + savedRequest, + previousLaunchParams: undefined, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }); + }); + + it('falls back to previous launch params when the current team snapshot is unavailable', () => { + const result = resolveLaunchDialogPrefill({ + members: [], + savedRequest: null, + previousLaunchParams: { + providerId: 'codex', + model: 'gpt-5.3-codex', + effort: 'high', + }, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'codex', + model: 'gpt-5.3-codex', + effort: 'high', + }); + }); + + it('does not carry a frozen Gemini model into an Anthropic fallback', () => { + const members = [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'gemini', + model: 'gemini-2.5-flash-lite', + effort: 'medium', + }, + ] as ResolvedTeamMember[]; + + const result = resolveLaunchDialogPrefill({ + members, + savedRequest: null, + previousLaunchParams: undefined, + multimodelEnabled: true, + storedProviderId: 'anthropic', + storedEffort: 'medium', + getStoredModel: createStoredModelGetter({ + anthropic: 'haiku', + codex: 'gpt-5.4', + }), + }); + + expect(result).toEqual({ + providerId: 'anthropic', + model: 'haiku', + effort: 'medium', + }); + }); +}); diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts new file mode 100644 index 00000000..44dbc2dd --- /dev/null +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + createMemberDraftsFromInputs, + filterEditableMemberInputs, +} from '@renderer/components/team/members/MembersEditorSection'; +import type { ResolvedTeamMember } from '@shared/types'; + +describe('members editor editable input filtering', () => { + it('filters the canonical team lead out of editable member inputs', () => { + const members = [ + { + name: 'team-lead', + agentType: 'team-lead', + }, + { + name: 'alice', + agentType: 'reviewer', + }, + { + name: 'bob', + agentType: 'developer', + }, + ] satisfies Array>; + + expect(filterEditableMemberInputs(members).map(member => member.name)).toEqual([ + 'alice', + 'bob', + ]); + }); + + it('keeps teammate runtime overrides intact after filtering out the lead', () => { + const members = [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + model: 'gpt-5.4', + }, + { + name: 'alice', + agentType: 'reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }, + ] satisfies Array< + Pick< + ResolvedTeamMember, + 'name' | 'agentType' | 'providerId' | 'model' | 'effort' + > + >; + + const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members)); + expect(drafts).toHaveLength(1); + expect(drafts[0]).toMatchObject({ + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index d09d296a..d09e395c 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -5,6 +5,8 @@ vi.mock('@renderer/api', () => ({ api: { cliInstaller: { getStatus: vi.fn(), + getProviderStatus: vi.fn(), + invalidateStatus: vi.fn(), install: vi.fn(), onProgress: vi.fn(() => vi.fn()), }, @@ -139,6 +141,34 @@ describe('cliInstallerSlice', () => { }); }); + describe('bootstrapCliStatus', () => { + it('falls back to the full Claude status if multimodel bootstrap resolves a claude flavor', async () => { + const mockStatus: CliInstallationStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '2.1.100', + binaryPath: '/Users/belief/.local/bin/claude', + latestVersion: '2.1.100', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth_token', + providers: [], + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + + await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + + expect(useStore.getState().cliStatus).toEqual(mockStatus); + expect(useStore.getState().cliStatusLoading).toBe(false); + expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); + }); + }); + describe('installCli', () => { it('sets state to checking and calls API', () => { vi.mocked(api.cliInstaller.install).mockResolvedValue(undefined);