feat(team): refine launch and cli status flows
This commit is contained in:
parent
0dd4746700
commit
3e74b11b23
24 changed files with 1327 additions and 488 deletions
|
|
@ -1,84 +1,90 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs';
|
||||||
import path from 'node:path'
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
function runOrExit(cmd, args, options = {}) {
|
function runOrExit(cmd, args, options = {}) {
|
||||||
const result = spawnSync(cmd, args, {
|
const result = spawnSync(cmd, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
...options,
|
...options,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error(`Failed to run ${cmd}: ${result.error.message}`)
|
console.error(`Failed to run ${cmd}: ${result.error.message}`);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
process.exit(result.status ?? 1)
|
process.exit(result.status ?? 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPackageManagerCommand(repoRoot) {
|
function readPackageManagerCommand(repoRoot) {
|
||||||
const packageJsonPath = path.join(repoRoot, 'package.json')
|
const packageJsonPath = path.join(repoRoot, 'package.json');
|
||||||
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8')
|
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8');
|
||||||
const packageJson = JSON.parse(rawPackageJson)
|
const packageJson = JSON.parse(rawPackageJson);
|
||||||
const rawPackageManager = packageJson.packageManager
|
const rawPackageManager = packageJson.packageManager;
|
||||||
|
|
||||||
if (typeof rawPackageManager !== 'string' || rawPackageManager.trim().length === 0) {
|
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) {
|
if (!packageManagerName) {
|
||||||
return 'pnpm'
|
return 'pnpm';
|
||||||
}
|
}
|
||||||
|
|
||||||
return packageManagerName
|
return packageManagerName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const uiRepoRoot = path.resolve(scriptDir, '..')
|
const uiRepoRoot = path.resolve(scriptDir, '..');
|
||||||
// Keep the dev runtime target explicit. This workspace can contain multiple
|
// Keep the dev runtime target explicit. This workspace can contain multiple
|
||||||
// sibling repos with the same package name, so auto-discovery is ambiguous and
|
// 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.
|
// 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) {
|
if (!runtimeRepoRoot) {
|
||||||
console.error(
|
console.error(
|
||||||
'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' +
|
'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' +
|
||||||
'Point it at the runtime repo root you want the UI to use in dev.',
|
'Point it at the runtime repo root you want the UI to use in dev.'
|
||||||
)
|
);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json')
|
const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json');
|
||||||
if (!fs.existsSync(runtimePackageJsonPath)) {
|
if (!fs.existsSync(runtimePackageJsonPath)) {
|
||||||
console.error(
|
console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`);
|
||||||
`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`,
|
process.exit(1);
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot)
|
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot);
|
||||||
|
|
||||||
if (process.argv.includes('--print-runtime-path')) {
|
if (process.argv.includes('--print-runtime-path')) {
|
||||||
process.stdout.write(`${runtimeRepoRoot}\n`)
|
process.stdout.write(`${runtimeRepoRoot}\n`);
|
||||||
process.exit(0)
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect the runtime repo's own package manager. The UI repo uses pnpm, but
|
// 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
|
// the runtime may legitimately be a Bun workspace, and forcing pnpm there can
|
||||||
// fail before the build even starts.
|
// 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'], {
|
runOrExit('pnpm', ['run', 'dev:ui'], {
|
||||||
cwd: uiRepoRoot,
|
cwd: uiRepoRoot,
|
||||||
env: {
|
env: uiEnv,
|
||||||
...process.env,
|
});
|
||||||
CLAUDE_CLI_PATH: runtimeCliPath,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,10 @@ export class ClaudeBinaryResolver {
|
||||||
const enrichedPath = buildMergedCliPath(null);
|
const enrichedPath = buildMergedCliPath(null);
|
||||||
const flavor = getConfiguredCliFlavor();
|
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) {
|
if (overrideRaw) {
|
||||||
const looksLikePath =
|
const looksLikePath =
|
||||||
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/** Error display with multi-line support */
|
||||||
const ErrorDisplay = ({
|
const ErrorDisplay = ({
|
||||||
error,
|
error,
|
||||||
|
|
@ -831,6 +846,51 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
? getProviderTerminalCommand(activeTerminalProvider)
|
? getProviderTerminalCommand(activeTerminalProvider)
|
||||||
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
||||||
: null;
|
: 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 ────────────────────────────────────────
|
// ── Loading / fetch error state ────────────────────────────────────────
|
||||||
if (!cliStatus && installerState === 'idle') {
|
if (!cliStatus && installerState === 'idle') {
|
||||||
|
|
@ -998,18 +1058,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Completed ──────────────────────────────────────────────────────────
|
// ── Completed ──────────────────────────────────────────────────────────
|
||||||
if (installerState === 'completed') {
|
if (installerState === 'completed' && (!cliStatus || !cliStatus.installed)) {
|
||||||
return (
|
return <InstallCompletedNotice version={completedVersion} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Error ──────────────────────────────────────────────────────────────
|
// ── Error ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1075,18 +1125,26 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
) {
|
) {
|
||||||
if (cliStatus.authStatusChecking || isVerifyingAuth) {
|
if (cliStatus.authStatusChecking || isVerifyingAuth) {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4"
|
<InstalledBanner
|
||||||
style={{
|
cliStatus={cliStatus}
|
||||||
borderColor: VARIANT_STYLES.info.border,
|
cliStatusLoading={cliStatusLoading}
|
||||||
backgroundColor: VARIANT_STYLES.info.bg,
|
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||||
}}
|
cliStatusError={cliStatusError ?? null}
|
||||||
>
|
isBusy={isBusy}
|
||||||
<RefreshCw className="size-4 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
|
multimodelEnabled={multimodelEnabled}
|
||||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
multimodelBusy={isSwitchingFlavor}
|
||||||
Verifying authentication...
|
onInstall={handleInstall}
|
||||||
</p>
|
onRefresh={handleRefresh}
|
||||||
</div>
|
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 (
|
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
|
<div
|
||||||
className="mb-6 rounded-lg border-l-4 p-4"
|
className="mb-6 rounded-lg border-l-4 p-4"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1235,6 +1310,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{installedAuxiliaryUi}
|
||||||
{showLoginTerminal && cliStatus.binaryPath && (
|
{showLoginTerminal && cliStatus.binaryPath && (
|
||||||
<TerminalModal
|
<TerminalModal
|
||||||
title={`${cliStatus.displayName} Login`}
|
title={`${cliStatus.displayName} Login`}
|
||||||
|
|
@ -1300,48 +1376,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onProviderRefresh={handleProviderRefresh}
|
onProviderRefresh={handleProviderRefresh}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>
|
/>
|
||||||
{cliStatus && (
|
{installedAuxiliaryUi}
|
||||||
<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'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
||||||
export const CliStatusSection = (): React.JSX.Element | null => {
|
export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
const appConfig = useStore((s) => s.appConfig);
|
const appConfig = useStore((s) => s.appConfig);
|
||||||
|
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||||
const updateConfig = useStore((s) => s.updateConfig);
|
const updateConfig = useStore((s) => s.updateConfig);
|
||||||
const {
|
const {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
|
|
@ -189,6 +190,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||||
? createLoadingMultimodelCliStatus()
|
? createLoadingMultimodelCliStatus()
|
||||||
: cliStatus;
|
: cliStatus;
|
||||||
|
const showInstalledControls =
|
||||||
|
effectiveCliStatus !== null &&
|
||||||
|
(installerState === 'idle' ||
|
||||||
|
(installerState === 'completed' && effectiveCliStatus.installed === true));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
|
|
@ -325,7 +330,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status display */}
|
{/* Status display */}
|
||||||
{effectiveCliStatus && installerState === 'idle' && (
|
{showInstalledControls && effectiveCliStatus && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{effectiveCliStatus.installed ? (
|
{effectiveCliStatus.installed ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -397,18 +402,20 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Extensions button — right-aligned */}
|
{/* Extensions button — right-aligned */}
|
||||||
<button
|
{effectiveCliStatus.authLoggedIn && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {}}
|
type="button"
|
||||||
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"
|
onClick={openExtensionsTab}
|
||||||
style={{
|
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"
|
||||||
borderColor: 'var(--color-border)',
|
style={{
|
||||||
color: 'var(--color-text-secondary)',
|
borderColor: 'var(--color-border)',
|
||||||
}}
|
color: 'var(--color-text-secondary)',
|
||||||
>
|
}}
|
||||||
<Puzzle className="size-3.5" />
|
>
|
||||||
Extensions
|
<Puzzle className="size-3.5" />
|
||||||
</button>
|
Extensions
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@ export const ClaudeLogsPanel = ({
|
||||||
pendingNewCount,
|
pendingNewCount,
|
||||||
isAlive,
|
isAlive,
|
||||||
filteredText,
|
filteredText,
|
||||||
totalGroupCount,
|
|
||||||
filteredGroupCount,
|
|
||||||
showMoreVisible,
|
showMoreVisible,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
|
@ -72,17 +70,9 @@ export const ClaudeLogsPanel = ({
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-2 pb-2">
|
<div className="flex items-center justify-between gap-2 pb-2">
|
||||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
<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">{data.total}</span> lines
|
||||||
<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}
|
|
||||||
</>
|
</>
|
||||||
) : isAlive ? (
|
) : isAlive ? (
|
||||||
'No logs yet.'
|
'No logs yet.'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
|
filterEditableMemberInputs,
|
||||||
MembersEditorSection,
|
MembersEditorSection,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
|
@ -49,7 +50,7 @@ interface EditTeamDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||||
return createMemberDraftsFromInputs(members);
|
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditTeamDialog = ({
|
export const EditTeamDialog = ({
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
clearMemberModelOverrides,
|
clearMemberModelOverrides,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
|
filterEditableMemberInputs,
|
||||||
normalizeMemberDraftForProviderMode,
|
normalizeMemberDraftForProviderMode,
|
||||||
normalizeProviderForMode,
|
normalizeProviderForMode,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||||
import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner';
|
|
||||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
|
@ -42,10 +42,7 @@ import {
|
||||||
} from '@renderer/utils/geminiUiFreeze';
|
} from '@renderer/utils/geminiUiFreeze';
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
getCurrentProvisioningProgressForTeam,
|
|
||||||
isTeamProvisioningActive,
|
|
||||||
} from '@renderer/store/slices/teamSlice';
|
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,6 +72,7 @@ import {
|
||||||
type ProvisioningProviderCheck,
|
type ProvisioningProviderCheck,
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
import {
|
import {
|
||||||
computeEffectiveTeamModel,
|
computeEffectiveTeamModel,
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
|
|
@ -219,6 +217,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [selectedTeamName, setSelectedTeamName] = useState('');
|
const [selectedTeamName, setSelectedTeamName] = useState('');
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
const teamByName = useStore((s) => s.teamByName);
|
||||||
const openDashboard = useStore((s) => s.openDashboard);
|
const openDashboard = useStore((s) => s.openDashboard);
|
||||||
|
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||||
const teamOptions = useMemo(
|
const teamOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(teamByName)
|
Object.values(teamByName)
|
||||||
|
|
@ -485,6 +484,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setMaxBudgetUsd('');
|
setMaxBudgetUsd('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDialog = (): void => {
|
||||||
|
if (isLaunch) {
|
||||||
|
resetFormState();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
// Populate form in schedule edit mode
|
// Populate form in schedule edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !isSchedule) return;
|
if (!open || !isSchedule) return;
|
||||||
|
|
@ -551,18 +557,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
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 =
|
const nextMembersSource =
|
||||||
members.length > 0
|
members.length > 0
|
||||||
? members
|
? members
|
||||||
: savedRequest?.members && savedRequest.members.length > 0
|
: savedRequest?.members && savedRequest.members.length > 0
|
||||||
? savedRequest.members
|
? savedRequest.members
|
||||||
: [];
|
: [];
|
||||||
|
const editableMembersSource = filterEditableMemberInputs(nextMembersSource);
|
||||||
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
||||||
const savedProviderId =
|
const savedProviderId =
|
||||||
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
||||||
|
|
@ -570,35 +571,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
: savedRequest?.providerId === 'anthropic'
|
: savedRequest?.providerId === 'anthropic'
|
||||||
? 'anthropic'
|
? 'anthropic'
|
||||||
: null;
|
: null;
|
||||||
|
const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
|
||||||
|
const launchPrefill = resolveLaunchDialogPrefill({
|
||||||
|
members,
|
||||||
|
savedRequest,
|
||||||
|
previousLaunchParams,
|
||||||
|
multimodelEnabled,
|
||||||
|
storedProviderId,
|
||||||
|
storedEffort: storedEffort === null ? 'medium' : storedEffort,
|
||||||
|
getStoredModel: getStoredTeamModel,
|
||||||
|
});
|
||||||
setSavedLaunchProviderId(savedProviderId);
|
setSavedLaunchProviderId(savedProviderId);
|
||||||
|
|
||||||
setMembersDrafts(
|
setMembersDrafts(
|
||||||
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
|
createMemberDraftsFromInputs(editableMembersSource).map((member) =>
|
||||||
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
|
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setSyncModelsWithLead(
|
setSyncModelsWithLead(
|
||||||
!nextMembersSource.some((member) => member.providerId || member.model || member.effort)
|
!editableMembersSource.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)
|
|
||||||
);
|
);
|
||||||
|
setSelectedProviderIdRaw(launchPrefill.providerId);
|
||||||
|
setSelectedModelRaw(launchPrefill.model);
|
||||||
|
setSelectedEffortRaw(launchPrefill.effort);
|
||||||
setLimitContextRaw(
|
setLimitContextRaw(
|
||||||
savedRequest?.limitContext === true ||
|
savedRequest?.limitContext === true ||
|
||||||
localStorage.getItem('team:lastLimitContext') === 'true'
|
localStorage.getItem('team:lastLimitContext') === 'true'
|
||||||
|
|
@ -612,7 +607,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
|
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
|
||||||
|
|
||||||
const previousProviderId = useMemo<TeamProviderId | null>(() => {
|
const previousProviderId = useMemo<TeamProviderId | null>(() => {
|
||||||
if (!isLaunch) {
|
if (!isLaunch) {
|
||||||
|
|
@ -1099,15 +1094,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
const provisioningError = isLaunch ? props.provisioningError : null;
|
const provisioningError = isLaunch ? props.provisioningError : null;
|
||||||
const activeError = localError ?? provisioningError;
|
const activeError = localError ?? provisioningError;
|
||||||
const currentProvisioning = useStore((s) =>
|
|
||||||
isLaunch && effectiveTeamName
|
|
||||||
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
const launchInFlight = useStore((s) =>
|
const launchInFlight = useStore((s) =>
|
||||||
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
|
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
|
// Submit
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1161,6 +1169,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
extraCliArgs: customArgs.trim() || undefined,
|
extraCliArgs: customArgs.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath);
|
||||||
|
closeDialog();
|
||||||
} else {
|
} else {
|
||||||
// Schedule mode: create or update
|
// Schedule mode: create or update
|
||||||
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
|
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
|
||||||
|
|
@ -1197,7 +1207,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
await createSchedule(input);
|
await createSchedule(input);
|
||||||
}
|
}
|
||||||
onClose();
|
closeDialog();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
|
|
@ -1266,8 +1276,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
if (isLaunch) resetFormState();
|
closeDialog();
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1359,7 +1368,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
type="button"
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
onClose();
|
closeDialog();
|
||||||
openDashboard();
|
openDashboard();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1760,12 +1769,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
|
|
||||||
<div className="pt-1">
|
|
||||||
<TeamProvisioningBanner teamName={effectiveTeamName} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
|
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
|
||||||
{/* Launch-only: CLI warm-up status */}
|
{/* Launch-only: CLI warm-up status */}
|
||||||
{isLaunch ? (
|
{isLaunch ? (
|
||||||
|
|
@ -1824,7 +1827,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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'}
|
{isLaunch ? 'Close' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,89 @@ const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
</svg>
|
</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 ---
|
// --- Provider definitions ---
|
||||||
|
|
||||||
interface ProviderDef {
|
interface ProviderDef {
|
||||||
|
|
@ -57,8 +140,11 @@ const PROVIDERS: ProviderDef[] = [
|
||||||
{ id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
|
{ 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: '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 = [
|
const ANTHROPIC_MODEL_OPTIONS = [
|
||||||
{ value: '', label: 'Default' },
|
{ value: '', label: 'Default' },
|
||||||
{ value: 'opus', label: 'Opus 4.6' },
|
{ 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.';
|
return 'Uses the runtime default for the selected provider.';
|
||||||
}, [effectiveProviderId]);
|
}, [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 =>
|
const isProviderTemporarilyDisabled = (candidateProviderId: string): boolean =>
|
||||||
disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini';
|
getProviderDisabledReason(candidateProviderId) !== null;
|
||||||
const isProviderSelectable = (candidateProviderId: string): boolean =>
|
const isProviderSelectable = (candidateProviderId: string): boolean =>
|
||||||
!isProviderTemporarilyDisabled(candidateProviderId) &&
|
!isProviderTemporarilyDisabled(candidateProviderId) &&
|
||||||
(multimodelAvailable || candidateProviderId === 'anthropic');
|
(multimodelAvailable || candidateProviderId === 'anthropic');
|
||||||
|
|
@ -304,6 +399,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
const isActive = provider.id === activeProvider.id;
|
const isActive = provider.id === activeProvider.id;
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
||||||
|
const providerDisabledReason = getProviderDisabledReason(provider.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={provider.id}>
|
<React.Fragment key={provider.id}>
|
||||||
|
|
@ -345,16 +441,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
Coming Soon
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!provider.comingSoon && isProviderTemporarilyDisabled(provider.id) && (
|
{!provider.comingSoon && providerDisabledReason && (
|
||||||
<span
|
<span
|
||||||
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
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}
|
{GEMINI_UI_DISABLED_BADGE_LABEL}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!provider.comingSoon &&
|
{!provider.comingSoon &&
|
||||||
!isProviderTemporarilyDisabled(provider.id) &&
|
!providerDisabledReason &&
|
||||||
!isProviderSelectable(provider.id) && (
|
!isProviderSelectable(provider.id) && (
|
||||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
Multimodel off
|
Multimodel off
|
||||||
|
|
|
||||||
99
src/renderer/components/team/dialogs/launchDialogPrefill.ts
Normal file
99
src/renderer/components/team/dialogs/launchDialogPrefill.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -393,6 +393,7 @@ export {
|
||||||
clearMemberModelOverrides,
|
clearMemberModelOverrides,
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
|
filterEditableMemberInputs,
|
||||||
getMemberDraftRole,
|
getMemberDraftRole,
|
||||||
normalizeMemberDraftForProviderMode,
|
normalizeMemberDraftForProviderMode,
|
||||||
normalizeProviderForMode,
|
normalizeProviderForMode,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFree
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
|
|
@ -47,6 +48,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
export function createMemberDraftsFromInputs(
|
export function createMemberDraftsFromInputs(
|
||||||
members: readonly {
|
members: readonly {
|
||||||
name: string;
|
name: string;
|
||||||
|
agentType?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: TeamProviderId;
|
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 {
|
export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ export const MessageComposer = ({
|
||||||
}),
|
}),
|
||||||
[aliveTeams, crossTeamTargets]
|
[aliveTeams, crossTeamTargets]
|
||||||
);
|
);
|
||||||
|
const hasCrossTeamOptions = sortedCrossTeamTargets.length > 0;
|
||||||
|
|
||||||
const isCrossTeam = selectedTeam !== null;
|
const isCrossTeam = selectedTeam !== null;
|
||||||
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
|
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
|
||||||
|
|
@ -499,272 +500,167 @@ export const MessageComposer = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Combined team + member selector */}
|
{/* Combined team + member selector */}
|
||||||
{sortedCrossTeamTargets.length > 0 ? (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'inline-flex items-center rounded-full border text-xs transition-colors',
|
||||||
'inline-flex items-center rounded-full border text-xs transition-colors',
|
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
|
||||||
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
|
||||||
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
|
<PopoverTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={cn(
|
||||||
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',
|
||||||
'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
|
||||||
isCrossTeam
|
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
|
||||||
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
|
: 'hover:bg-[var(--color-surface-raised)]'
|
||||||
: 'hover:bg-[var(--color-surface-raised)]'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{isCrossTeam ? (
|
||||||
{isCrossTeam ? (
|
<>
|
||||||
<>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-block size-2 shrink-0 rounded-full',
|
||||||
'inline-block size-2 shrink-0 rounded-full',
|
selectedTarget?.isOnline && 'animate-pulse'
|
||||||
selectedTarget?.isOnline && 'animate-pulse'
|
)}
|
||||||
)}
|
style={{
|
||||||
style={{
|
backgroundColor: selectedTarget?.isOnline
|
||||||
backgroundColor: selectedTarget?.isOnline
|
? '#22c55e'
|
||||||
? '#22c55e'
|
: selectedTarget
|
||||||
: selectedTarget
|
? selectedTarget.color
|
||||||
? selectedTarget.color
|
? getTeamColorSet(selectedTarget.color).border
|
||||||
? getTeamColorSet(selectedTarget.color).border
|
: nameColorSet(selectedTarget.displayName).border
|
||||||
: nameColorSet(selectedTarget.displayName).border
|
: undefined,
|
||||||
: undefined,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
|
||||||
<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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentTeamColor ? (
|
{currentTeamColor ? (
|
||||||
<span
|
<span
|
||||||
className="inline-block size-2 shrink-0 rounded-full"
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: currentTeamColor }}
|
style={{ backgroundColor: currentTeamColor }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate text-[var(--color-text)]">This team</span>
|
<span className="text-[var(--color-text-secondary)]">This team</span>
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
</>
|
||||||
current
|
)}
|
||||||
</span>
|
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
{!isCrossTeam ? (
|
</button>
|
||||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
</PopoverTrigger>
|
||||||
) : null}
|
<PopoverContent align="end" className="w-56 p-1.5">
|
||||||
</button>
|
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
|
{/* Current team option */}
|
||||||
{/* 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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
|
'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
|
!isCrossTeam && 'bg-[var(--color-surface-raised)]'
|
||||||
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
|
|
||||||
: 'hover:bg-[var(--color-surface-raised)]'
|
|
||||||
)}
|
)}
|
||||||
disabled={isCrossTeam}
|
onClick={() => {
|
||||||
|
setSelectedTeam(null);
|
||||||
|
setTeamSelectorOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{recipient ? (
|
{currentTeamColor ? (
|
||||||
<MemberBadge
|
<span
|
||||||
name={recipient}
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
color={selectedResolvedColor}
|
style={{ backgroundColor: currentTeamColor }}
|
||||||
size="sm"
|
|
||||||
hideAvatar={recipient === 'user'}
|
|
||||||
disableHoverCard
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
<span className="truncate text-[var(--color-text)]">This team</span>
|
||||||
)}
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
current
|
||||||
|
</span>
|
||||||
|
{!isCrossTeam ? (
|
||||||
|
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
{hasCrossTeamOptions ? (
|
||||||
align="end"
|
<>
|
||||||
className="w-56 p-1.5"
|
<div className="my-1 h-px bg-[var(--color-border)]" />
|
||||||
onOpenAutoFocus={(e) => {
|
|
||||||
e.preventDefault();
|
{sortedCrossTeamTargets.map((target) => {
|
||||||
setRecipientSearch('');
|
const isSelected = selectedTeam === target.teamName;
|
||||||
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;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.name}
|
key={target.teamName}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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)]',
|
'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={() => {
|
onClick={() => {
|
||||||
setRecipient(m.name);
|
setSelectedTeam(target.teamName);
|
||||||
setRecipientOpen(false);
|
setRecipient('team-lead');
|
||||||
setRecipientSearch('');
|
setTeamSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MemberBadge
|
<span
|
||||||
name={m.name}
|
className={cn(
|
||||||
color={resolvedColor}
|
'inline-block size-2 shrink-0 rounded-full',
|
||||||
size="sm"
|
target.isOnline && 'animate-pulse'
|
||||||
hideAvatar={m.name === 'user'}
|
)}
|
||||||
disableHoverCard
|
style={{
|
||||||
|
backgroundColor: target.isOnline
|
||||||
|
? '#22c55e'
|
||||||
|
: target.color
|
||||||
|
? getTeamColorSet(target.color).border
|
||||||
|
: nameColorSet(target.displayName).border,
|
||||||
|
}}
|
||||||
|
title={target.isOnline ? 'Online' : 'Offline'}
|
||||||
/>
|
/>
|
||||||
{role ? (
|
<div className="min-w-0 flex-1">
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
<div className="flex items-center gap-1.5">
|
||||||
{role}
|
<div className="truncate text-[var(--color-text)]">
|
||||||
</span>
|
{target.displayName}
|
||||||
) : null}
|
</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 ? (
|
{isSelected ? (
|
||||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
})}
|
||||||
})()}
|
</>
|
||||||
</div>
|
) : null}
|
||||||
</PopoverContent>
|
</div>
|
||||||
</Popover>
|
</PopoverContent>
|
||||||
</div>
|
</Popover>
|
||||||
) : (
|
|
||||||
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
|
<Popover
|
||||||
|
open={isCrossTeam ? false : recipientOpen}
|
||||||
|
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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 ? (
|
{recipient ? (
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
|
|
@ -864,7 +760,7 @@ export const MessageComposer = ({
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
setTeamClaudeLogsSidebarUiState,
|
setTeamClaudeLogsSidebarUiState,
|
||||||
} from './sidebar/teamSidebarUiState';
|
} from './sidebar/teamSidebarUiState';
|
||||||
import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||||
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
|
|
||||||
|
|
||||||
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||||
import type { ClaudeLogsViewerState } from './CliLogsRichView';
|
import type { ClaudeLogsViewerState } from './CliLogsRichView';
|
||||||
|
|
@ -60,8 +59,6 @@ export interface ClaudeLogsController {
|
||||||
filteredText: string;
|
filteredText: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
badge: number | undefined;
|
badge: number | undefined;
|
||||||
totalGroupCount: number;
|
|
||||||
filteredGroupCount: number;
|
|
||||||
showMoreVisible: boolean;
|
showMoreVisible: boolean;
|
||||||
lastLogPreview: LastLogPreview | null;
|
lastLogPreview: LastLogPreview | null;
|
||||||
|
|
||||||
|
|
@ -611,15 +608,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
||||||
return filterStreamJsonText(data.lines, searchQuery, filter);
|
return filterStreamJsonText(data.lines, searchQuery, filter);
|
||||||
}, [data.lines, normalizedText, searchQuery, filter]);
|
}, [data.lines, normalizedText, searchQuery, filter]);
|
||||||
|
|
||||||
const totalGroupCount = useMemo(
|
const badge = data.total > 0 ? data.total : undefined;
|
||||||
() => parseStreamJsonToGroups(normalizedText).length,
|
|
||||||
[normalizedText]
|
|
||||||
);
|
|
||||||
const filteredGroupCount = useMemo(
|
|
||||||
() => parseStreamJsonToGroups(filteredText).length,
|
|
||||||
[filteredText]
|
|
||||||
);
|
|
||||||
const badge = totalGroupCount > 0 ? totalGroupCount : undefined;
|
|
||||||
|
|
||||||
// ── Container ref callback ────────────────────────────────────────────
|
// ── Container ref callback ────────────────────────────────────────────
|
||||||
const containerRefCallback = useCallback((el: HTMLDivElement | null) => {
|
const containerRefCallback = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
|
@ -661,8 +650,6 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
||||||
filteredText,
|
filteredText,
|
||||||
online,
|
online,
|
||||||
badge,
|
badge,
|
||||||
totalGroupCount,
|
|
||||||
filteredGroupCount,
|
|
||||||
showMoreVisible,
|
showMoreVisible,
|
||||||
lastLogPreview,
|
lastLogPreview,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ export function useCliInstaller(): {
|
||||||
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
||||||
const installCli = useStore((s) => s.installCli);
|
const installCli = useStore((s) => s.installCli);
|
||||||
|
|
||||||
const isBusy = installerState !== 'idle' && installerState !== 'error';
|
const isBusy =
|
||||||
|
installerState !== 'idle' && installerState !== 'error' && installerState !== 'completed';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export const useStore = create<AppState>()((...args) => ({
|
||||||
export function initializeNotificationListeners(): () => void {
|
export function initializeNotificationListeners(): () => void {
|
||||||
void cleanupCommentReadState();
|
void cleanupCommentReadState();
|
||||||
const cleanupFns: (() => void)[] = [];
|
const cleanupFns: (() => void)[] = [];
|
||||||
|
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
useStore.getState().subscribeProvisioningProgress();
|
useStore.getState().subscribeProvisioningProgress();
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
useStore.getState().unsubscribeProvisioningProgress();
|
useStore.getState().unsubscribeProvisioningProgress();
|
||||||
|
|
@ -186,6 +187,29 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const loadedConfig = useStore.getState().appConfig;
|
const loadedConfig = useStore.getState().appConfig;
|
||||||
syncRendererTelemetry(loadedConfig?.general?.telemetryEnabled ?? true);
|
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
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -196,32 +220,6 @@ export function initializeNotificationListeners(): () => void {
|
||||||
useStore.getState().fetchSchedules(),
|
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(() => {
|
cleanupFns.push(() => {
|
||||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -141,37 +141,51 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
cliStatusError: null,
|
cliStatusError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
void (async () => {
|
try {
|
||||||
try {
|
const metadata = await api.cliInstaller.getStatus();
|
||||||
const metadata = await api.cliInstaller.getStatus();
|
if (metadata.flavor !== 'free-code') {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (epoch !== cliStatusEpoch || !state.cliStatus) {
|
if (epoch !== cliStatusEpoch) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cliStatus: {
|
cliStatus: metadata,
|
||||||
...state.cliStatus,
|
cliStatusLoading: false,
|
||||||
flavor: metadata.flavor,
|
cliProviderStatusLoading: {},
|
||||||
displayName: metadata.displayName,
|
cliStatusError: state.cliStatusError,
|
||||||
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) {
|
return;
|
||||||
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
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 {
|
try {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled';
|
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_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 =
|
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.';
|
'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(
|
export function getTeamModelUiDisabledReason(
|
||||||
providerId: TeamProviderId | undefined,
|
providerId: TeamProviderId | undefined,
|
||||||
|
|
@ -12,6 +15,9 @@ export function getTeamModelUiDisabledReason(
|
||||||
if (providerId === 'codex' && model === GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL) {
|
if (providerId === 'codex' && model === GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL) {
|
||||||
return GPT_5_1_CODEX_MINI_UI_DISABLED_REASON;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>()
|
||||||
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
|
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'free-code'>();
|
||||||
|
|
||||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||||
const statMock = vi.fn<
|
const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>();
|
||||||
(filePath: PathLike) => Promise<{ isFile: () => boolean }>
|
|
||||||
>();
|
|
||||||
|
|
||||||
vi.mock('@main/utils/cliPathMerge', () => ({
|
vi.mock('@main/utils/cliPathMerge', () => ({
|
||||||
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
|
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
|
||||||
|
|
@ -59,6 +57,7 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
});
|
});
|
||||||
process.cwd = vi.fn(() => workspaceRoot);
|
process.cwd = vi.fn(() => workspaceRoot);
|
||||||
delete process.env.CLAUDE_CLI_PATH;
|
delete process.env.CLAUDE_CLI_PATH;
|
||||||
|
delete process.env.CLAUDE_FREE_CODE_CLI_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -89,6 +88,44 @@ describe('ClaudeBinaryResolver', () => {
|
||||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
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 () => {
|
it('falls back to claude-multimodel on PATH for free-code runtime', async () => {
|
||||||
const expectedBinary = '/usr/local/bin/claude-multimodel';
|
const expectedBinary = '/usr/local/bin/claude-multimodel';
|
||||||
|
|
||||||
|
|
|
||||||
297
test/renderer/components/cli/CliStatusVisibility.test.ts
Normal file
297
test/renderer/components/cli/CliStatusVisibility.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import {
|
import {
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||||
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
getTeamModelUiDisabledReason,
|
getTeamModelUiDisabledReason,
|
||||||
normalizeTeamModelForUi,
|
normalizeTeamModelForUi,
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
|
|
@ -22,12 +23,16 @@ describe('formatTeamModelSummary', () => {
|
||||||
expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe(
|
expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe(
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON
|
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('codex', 'gpt-5.4-mini')).toBeNull();
|
||||||
expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull();
|
expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes disabled Codex model selections back to default', () => {
|
it('normalizes disabled Codex model selections back to default', () => {
|
||||||
expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe('');
|
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');
|
expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ vi.mock('@renderer/store', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
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', () => {
|
describe('TeamModelSelector disabled Codex models', () => {
|
||||||
afterEach(() => {
|
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 () => {
|
it('normalizes a stale disabled selection back to default', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
@ -83,4 +114,80 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
await Promise.resolve();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,8 @@ vi.mock('@renderer/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
cliInstaller: {
|
cliInstaller: {
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
|
getProviderStatus: vi.fn(),
|
||||||
|
invalidateStatus: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
onProgress: vi.fn(() => 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', () => {
|
describe('installCli', () => {
|
||||||
it('sets state to checking and calls API', () => {
|
it('sets state to checking and calls API', () => {
|
||||||
vi.mocked(api.cliInstaller.install).mockResolvedValue(undefined);
|
vi.mocked(api.cliInstaller.install).mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue