feat(team): refine launch and cli status flows

This commit is contained in:
777genius 2026-04-10 16:45:00 +03:00
parent 0dd4746700
commit 3e74b11b23
24 changed files with 1327 additions and 488 deletions

View file

@ -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,
});

View file

@ -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('/');

View file

@ -70,6 +70,21 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null
);
};
const InstallCompletedNotice = ({ version }: { version: string | null }): React.JSX.Element => (
<div
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{
borderColor: VARIANT_STYLES.success.border,
backgroundColor: VARIANT_STYLES.success.bg,
}}
>
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
<span className="text-sm" style={{ color: '#4ade80' }}>
Successfully installed Claude CLI v{version ?? 'latest'}
</span>
</div>
);
/** 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 ? (
<>
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={visibleCliProviders}
initialProviderId={
visibleCliProviders.some((provider) => 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 && (
<TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
providerTerminal.providerId
)}`}
command={cliStatus.binaryPath}
args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env}
onClose={() => {
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 (
<div
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
<span className="text-sm" style={{ color: '#4ade80' }}>
Successfully installed Claude CLI v{completedVersion ?? 'latest'}
</span>
</div>
);
if (installerState === 'completed' && (!cliStatus || !cliStatus.installed)) {
return <InstallCompletedNotice version={completedVersion} />;
}
// ── Error ──────────────────────────────────────────────────────────────
@ -1075,18 +1125,26 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
) {
if (cliStatus.authStatusChecking || isVerifyingAuth) {
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4"
style={{
borderColor: VARIANT_STYLES.info.border,
backgroundColor: VARIANT_STYLES.info.bg,
}}
>
<RefreshCw className="size-4 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Verifying authentication...
</p>
</div>
<>
<InstalledBanner
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => 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 (
<>
<InstalledBanner
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
multimodelEnabled={multimodelEnabled}
multimodelBusy={isSwitchingFlavor}
onInstall={handleInstall}
onRefresh={handleRefresh}
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
variant={variant}
/>
<div
className="mb-6 rounded-lg border-l-4 p-4"
style={{
@ -1235,6 +1310,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</div>
)}
</div>
{installedAuxiliaryUi}
{showLoginTerminal && cliStatus.binaryPath && (
<TerminalModal
title={`${cliStatus.displayName} Login`}
@ -1300,48 +1376,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderRefresh={handleProviderRefresh}
variant={variant}
/>
{cliStatus && (
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={visibleCliProviders}
initialProviderId={
visibleCliProviders.some((provider) => 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 && (
<TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
providerTerminal.providerId
)}`}
command={cliStatus.binaryPath}
args={providerTerminalCommand?.args}
env={providerTerminalCommand?.env}
onClose={() => {
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}
</>
);
};

View file

@ -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 && (
<div className="space-y-2">
{effectiveCliStatus.installed ? (
<div className="space-y-1">
@ -397,18 +402,20 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</button>
) : null}
{/* Extensions button — right-aligned */}
<button
type="button"
onClick={() => {}}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Puzzle className="size-3.5" />
Extensions
</button>
{effectiveCliStatus.authLoggedIn && (
<button
type="button"
onClick={openExtensionsTab}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Puzzle className="size-3.5" />
Extensions
</button>
)}
</div>
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
<p

View file

@ -50,8 +50,6 @@ export const ClaudeLogsPanel = ({
pendingNewCount,
isAlive,
filteredText,
totalGroupCount,
filteredGroupCount,
showMoreVisible,
searchQuery,
setSearchQuery,
@ -72,17 +70,9 @@ export const ClaudeLogsPanel = ({
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 pb-2">
<span className="text-[11px] text-[var(--color-text-muted)]">
{totalGroupCount > 0 ? (
{data.total > 0 ? (
<>
<span className="font-mono">{filteredGroupCount}</span> of{' '}
<span className="font-mono">{totalGroupCount}</span> groups
{data.total !== totalGroupCount ? (
<>
{' '}
<span aria-hidden="true">·</span> <span className="font-mono">{data.total}</span>{' '}
raw lines
</>
) : null}
<span className="font-mono">{data.total}</span> lines
</>
) : isAlive ? (
'No logs yet.'

View file

@ -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 = ({

View file

@ -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<TeamProviderId | null>(() => {
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
</div>
) : null}
{isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
<div className="pt-1">
<TeamProvisioningBanner teamName={effectiveTeamName} />
</div>
) : null}
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */}
{isLaunch ? (
@ -1824,7 +1827,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
<Button variant="outline" size="sm" onClick={closeDialog}>
{isLaunch ? 'Close' : 'Cancel'}
</Button>
<Button

View file

@ -43,6 +43,89 @@ const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
</svg>
);
const OpenCodeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg viewBox="0 0 24 24" className={className}>
<defs>
<linearGradient id="opencode-bg" x1="4" y1="3" x2="20" y2="21" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#303030" />
<stop offset="1" stopColor="#161616" />
</linearGradient>
<linearGradient
id="opencode-frame"
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f4f4f4" />
<stop offset="0.35" stopColor="#d9d9d9" />
<stop offset="0.68" stopColor="#a8a8a8" />
<stop offset="1" stopColor="#ececec" />
</linearGradient>
<linearGradient
id="opencode-frame-stroke"
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#ffffff" stopOpacity="0.9" />
<stop offset="1" stopColor="#5a5a5a" stopOpacity="0.9" />
</linearGradient>
<linearGradient
id="opencode-core"
x1="12"
y1="7"
x2="12"
y2="17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#121212" />
<stop offset="0.42" stopColor="#3e3b33" />
<stop offset="1" stopColor="#16140f" />
</linearGradient>
<linearGradient
id="opencode-core-stroke"
x1="9"
y1="7"
x2="15"
y2="17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f2f2f2" stopOpacity="0.95" />
<stop offset="1" stopColor="#6e6e6e" stopOpacity="0.85" />
</linearGradient>
<filter id="opencode-shadow" x="0" y="0" width="24" height="24" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="1.2" stdDeviation="1.2" floodColor="#000000" floodOpacity="0.42" />
</filter>
</defs>
<rect x="1.5" y="1.5" width="21" height="21" rx="5.2" fill="url(#opencode-bg)" />
<g filter="url(#opencode-shadow)">
<path
d="M7 4.25h10c.3 0 .55.25.55.55v14.4c0 .3-.25.55-.55.55H7c-.3 0-.55-.25-.55-.55V4.8c0-.3.25-.55.55-.55Z"
fill="url(#opencode-frame)"
stroke="url(#opencode-frame-stroke)"
strokeWidth="0.55"
/>
<path
d="M8.95 7.25h6.1c.22 0 .4.18.4.4v8.7c0 .22-.18.4-.4.4h-6.1a.4.4 0 0 1-.4-.4v-8.7c0-.22.18-.4.4-.4Z"
fill="url(#opencode-core)"
stroke="url(#opencode-core-stroke)"
strokeWidth="0.45"
/>
<path
d="M9.25 7.6h5.5"
stroke="#ffffff"
strokeOpacity="0.18"
strokeWidth="0.45"
strokeLinecap="round"
/>
</g>
</svg>
);
// --- Provider definitions ---
interface ProviderDef {
@ -57,8 +140,11 @@ const PROVIDERS: ProviderDef[] = [
{ id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
// { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
{ id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
{ id: 'opencode', label: 'OpenCode', icon: OpenCodeIcon, comingSoon: false },
];
const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development';
const ANTHROPIC_MODEL_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
@ -223,8 +309,17 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
return 'Uses the runtime default for the selected provider.';
}, [effectiveProviderId]);
const getProviderDisabledReason = (candidateProviderId: string): string | null => {
if (candidateProviderId === 'opencode') {
return OPENCODE_UI_DISABLED_REASON;
}
if (disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini') {
return GEMINI_UI_DISABLED_REASON;
}
return null;
};
const isProviderTemporarilyDisabled = (candidateProviderId: string): boolean =>
disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini';
getProviderDisabledReason(candidateProviderId) !== null;
const isProviderSelectable = (candidateProviderId: string): boolean =>
!isProviderTemporarilyDisabled(candidateProviderId) &&
(multimodelAvailable || candidateProviderId === 'anthropic');
@ -304,6 +399,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const isActive = provider.id === activeProvider.id;
const isFirst = index === 0;
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
const providerDisabledReason = getProviderDisabledReason(provider.id);
return (
<React.Fragment key={provider.id}>
@ -345,16 +441,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Coming Soon
</span>
)}
{!provider.comingSoon && isProviderTemporarilyDisabled(provider.id) && (
{!provider.comingSoon && providerDisabledReason && (
<span
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={GEMINI_UI_DISABLED_REASON}
title={providerDisabledReason}
>
{GEMINI_UI_DISABLED_BADGE_LABEL}
</span>
)}
{!provider.comingSoon &&
!isProviderTemporarilyDisabled(provider.id) &&
!providerDisabledReason &&
!isProviderSelectable(provider.id) && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Multimodel off

View file

@ -0,0 +1,99 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@shared/types';
interface PreviousLaunchParamsLike {
providerId?: TeamProviderId;
model?: string;
effort?: string;
}
interface LaunchDialogPrefillInput {
members: readonly ResolvedTeamMember[];
savedRequest: TeamCreateRequest | null;
previousLaunchParams?: PreviousLaunchParamsLike;
multimodelEnabled: boolean;
storedProviderId: TeamProviderId;
storedEffort: string;
getStoredModel: (providerId: TeamProviderId) => string;
}
interface LaunchDialogPrefillResult {
providerId: TeamProviderId;
model: string;
effort: string;
}
function normalizeModelCandidate(model: string | undefined): string {
const trimmed = model?.trim() ?? '';
if (!trimmed || trimmed === 'default' || trimmed === '__default__') {
return '';
}
return trimmed;
}
function canReuseModelForSelectedProvider(
sourceProviderId: TeamProviderId | undefined,
selectedProviderId: TeamProviderId
): boolean {
if (!sourceProviderId || sourceProviderId === 'gemini') {
return false;
}
return selectedProviderId === normalizeCreateLaunchProviderForUi(sourceProviderId, true);
}
export function resolveLaunchDialogPrefill({
members,
savedRequest,
previousLaunchParams,
multimodelEnabled,
storedProviderId,
storedEffort,
getStoredModel,
}: LaunchDialogPrefillInput): LaunchDialogPrefillResult {
const currentLead = members.find((member) => isLeadMember(member));
const currentLeadProviderId = normalizeOptionalTeamProviderId(currentLead?.providerId);
const savedRequestProviderId = normalizeOptionalTeamProviderId(savedRequest?.providerId);
const previousLaunchProviderId = normalizeOptionalTeamProviderId(
previousLaunchParams?.providerId
);
const providerId = normalizeCreateLaunchProviderForUi(
currentLeadProviderId ?? savedRequestProviderId ?? previousLaunchProviderId ?? storedProviderId,
multimodelEnabled
);
const modelCandidates = [
{
providerId: currentLeadProviderId,
model: normalizeModelCandidate(currentLead?.model),
},
{
providerId: savedRequestProviderId,
model: normalizeModelCandidate(savedRequest?.model),
},
{
providerId: previousLaunchProviderId,
model: normalizeModelCandidate(previousLaunchParams?.model),
},
];
const matchingModel = modelCandidates.find(
(candidate) =>
candidate.model && canReuseModelForSelectedProvider(candidate.providerId, providerId)
)?.model;
const effort =
currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort;
return {
providerId,
model: matchingModel
? normalizeTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId),
effort,
};
}

View file

@ -393,6 +393,7 @@ export {
clearMemberModelOverrides,
createMemberDraft,
createMemberDraftsFromInputs,
filterEditableMemberInputs,
getMemberDraftRole,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,

View file

@ -3,6 +3,7 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFree
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes';
@ -47,6 +48,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
export function createMemberDraftsFromInputs(
members: readonly {
name: string;
agentType?: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
@ -74,6 +76,12 @@ export function createMemberDraftsFromInputs(
});
}
export function filterEditableMemberInputs<T extends { name?: unknown; agentType?: unknown }>(
members: readonly T[]
): T[] {
return members.filter((member) => !isLeadMember(member));
}
export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
return {
...member,

View file

@ -154,6 +154,7 @@ export const MessageComposer = ({
}),
[aliveTeams, crossTeamTargets]
);
const hasCrossTeamOptions = sortedCrossTeamTargets.length > 0;
const isCrossTeam = selectedTeam !== null;
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
@ -499,272 +500,167 @@ export const MessageComposer = ({
)}
{/* Combined team + member selector */}
{sortedCrossTeamTargets.length > 0 ? (
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
: 'hover:bg-[var(--color-surface-raised)]'
)}
>
{isCrossTeam ? (
<>
<span
className={cn(
'inline-block size-2 shrink-0 rounded-full',
selectedTarget?.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: selectedTarget?.isOnline
? '#22c55e'
: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="text-[var(--color-text-secondary)]">This team</span>
</>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* Current team option */}
<button
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
!isCrossTeam && 'bg-[var(--color-surface-raised)]'
)}
onClick={() => {
setSelectedTeam(null);
setTeamSelectorOpen(false);
}}
>
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
: 'hover:bg-[var(--color-surface-raised)]'
)}
>
{isCrossTeam ? (
<>
<span
className={cn(
'inline-block size-2 shrink-0 rounded-full',
selectedTarget?.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: selectedTarget?.isOnline
? '#22c55e'
: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="truncate text-[var(--color-text)]">This team</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
current
</span>
{!isCrossTeam ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
{/* Separator */}
<div className="my-1 h-px bg-[var(--color-border)]" />
{/* Other teams */}
{sortedCrossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName;
return (
<button
key={target.teamName}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--cross-team-bg)]'
)}
onClick={() => {
setSelectedTeam(target.teamName);
setRecipient('team-lead');
setTeamSelectorOpen(false);
}}
>
<span
className={cn(
'inline-block size-2 shrink-0 rounded-full',
target.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: target.isOnline
? '#22c55e'
: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
title={target.isOnline ? 'Online' : 'Offline'}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
</div>
<span
className={cn(
'shrink-0 text-[10px]',
target.isOnline
? 'text-green-400'
: 'text-[var(--color-text-muted)]'
)}
>
{target.isOnline ? 'online' : 'offline'}
</span>
</div>
{target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
{target.description}
</div>
) : null}
</div>
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<span className="text-[var(--color-text-secondary)]">This team</span>
</>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* Current team option */}
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
!isCrossTeam && 'bg-[var(--color-surface-raised)]'
)}
disabled={isCrossTeam}
onClick={() => {
setSelectedTeam(null);
setTeamSelectorOpen(false);
}}
>
{recipient ? (
<MemberBadge
name={recipient}
color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
disableHoverCard
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : (
<span className="text-[var(--color-text-muted)]">Select...</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
) : null}
<span className="truncate text-[var(--color-text)]">This team</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
current
</span>
{!isCrossTeam ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* 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 (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div>
);
}
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 ? (
<>
<div className="my-1 h-px bg-[var(--color-border)]" />
{sortedCrossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName;
return (
<button
key={m.name}
key={target.teamName}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--color-surface-raised)]'
isSelected && 'bg-[var(--cross-team-bg)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
setSelectedTeam(target.teamName);
setRecipient('team-lead');
setTeamSelectorOpen(false);
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
disableHoverCard
<span
className={cn(
'inline-block size-2 shrink-0 rounded-full',
target.isOnline && 'animate-pulse'
)}
style={{
backgroundColor: target.isOnline
? '#22c55e'
: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
title={target.isOnline ? 'Online' : 'Offline'}
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
</div>
<span
className={cn(
'shrink-0 text-[10px]',
target.isOnline
? 'text-green-400'
: 'text-[var(--color-text-muted)]'
)}
>
{target.isOnline ? 'online' : 'offline'}
</span>
</div>
{target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
{target.description}
</div>
) : null}
</div>
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
</div>
) : (
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
})}
</>
) : null}
</div>
</PopoverContent>
</Popover>
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
className={cn(
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'
)}
disabled={isCrossTeam}
>
{recipient ? (
<MemberBadge
@ -864,7 +760,7 @@ export const MessageComposer = ({
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</div>

View file

@ -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,

View file

@ -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,

View file

@ -169,6 +169,7 @@ export const useStore = create<AppState>()((...args) => ({
export function initializeNotificationListeners(): () => void {
void cleanupCommentReadState();
const cleanupFns: (() => void)[] = [];
let cliStatusTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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);
});

View file

@ -141,37 +141,51 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliStatusError: null,
});
void (async () => {
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(

View file

@ -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;
}

View file

@ -8,9 +8,7 @@ const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>()
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
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';

View file

@ -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<string, unknown> | null;
cliStatusLoading: boolean;
cliProviderStatusLoading: Record<string, boolean>;
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<typeof vi.fn>;
fetchCliStatus: ReturnType<typeof vi.fn>;
fetchCliProviderStatus: ReturnType<typeof vi.fn>;
invalidateCliStatus: ReturnType<typeof vi.fn>;
installCli: ReturnType<typeof vi.fn>;
appConfig: {
general: {
multimodelEnabled: boolean;
};
runtime?: {
providerBackends?: Record<string, string>;
};
};
updateConfig: ReturnType<typeof vi.fn>;
openExtensionsTab: ReturnType<typeof vi.fn>;
};
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<object>('@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<string, unknown>>
): Record<string, unknown> {
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();
});
});
});

View file

@ -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');
});
});

View file

@ -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();
});
});
});

View file

@ -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<Record<TeamProviderId, string>>) {
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',
});
});
});

View file

@ -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<Pick<ResolvedTeamMember, 'name' | 'agentType'>>;
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',
});
});
});

View file

@ -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);