feat: add teammate runtime compatibility notices and test coverage
This commit is contained in:
parent
19b6937446
commit
75f9e6bcec
22 changed files with 1312 additions and 54 deletions
|
|
@ -27,6 +27,11 @@ function shouldUseWindowsShell(cmd) {
|
|||
return false;
|
||||
}
|
||||
|
||||
const extension = path.extname(cmd).toLowerCase();
|
||||
if (extension === '.cmd' || extension === '.bat') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const commandName = path.basename(cmd).toLowerCase();
|
||||
return WINDOWS_SHELL_COMMANDS.has(commandName);
|
||||
}
|
||||
|
|
@ -502,7 +507,8 @@ async function resolveRuntimeCli() {
|
|||
|
||||
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
|
||||
|
||||
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
|
||||
const runtimeCliName = process.platform === 'win32' ? 'cli-dev.cmd' : 'cli-dev';
|
||||
const runtimeCliPath = path.join(runtimeRepoRoot, runtimeCliName);
|
||||
return {
|
||||
binaryPath: runtimeCliPath,
|
||||
versionText: readBinaryVersion(runtimeCliPath),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function expandWindowsExtensions(candidate: string): string[] {
|
|||
return [candidate];
|
||||
}
|
||||
|
||||
return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)];
|
||||
return [...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`), candidate];
|
||||
}
|
||||
|
||||
async function verifyBinary(candidate: string): Promise<string | null> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
// @vitest-environment node
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import type { PathLike } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||
}));
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalCodexCliPath = process.env.CODEX_CLI_PATH;
|
||||
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodexBinaryResolver', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
setPlatform('win32');
|
||||
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||
delete process.env.CODEX_CLI_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
process.env.PATH = originalPath;
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
process.env.CODEX_CLI_PATH = originalCodexCliPath;
|
||||
});
|
||||
|
||||
it('prefers the Windows command shim over the extensionless POSIX shim on PATH', async () => {
|
||||
const binDir = 'C:\\Program Files\\nodejs';
|
||||
const extensionless = path.join(binDir, 'codex');
|
||||
const cmdShim = path.join(binDir, 'codex.cmd');
|
||||
process.env.PATH = binDir;
|
||||
|
||||
accessMock.mockImplementation((filePath, mode) => {
|
||||
expect(mode).toBe(fsConstants.X_OK);
|
||||
if (filePath === extensionless || filePath === cmdShim) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||
});
|
||||
|
||||
it('expands an explicit extensionless override to the Windows command shim first', async () => {
|
||||
const extensionless = 'C:\\Program Files\\nodejs\\codex';
|
||||
const cmdShim = 'C:\\Program Files\\nodejs\\codex.cmd';
|
||||
process.env.CODEX_CLI_PATH = extensionless;
|
||||
|
||||
accessMock.mockImplementation((filePath) => {
|
||||
if (filePath === extensionless || filePath === cmdShim) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
spawn,
|
||||
type SpawnOptions,
|
||||
} from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
|
|
@ -79,14 +80,95 @@ function containsNonAscii(str: string): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* On Windows, creating a process whose *path* contains non-ASCII
|
||||
* characters will often fail with `spawn EINVAL`. Detect that case so
|
||||
* callers can automatically fall back to launching via a shell.
|
||||
* On Windows, batch launchers need cmd.exe, and creating a process whose
|
||||
* path contains non-ASCII characters will often fail with `spawn EINVAL`.
|
||||
* Detect both cases so callers can launch through a shell when needed.
|
||||
*/
|
||||
function needsShell(binaryPath: string): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
if (!binaryPath) return false;
|
||||
return containsNonAscii(binaryPath);
|
||||
const extension = path.extname(binaryPath).toLowerCase();
|
||||
return extension === '.cmd' || extension === '.bat' || containsNonAscii(binaryPath);
|
||||
}
|
||||
|
||||
interface DirectWindowsLauncher {
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
function isWindowsBatchLauncher(binaryPath: string): boolean {
|
||||
const extension = path.extname(binaryPath).toLowerCase();
|
||||
return extension === '.cmd' || extension === '.bat';
|
||||
}
|
||||
|
||||
function resolveCmdPathTemplate(template: string, launcherDir: string): string {
|
||||
const dirWithSep = launcherDir.endsWith(path.sep) ? launcherDir : `${launcherDir}${path.sep}`;
|
||||
return path.resolve(
|
||||
template
|
||||
.replace(/%SCRIPT_DIR%/gi, dirWithSep)
|
||||
.replace(/%~dp0/gi, dirWithSep)
|
||||
.replace(/%dp0%/gi, dirWithSep)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeneratedBunLauncher(
|
||||
content: string,
|
||||
launcherDir: string
|
||||
): DirectWindowsLauncher | null {
|
||||
if (!/\bbun\s+"%TARGET%"\s+%\*/i.test(content)) {
|
||||
return null;
|
||||
}
|
||||
const targetMatch = /set\s+"TARGET=([^"]+)"/i.exec(content);
|
||||
const targetTemplate = targetMatch?.[1];
|
||||
if (!targetTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveCmdPathTemplate(targetTemplate, launcherDir);
|
||||
if (!existsSync(target)) {
|
||||
return null;
|
||||
}
|
||||
return { command: 'bun', argsPrefix: [target] };
|
||||
}
|
||||
|
||||
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
|
||||
const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content);
|
||||
const scriptTemplate = scriptMatch?.[1];
|
||||
if (!scriptTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scriptPath = resolveCmdPathTemplate(scriptTemplate, launcherDir);
|
||||
if (!existsSync(scriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localNode = path.join(launcherDir, 'node.exe');
|
||||
return {
|
||||
command: existsSync(localNode) ? localNode : 'node',
|
||||
argsPrefix: [scriptPath],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Some Windows launchers are thin wrappers around a real JS entrypoint.
|
||||
* Running that entrypoint directly with an argv array avoids cmd.exe's
|
||||
* percent expansion, which cannot safely represent args like `%PATH%`.
|
||||
*/
|
||||
function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher | null {
|
||||
if (process.platform !== 'win32' || !isWindowsBatchLauncher(binaryPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(binaryPath, 'utf8');
|
||||
const launcherDir = path.dirname(binaryPath);
|
||||
return (
|
||||
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir)
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,17 +176,25 @@ function needsShell(binaryPath: string): boolean {
|
|||
*
|
||||
* cmd.exe rules:
|
||||
* - Double-quote args containing spaces or special characters
|
||||
* - Inside double quotes, escape literal `"` as `""`
|
||||
* - `%` is expanded as env var even inside double quotes — escape as `%%`
|
||||
* - Inside double quotes, escape literal `"` as `\"` for the target argv parser
|
||||
* - Double trailing backslashes so they do not escape the closing quote
|
||||
* - `%` is expanded as env var even inside double quotes. Keep it outside
|
||||
* quoted chunks and escape it as `^%`.
|
||||
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
|
||||
*
|
||||
* Our callers only pass controlled strings (binary paths, CLI flags),
|
||||
* NOT arbitrary user input.
|
||||
*/
|
||||
function quoteCmdChunk(chunk: string): string {
|
||||
const escaped = chunk
|
||||
.replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
|
||||
.replace(/(\\+)$/g, '$1$1');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
function quoteArg(arg: string): string {
|
||||
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
|
||||
const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
return arg.split('%').map(quoteCmdChunk).join('^%');
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
|
@ -172,6 +262,15 @@ export async function execCli(
|
|||
}
|
||||
const target = binaryPath;
|
||||
const opts = withCliEnv(options);
|
||||
const directLauncher = resolveDirectWindowsLauncher(target);
|
||||
if (directLauncher) {
|
||||
const result = await execFileAsync(
|
||||
directLauncher.command,
|
||||
[...directLauncher.argsPrefix, ...args],
|
||||
opts
|
||||
);
|
||||
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
||||
}
|
||||
|
||||
// attempt the normal execFile path first
|
||||
if (!needsShell(target)) {
|
||||
|
|
@ -209,6 +308,14 @@ export function spawnCli(
|
|||
options: SpawnOptions = {}
|
||||
): ReturnType<typeof spawn> {
|
||||
const opts = withCliEnv(options);
|
||||
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
|
||||
if (directLauncher) {
|
||||
const directOpts = { ...opts };
|
||||
delete directOpts.shell;
|
||||
return trackCliProcess(
|
||||
spawn(directLauncher.command, [...directLauncher.argsPrefix, ...args], directOpts)
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && needsShell(binaryPath)) {
|
||||
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatCodexCreditsValue,
|
||||
|
|
@ -561,6 +561,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const [runtimeSaving, setRuntimeSaving] = useState(false);
|
||||
const [pendingConnectionAction, setPendingConnectionAction] =
|
||||
useState<PendingConnectionAction>(null);
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const apiKeys = useStore((s) => s.apiKeys);
|
||||
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
|
||||
|
|
@ -799,6 +800,19 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
configuredAuthMode !== 'api_key' &&
|
||||
selectedProvider.statusMessage !== 'Checking...' &&
|
||||
(!selectedProvider?.authenticated || hasSubscriptionSession || configuredAuthMode === 'oauth');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showApiKeyForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
apiKeyInputRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [selectedProvider?.providerId, showApiKeyForm]);
|
||||
|
||||
let connectionStatusLabel: string | null = null;
|
||||
if (selectedProvider) {
|
||||
if (!hideConnectionMethodMeta && selectedProvider.authenticated) {
|
||||
|
|
@ -1733,6 +1747,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
{apiKeyConfig.name}
|
||||
</Label>
|
||||
<Input
|
||||
ref={apiKeyInputRef}
|
||||
id={`${selectedProvider.providerId}-api-key`}
|
||||
type="password"
|
||||
value={apiKeyValue}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ import {
|
|||
updateProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import {
|
||||
analyzeTeammateRuntimeCompatibility,
|
||||
useTmuxRuntimeReadiness,
|
||||
} from './teammateRuntimeCompatibility';
|
||||
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
|
||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
||||
|
|
@ -341,6 +346,7 @@ export const CreateTeamDialog = ({
|
|||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
const loadingCliStatus = useMemo(
|
||||
() =>
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
|
|
@ -543,6 +549,7 @@ export const CreateTeamDialog = ({
|
|||
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
|
||||
[members, syncModelsWithLead]
|
||||
);
|
||||
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
|
||||
|
||||
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
|
||||
if (!multimodelEnabled) {
|
||||
|
|
@ -582,6 +589,14 @@ export const CreateTeamDialog = ({
|
|||
),
|
||||
[effectiveCliStatus?.providers]
|
||||
);
|
||||
const selectedProviderBackendId = useMemo(
|
||||
() =>
|
||||
resolveUiOwnedProviderBackendId(
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
),
|
||||
[runtimeProviderStatusById, selectedProviderId]
|
||||
);
|
||||
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
|
||||
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
|
||||
const prepareModelResultsCacheRef = useRef(
|
||||
|
|
@ -1136,6 +1151,31 @@ export const CreateTeamDialog = ({
|
|||
),
|
||||
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||
);
|
||||
const teammateRuntimeCompatibility = useMemo(
|
||||
() =>
|
||||
analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: selectedProviderId,
|
||||
leadProviderBackendId: selectedProviderBackendId,
|
||||
members: effectiveMemberDrafts,
|
||||
soloTeam: soloTeam || !canCreate,
|
||||
extraCliArgs: launchTeam ? customArgs : undefined,
|
||||
tmuxStatus: tmuxRuntime.status,
|
||||
tmuxStatusLoading: tmuxRuntime.loading,
|
||||
tmuxStatusError: tmuxRuntime.error,
|
||||
}),
|
||||
[
|
||||
customArgs,
|
||||
effectiveMemberDrafts,
|
||||
launchTeam,
|
||||
canCreate,
|
||||
selectedProviderBackendId,
|
||||
selectedProviderId,
|
||||
soloTeam,
|
||||
tmuxRuntime.error,
|
||||
tmuxRuntime.loading,
|
||||
tmuxRuntime.status,
|
||||
]
|
||||
);
|
||||
const anthropicRuntimeSelection = useMemo(
|
||||
() =>
|
||||
selectedProviderId === 'anthropic'
|
||||
|
|
@ -1279,11 +1319,7 @@ export const CreateTeamDialog = ({
|
|||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
providerId: selectedProviderId,
|
||||
providerBackendId:
|
||||
resolveUiOwnedProviderBackendId(
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ?? undefined,
|
||||
providerBackendId: selectedProviderBackendId ?? undefined,
|
||||
model: effectiveModel,
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
fastMode:
|
||||
|
|
@ -1304,7 +1340,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveCwd,
|
||||
prompt,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById,
|
||||
selectedProviderBackendId,
|
||||
effectiveModel,
|
||||
selectedEffort,
|
||||
selectedFastMode,
|
||||
|
|
@ -1388,7 +1424,8 @@ export const CreateTeamDialog = ({
|
|||
isNameTakenByExistingTeam ||
|
||||
isNameProvisioning ||
|
||||
!requestValidation.valid ||
|
||||
!!modelValidationError;
|
||||
!!modelValidationError ||
|
||||
teammateRuntimeCompatibility.blocksSubmission;
|
||||
|
||||
const internalArgs = useMemo(() => {
|
||||
const args: string[] = [];
|
||||
|
|
@ -1528,6 +1565,10 @@ export const CreateTeamDialog = ({
|
|||
setLocalError(modelValidationError);
|
||||
return;
|
||||
}
|
||||
if (teammateRuntimeCompatibility.blocksSubmission) {
|
||||
setLocalError(teammateRuntimeCompatibility.message);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
setLocalError(null);
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -1661,6 +1702,14 @@ export const CreateTeamDialog = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label htmlFor="team-name">Team name</Label>
|
||||
|
|
@ -1735,6 +1784,7 @@ export const CreateTeamDialog = ({
|
|||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberWarningById={teammateRuntimeCompatibility.memberWarningById}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
headerTop={
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ import {
|
|||
shouldHideProvisioningProviderStatusList,
|
||||
updateProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import {
|
||||
analyzeTeammateRuntimeCompatibility,
|
||||
useTmuxRuntimeReadiness,
|
||||
} from './teammateRuntimeCompatibility';
|
||||
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
|
||||
import {
|
||||
computeEffectiveTeamModel,
|
||||
formatTeamModelSummary,
|
||||
|
|
@ -451,6 +456,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
|
||||
[membersDrafts, syncModelsWithLead]
|
||||
);
|
||||
const tmuxRuntime = useTmuxRuntimeReadiness(open && isLaunchMode);
|
||||
const selectedMemberProviders = useMemo<TeamProviderId[]>(
|
||||
() =>
|
||||
!multimodelEnabled
|
||||
|
|
@ -842,6 +848,46 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
) ?? '',
|
||||
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||
);
|
||||
const selectedProviderBackendId = useMemo(
|
||||
() =>
|
||||
resolveUiOwnedProviderBackendId(
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ??
|
||||
migrateProviderBackendId(
|
||||
selectedProviderId,
|
||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||
) ??
|
||||
undefined,
|
||||
[
|
||||
previousLaunchParams?.providerBackendId,
|
||||
runtimeProviderStatusById,
|
||||
savedLaunchProviderBackendId,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
const teammateRuntimeCompatibility = useMemo(
|
||||
() =>
|
||||
analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: selectedProviderId,
|
||||
leadProviderBackendId: selectedProviderBackendId,
|
||||
members: isLaunchMode ? effectiveMemberDrafts : [],
|
||||
extraCliArgs: isLaunchMode ? customArgs : undefined,
|
||||
tmuxStatus: tmuxRuntime.status,
|
||||
tmuxStatusLoading: tmuxRuntime.loading,
|
||||
tmuxStatusError: tmuxRuntime.error,
|
||||
}),
|
||||
[
|
||||
customArgs,
|
||||
effectiveMemberDrafts,
|
||||
isLaunchMode,
|
||||
selectedProviderBackendId,
|
||||
selectedProviderId,
|
||||
tmuxRuntime.error,
|
||||
tmuxRuntime.loading,
|
||||
tmuxRuntime.status,
|
||||
]
|
||||
);
|
||||
const anthropicRuntimeSelection = useMemo(
|
||||
() =>
|
||||
selectedProviderId === 'anthropic'
|
||||
|
|
@ -1218,6 +1264,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
return warnings;
|
||||
}, [effectiveMemberDrafts, runtimeChangeNoteByKey]);
|
||||
const combinedMemberRuntimeWarningById = useMemo(() => {
|
||||
const warnings: Record<string, string> = { ...memberRuntimeWarningById };
|
||||
for (const [memberId, warning] of Object.entries(
|
||||
teammateRuntimeCompatibility.memberWarningById
|
||||
)) {
|
||||
warnings[memberId] = warnings[memberId] ? `${warnings[memberId]} ${warning}` : warning;
|
||||
}
|
||||
return warnings;
|
||||
}, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch-only effects
|
||||
|
|
@ -1823,6 +1878,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setLocalError(modelValidationError);
|
||||
return;
|
||||
}
|
||||
if (isLaunchMode && teammateRuntimeCompatibility.blocksSubmission) {
|
||||
setLocalError(teammateRuntimeCompatibility.message);
|
||||
return;
|
||||
}
|
||||
if (isLaunchMode && !effectiveCwd) {
|
||||
setLocalError('Select working directory (cwd)');
|
||||
return;
|
||||
|
|
@ -1862,10 +1921,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ??
|
||||
migrateProviderBackendId(
|
||||
selectedProviderId,
|
||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||
) ??
|
||||
selectedProviderBackendId ??
|
||||
undefined,
|
||||
model: computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
|
|
@ -1902,10 +1958,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ??
|
||||
migrateProviderBackendId(
|
||||
selectedProviderId,
|
||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||
) ??
|
||||
selectedProviderBackendId ??
|
||||
undefined;
|
||||
const scheduleModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
|
|
@ -2000,7 +2053,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
validationErrors.length > 0 ||
|
||||
!!modelValidationError ||
|
||||
hasInvalidLaunchMemberNames ||
|
||||
hasDuplicateLaunchMemberNames
|
||||
hasDuplicateLaunchMemberNames ||
|
||||
teammateRuntimeCompatibility.blocksSubmission
|
||||
: isSubmitting || validationErrors.length > 0 || !!modelValidationError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2130,6 +2184,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{isLaunchMode ? (
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
closeDialog();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Schedule-only: Team selector (standalone mode)
|
||||
|
|
@ -2360,7 +2424,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
memberWarningById={combinedMemberRuntimeWarningById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
softDeleteMembers
|
||||
|
|
|
|||
|
|
@ -59,8 +59,9 @@ const PROVIDERS: ProviderDef[] = [
|
|||
];
|
||||
|
||||
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_REASON = 'OpenCode is not available for team lead.';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'not teamlead';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_REASON =
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'side lane';
|
||||
|
||||
export function getTeamModelLabel(model: string): string {
|
||||
return getCatalogTeamModelLabel(model) ?? model;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
import type { TeammateRuntimeCompatibility } from './teammateRuntimeCompatibility';
|
||||
|
||||
interface TeammateRuntimeCompatibilityNoticeProps {
|
||||
readonly analysis: TeammateRuntimeCompatibility;
|
||||
readonly onOpenDashboard?: () => void;
|
||||
}
|
||||
|
||||
export const TeammateRuntimeCompatibilityNotice = ({
|
||||
analysis,
|
||||
onOpenDashboard,
|
||||
}: TeammateRuntimeCompatibilityNoticeProps): React.JSX.Element | null => {
|
||||
if (!analysis.visible) {
|
||||
return null;
|
||||
}
|
||||
const Icon = analysis.checking ? Info : AlertTriangle;
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border p-3 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">{analysis.title}</p>
|
||||
<p className="opacity-80">{analysis.message}</p>
|
||||
{analysis.tmuxDetail ? (
|
||||
<p className="text-[11px] opacity-70">{analysis.tmuxDetail}</p>
|
||||
) : null}
|
||||
{analysis.details.length > 0 ? (
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-[11px] opacity-80">
|
||||
{analysis.details.map((detail) => (
|
||||
<li key={detail}>{detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{onOpenDashboard ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 h-7 px-2 text-[11px]"
|
||||
onClick={onOpenDashboard}
|
||||
>
|
||||
Open Dashboard
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TmuxStatus } from '@features/tmux-installer/contracts';
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
type TeammateRuntimeIssueReason =
|
||||
| 'mixed-provider'
|
||||
| 'codex-native-runtime'
|
||||
| 'explicit-tmux-mode'
|
||||
| 'opencode-led-mixed-unsupported';
|
||||
|
||||
interface RuntimeMemberInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: string | null;
|
||||
removedAt?: number | string | null;
|
||||
}
|
||||
|
||||
interface RuntimeIssue {
|
||||
reason: TeammateRuntimeIssueReason;
|
||||
memberId?: string;
|
||||
memberName?: string;
|
||||
memberProviderId?: TeamProviderId;
|
||||
}
|
||||
|
||||
export interface TeammateRuntimeCompatibility {
|
||||
visible: boolean;
|
||||
blocksSubmission: boolean;
|
||||
checking: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
details: string[];
|
||||
tmuxDetail: string | null;
|
||||
memberWarningById: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AnalyzeTeammateRuntimeCompatibilityInput {
|
||||
leadProviderId: TeamProviderId;
|
||||
leadProviderBackendId?: string | null;
|
||||
members: readonly RuntimeMemberInput[];
|
||||
soloTeam?: boolean;
|
||||
extraCliArgs?: string;
|
||||
tmuxStatus: TmuxStatus | null;
|
||||
tmuxStatusLoading: boolean;
|
||||
tmuxStatusError: string | null;
|
||||
}
|
||||
|
||||
export interface TmuxRuntimeReadiness {
|
||||
status: TmuxStatus | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<TeamProviderId, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
return PROVIDER_LABELS[providerId] ?? providerId;
|
||||
}
|
||||
|
||||
function getExplicitTeammateMode(
|
||||
rawExtraCliArgs: string | undefined
|
||||
): 'auto' | 'tmux' | 'in-process' | null {
|
||||
const tokens = parseCliArgs(rawExtraCliArgs);
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- parsing UI CLI flags, not comparing secrets
|
||||
if (token === '--teammate-mode') {
|
||||
const value = tokens[index + 1];
|
||||
return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null;
|
||||
}
|
||||
if (token.startsWith('--teammate-mode=')) {
|
||||
const value = token.slice('--teammate-mode='.length);
|
||||
return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTmuxRuntimeReady(status: TmuxStatus | null): boolean {
|
||||
return status?.effective.available === true && status.effective.runtimeReady === true;
|
||||
}
|
||||
|
||||
function getTmuxDetail(status: TmuxStatus | null, error: string | null): string | null {
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
return status?.effective.detail ?? status?.wsl?.statusDetail ?? status?.error ?? null;
|
||||
}
|
||||
|
||||
function summarizeIssueNames(
|
||||
issues: readonly RuntimeIssue[],
|
||||
reason: TeammateRuntimeIssueReason
|
||||
): string {
|
||||
const names = issues
|
||||
.filter((issue) => issue.reason === reason)
|
||||
.map((issue) => issue.memberName)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
if (names.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
return `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`;
|
||||
}
|
||||
|
||||
export function analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId,
|
||||
leadProviderBackendId,
|
||||
members,
|
||||
soloTeam = false,
|
||||
extraCliArgs,
|
||||
tmuxStatus,
|
||||
tmuxStatusLoading,
|
||||
tmuxStatusError,
|
||||
}: AnalyzeTeammateRuntimeCompatibilityInput): TeammateRuntimeCompatibility {
|
||||
const activeMembers = soloTeam
|
||||
? []
|
||||
: members.filter((member) => member.removedAt == null && member.name.trim().length > 0);
|
||||
const explicitTeammateMode = getExplicitTeammateMode(extraCliArgs);
|
||||
const leadBackendId = migrateProviderBackendId(leadProviderId, leadProviderBackendId);
|
||||
const issues: RuntimeIssue[] = [];
|
||||
|
||||
if (explicitTeammateMode === 'tmux' && activeMembers.length > 0) {
|
||||
issues.push({ reason: 'explicit-tmux-mode' });
|
||||
}
|
||||
|
||||
for (const member of activeMembers) {
|
||||
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId;
|
||||
const memberName = member.name.trim();
|
||||
if (memberProviderId !== leadProviderId) {
|
||||
if (leadProviderId !== 'opencode' && memberProviderId === 'opencode') {
|
||||
continue;
|
||||
}
|
||||
if (leadProviderId === 'opencode') {
|
||||
issues.push({
|
||||
reason: 'opencode-led-mixed-unsupported',
|
||||
memberId: member.id,
|
||||
memberName,
|
||||
memberProviderId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
issues.push({
|
||||
reason: 'mixed-provider',
|
||||
memberId: member.id,
|
||||
memberName,
|
||||
memberProviderId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberBackendId = migrateProviderBackendId(
|
||||
memberProviderId,
|
||||
member.providerBackendId ?? leadBackendId
|
||||
);
|
||||
if (memberProviderId === 'codex' && memberBackendId === 'codex-native') {
|
||||
issues.push({
|
||||
reason: 'codex-native-runtime',
|
||||
memberId: member.id,
|
||||
memberName,
|
||||
memberProviderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return {
|
||||
visible: false,
|
||||
blocksSubmission: false,
|
||||
checking: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: [],
|
||||
tmuxDetail: null,
|
||||
memberWarningById: {},
|
||||
};
|
||||
}
|
||||
|
||||
const tmuxReady = isTmuxRuntimeReady(tmuxStatus);
|
||||
const hasOpenCodeLeadMixedUnsupported = issues.some(
|
||||
(issue) => issue.reason === 'opencode-led-mixed-unsupported'
|
||||
);
|
||||
if (tmuxReady && !hasOpenCodeLeadMixedUnsupported) {
|
||||
return {
|
||||
visible: false,
|
||||
blocksSubmission: false,
|
||||
checking: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: [],
|
||||
tmuxDetail: null,
|
||||
memberWarningById: {},
|
||||
};
|
||||
}
|
||||
|
||||
const checking = !hasOpenCodeLeadMixedUnsupported && tmuxStatusLoading && !tmuxStatus;
|
||||
const blocksSubmission = true;
|
||||
const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider');
|
||||
const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime');
|
||||
const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode');
|
||||
const details: string[] = [];
|
||||
const memberWarningById: Record<string, string> = {};
|
||||
|
||||
if (hasMixedProviders) {
|
||||
const names = summarizeIssueNames(issues, 'mixed-provider');
|
||||
details.push(
|
||||
names
|
||||
? `Mixed providers: ${names} use a different provider than the ${getProviderLabel(leadProviderId)} lead.`
|
||||
: 'Mixed providers require teammate processes.'
|
||||
);
|
||||
}
|
||||
if (hasOpenCodeLeadMixedUnsupported) {
|
||||
const names = summarizeIssueNames(issues, 'opencode-led-mixed-unsupported');
|
||||
details.push(
|
||||
names
|
||||
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
|
||||
: 'OpenCode-led mixed teams are not supported in this phase.'
|
||||
);
|
||||
}
|
||||
if (hasCodexNative) {
|
||||
const names = summarizeIssueNames(issues, 'codex-native-runtime');
|
||||
details.push(
|
||||
names
|
||||
? `Codex native teammates: ${names} must run through separate Codex processes.`
|
||||
: 'Codex native teammates must run through separate Codex processes.'
|
||||
);
|
||||
}
|
||||
if (hasExplicitTmux) {
|
||||
details.push('Custom CLI args force --teammate-mode tmux.');
|
||||
}
|
||||
if (hasOpenCodeLeadMixedUnsupported) {
|
||||
details.push(
|
||||
'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.'
|
||||
);
|
||||
} else {
|
||||
details.push(
|
||||
hasCodexNative && !hasMixedProviders
|
||||
? 'Fix: install tmux/WSL tmux, use Solo team, or choose a same-provider runtime that supports in-process teammates.'
|
||||
: 'Fix: install tmux/WSL tmux, use Solo team, or keep every teammate on the same non-Codex-native provider as the lead.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (!issue.memberId || !issue.memberName) {
|
||||
continue;
|
||||
}
|
||||
if (issue.reason === 'mixed-provider') {
|
||||
memberWarningById[issue.memberId] =
|
||||
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
||||
`Without tmux, teammates must use the same provider as the ${getProviderLabel(leadProviderId)} lead.`;
|
||||
} else if (issue.reason === 'codex-native-runtime') {
|
||||
memberWarningById[issue.memberId] =
|
||||
`${issue.memberName} uses Codex native. Codex native teammates require a separate process, which currently needs tmux.`;
|
||||
} else if (issue.reason === 'opencode-led-mixed-unsupported') {
|
||||
memberWarningById[issue.memberId] =
|
||||
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
||||
'OpenCode cannot be the team lead when mixing providers in this phase.';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visible: blocksSubmission || checking,
|
||||
blocksSubmission,
|
||||
checking,
|
||||
title: checking
|
||||
? 'Checking tmux runtime for teammate support'
|
||||
: hasOpenCodeLeadMixedUnsupported
|
||||
? 'OpenCode cannot lead mixed-provider teams'
|
||||
: hasCodexNative && !hasMixedProviders
|
||||
? 'Codex teammates need tmux before they can run'
|
||||
: 'This team needs tmux before it can run',
|
||||
message: checking
|
||||
? 'Some teammates require separate processes. The app is checking whether tmux is available.'
|
||||
: hasOpenCodeLeadMixedUnsupported
|
||||
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.'
|
||||
: hasCodexNative && !hasMixedProviders
|
||||
? 'The Codex lead can run without tmux, but Codex native teammates cannot use the in-process teammate adapter. They must start as separate Codex processes, and this path currently needs tmux.'
|
||||
: 'tmux is not ready on this machine. Same-provider in-process teammates can run without tmux, but this team has teammates that require separate processes.',
|
||||
details,
|
||||
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
|
||||
memberWarningById,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTmuxRuntimeReadiness(enabled: boolean): TmuxRuntimeReadiness {
|
||||
const [status, setStatus] = useState<TmuxStatus | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (typeof api.tmux?.getStatus !== 'function') {
|
||||
throw new Error('tmux status API is not available. Restart the app.');
|
||||
}
|
||||
const nextStatus = await api.tmux.getStatus();
|
||||
setStatus(nextStatus);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux status');
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof api.tmux?.onProgress !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
return api.tmux.onProgress(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, [enabled, refresh]);
|
||||
|
||||
const effectiveLoading = enabled && (loading || (!status && !error));
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status,
|
||||
loading: effectiveLoading,
|
||||
error,
|
||||
refresh,
|
||||
}),
|
||||
[effectiveLoading, error, refresh, status]
|
||||
);
|
||||
}
|
||||
|
|
@ -20,8 +20,9 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
|||
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
||||
getTeamProviderLabel: (providerId: string) => providerId,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'not teamlead',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON: 'OpenCode is not available for team lead.',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@ import { useStore } from '../store';
|
|||
|
||||
const logger = createLogger('Hook:KeyboardShortcuts');
|
||||
|
||||
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const editableElement = target.closest(
|
||||
'input, textarea, select, [role="textbox"], [contenteditable]'
|
||||
);
|
||||
if (!editableElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentEditable = editableElement.getAttribute('contenteditable');
|
||||
return contentEditable == null || contentEditable.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(): void {
|
||||
const {
|
||||
openTabs,
|
||||
|
|
@ -77,6 +93,10 @@ export function useKeyboardShortcuts(): void {
|
|||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent): void {
|
||||
if (isEditableShortcutTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
// Layout-independent key (uses event.code for letters/symbols)
|
||||
|
|
|
|||
|
|
@ -790,6 +790,20 @@ body.theme-transitioning {
|
|||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
a[href],
|
||||
[role='button'],
|
||||
[role='combobox'],
|
||||
[role='menuitem'],
|
||||
[role='tab'],
|
||||
[role='textbox'],
|
||||
[contenteditable='true'] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Prevent drag region bleed-through on Windows: all fixed overlays must be no-drag */
|
||||
|
||||
.fixed {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' data: blob: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https:; media-src 'self' data: blob: http: https:; frame-src 'self' http: https:; object-src 'none'; base-uri 'self'; form-action 'none'; worker-src 'self' blob:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<link
|
||||
|
|
|
|||
|
|
@ -11,6 +11,23 @@
|
|||
|
||||
import { splitPath } from '@shared/utils/platformPath';
|
||||
|
||||
function isWindowsAbsolutePath(input: string): boolean {
|
||||
return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//');
|
||||
}
|
||||
|
||||
function comparePath(input: string, caseInsensitive: boolean): string {
|
||||
return caseInsensitive ? input.toLowerCase() : input;
|
||||
}
|
||||
|
||||
function pathSeparatorFor(root: string): '/' | '\\' {
|
||||
return root.includes('\\') && !root.includes('/') ? '\\' : '/';
|
||||
}
|
||||
|
||||
function joinDisplayPath(root: string, child: string): string {
|
||||
const sep = pathSeparatorFor(root);
|
||||
return root.replace(/[/\\]$/, '') + sep + child.replace(/[/\\]/g, sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a file path for display in compact UI elements.
|
||||
* Full path should still be available via tooltip (title attribute).
|
||||
|
|
@ -26,7 +43,13 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
|
|||
// 1. Make relative to project root
|
||||
if (projectRoot) {
|
||||
const root = projectRoot.replace(/[/\\]$/, '');
|
||||
if (p.startsWith(root + '/') || p.startsWith(root + '\\')) {
|
||||
const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root);
|
||||
const pathForCompare = comparePath(p, caseInsensitive);
|
||||
const rootForCompare = comparePath(root, caseInsensitive);
|
||||
if (
|
||||
pathForCompare.startsWith(rootForCompare + '/') ||
|
||||
pathForCompare.startsWith(rootForCompare + '\\')
|
||||
) {
|
||||
p = p.slice(root.length + 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +58,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
|
|||
p = p
|
||||
.replace(/^\/Users\/[^/]+/, '~')
|
||||
.replace(/^\/home\/[^/]+/, '~')
|
||||
.replace(/^[A-Z]:\\Users\\[^\\]+/, '~');
|
||||
.replace(/^[A-Za-z]:\\Users\\[^\\]+/i, '~');
|
||||
|
||||
// 3. If short enough, return as-is
|
||||
if (p.length <= maxLength) return p;
|
||||
|
|
@ -65,7 +88,7 @@ function inferHomeDir(projectRoot: string): string | null {
|
|||
const match =
|
||||
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
|
||||
/^(\/home\/[^/]+)/.exec(projectRoot) ??
|
||||
/^([A-Z]:\\Users\\[^\\]+)/.exec(projectRoot);
|
||||
/^([A-Za-z]:\\Users\\[^\\]+)/i.exec(projectRoot);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -107,23 +130,23 @@ function isWindowsUserPath(input: string): boolean {
|
|||
const drive = input.charCodeAt(0);
|
||||
const hasDriveLetter =
|
||||
((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
|
||||
return hasDriveLetter && input.startsWith('\\Users\\', 2);
|
||||
return hasDriveLetter && input.slice(2, 9).toLowerCase() === '\\users\\';
|
||||
}
|
||||
|
||||
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
|
||||
let p = filePath;
|
||||
|
||||
// Resolve ~ using home dir inferred from projectRoot
|
||||
if (p.startsWith('~/') && projectRoot) {
|
||||
if ((p.startsWith('~/') || p.startsWith('~\\')) && projectRoot) {
|
||||
const homeDir = inferHomeDir(projectRoot);
|
||||
if (homeDir) {
|
||||
p = homeDir + p.slice(1);
|
||||
p = joinDisplayPath(homeDir, p.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
// Make relative paths absolute by prepending projectRoot
|
||||
if (projectRoot && !p.startsWith('/') && !p.startsWith('~') && !/^[A-Z]:[/\\]/.test(p)) {
|
||||
p = projectRoot.replace(/[/\\]$/, '') + '/' + p;
|
||||
if (projectRoot && !p.startsWith('/') && !p.startsWith('~') && !isWindowsAbsolutePath(p)) {
|
||||
p = joinDisplayPath(projectRoot, p);
|
||||
}
|
||||
|
||||
return p;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
|
||||
// Mock the entire child_process module so that we can inspect how our helpers
|
||||
|
|
@ -31,6 +35,30 @@ function setPlatform(value: string) {
|
|||
// restore platform after tests
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function createGeneratedBunLauncher(): { dir: string; launcher: string; target: string } {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-launcher-'));
|
||||
const targetDir = path.join(dir, 'dist');
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
const target = path.join(targetDir, 'cli.js');
|
||||
writeFileSync(target, 'console.log("ok")', 'utf8');
|
||||
const launcher = path.join(dir, 'cli-dev.cmd');
|
||||
writeFileSync(
|
||||
launcher,
|
||||
[
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'set "SCRIPT_DIR=%~dp0"',
|
||||
'set "TARGET=%SCRIPT_DIR%dist\\cli.js"',
|
||||
':run_target',
|
||||
'bun "%TARGET%" %*',
|
||||
'exit /b %ERRORLEVEL%',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
'utf8'
|
||||
);
|
||||
return { dir, launcher, target };
|
||||
}
|
||||
|
||||
describe('cli child process helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
|
@ -79,6 +107,37 @@ describe('cli child process helpers', () => {
|
|||
expect(result).toBe(fake);
|
||||
});
|
||||
|
||||
it('uses shell directly for Windows cmd launchers', () => {
|
||||
setPlatform('win32');
|
||||
const fake = {} as any;
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(fake);
|
||||
|
||||
const result = spawnCli('C:\\runtime\\cli-dev.cmd', ['--version']);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0][0]).toContain('cli-dev.cmd');
|
||||
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
|
||||
expect(result).toBe(fake);
|
||||
});
|
||||
|
||||
it('runs generated Bun cmd launchers directly to preserve percent args', () => {
|
||||
setPlatform('win32');
|
||||
const fake = {} as any;
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(fake);
|
||||
const { dir, launcher, target } = createGeneratedBunLauncher();
|
||||
try {
|
||||
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0][0]).toBe('bun');
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
|
||||
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
|
||||
expect(result).toBe(fake);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses shell directly when path contains non-ASCII on windows', () => {
|
||||
setPlatform('win32');
|
||||
const fake = {} as any;
|
||||
|
|
@ -170,6 +229,44 @@ describe('cli child process helpers', () => {
|
|||
expect(result.stdout).toBe('ok');
|
||||
});
|
||||
|
||||
it('skips straight to shell for Windows cmd launchers', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, '0.0.8', '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const result = await execCli('C:\\runtime\\cli-dev.cmd', ['--version']);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('0.0.8');
|
||||
});
|
||||
|
||||
it('executes generated Bun cmd launchers directly to preserve percent args', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return {} as any;
|
||||
}
|
||||
);
|
||||
const { dir, launcher, target } = createGeneratedBunLauncher();
|
||||
try {
|
||||
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(execFileMock.mock.calls[0][0]).toBe('bun');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('ok');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips straight to shell when path contains non-ASCII on windows', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
|
|
@ -198,12 +295,35 @@ describe('cli child process helpers', () => {
|
|||
|
||||
await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']);
|
||||
const shellCmd = execMock.mock.calls[0][0] as string;
|
||||
// %PATH% must become %%PATH%% to prevent cmd.exe env var expansion
|
||||
expect(shellCmd).toContain('%%PATH%%');
|
||||
// double quote inside arg must become "" (cmd.exe escaping)
|
||||
expect(shellCmd).toContain('""arg');
|
||||
// should NOT contain \" (Unix-style escaping)
|
||||
expect(shellCmd).not.toContain('\\"');
|
||||
// Keep % outside quoted chunks so cmd.exe does not expand it as an env var.
|
||||
expect(shellCmd).toContain('^%"PATH"^%');
|
||||
expect(shellCmd).not.toContain('%PATH%');
|
||||
expect(shellCmd).not.toContain('%%PATH%%');
|
||||
// Quotes inside JSON-like args must survive cmd.exe and the target argv parser.
|
||||
expect(shellCmd).toContain('\\"arg');
|
||||
expect(shellCmd).not.toContain('""arg');
|
||||
});
|
||||
|
||||
it('keeps inline settings JSON as one argv-safe argument for Windows cmd launchers', async () => {
|
||||
setPlatform('win32');
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
await execCli('C:\\runtime\\cli-dev.cmd', [
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
'runtime',
|
||||
'status',
|
||||
'--json',
|
||||
'--provider',
|
||||
'codex',
|
||||
]);
|
||||
const shellCmd = execMock.mock.calls[0][0] as string;
|
||||
expect(shellCmd).toContain('"{\\"codex\\":{\\"forced_login_method\\":\\"chatgpt\\"}}"');
|
||||
expect(shellCmd).not.toContain('{""codex"":');
|
||||
});
|
||||
|
||||
it('shell: true cannot be overridden by caller options', () => {
|
||||
|
|
|
|||
|
|
@ -119,8 +119,9 @@ vi.mock('@renderer/components/ui/dialog', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/input', () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
||||
React.createElement('input', props),
|
||||
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
(props, ref) => React.createElement('input', { ...props, ref })
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
|
|
@ -438,6 +439,16 @@ function findButtonByText(container: HTMLElement, text: string): HTMLButtonEleme
|
|||
return button;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
if (!setter) {
|
||||
throw new Error('HTMLInputElement value setter not found');
|
||||
}
|
||||
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('ProviderRuntimeSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
|
@ -563,6 +574,65 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
|
||||
});
|
||||
|
||||
it('accepts and saves a typed Anthropic API key from provider settings', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderRuntimeSettingsDialog, {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providers: [
|
||||
createAnthropicProvider({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
}),
|
||||
],
|
||||
initialProviderId: 'anthropic',
|
||||
onSelectBackend: vi.fn(),
|
||||
onRefreshProvider,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Set API key').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const input = host.querySelector('#anthropic-api-key') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input!, 'sk-ant-test-key');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(input!.value).toBe('sk-ant-test-key');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Save key').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.saveApiKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
name: 'Anthropic API Key',
|
||||
scope: 'user',
|
||||
value: 'sk-ant-test-key',
|
||||
})
|
||||
);
|
||||
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
|
||||
});
|
||||
|
||||
it('shows native-only Codex connection copy and API-key management without login actions', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
|
|||
|
|
@ -813,10 +813,11 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
value: '',
|
||||
onValueChange: () => undefined,
|
||||
providerDisabledReasonById: {
|
||||
opencode: 'OpenCode is not available for team lead.',
|
||||
opencode:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
},
|
||||
providerDisabledBadgeLabelById: {
|
||||
opencode: 'not teamlead',
|
||||
opencode: 'side lane',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
@ -827,8 +828,10 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
button.textContent?.includes('OpenCode')
|
||||
);
|
||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(openCodeButton?.getAttribute('title')).toBe('OpenCode is not available for team lead.');
|
||||
expect(openCodeButton?.textContent).toContain('not teamlead');
|
||||
expect(openCodeButton?.getAttribute('title')).toBe(
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.'
|
||||
);
|
||||
expect(openCodeButton?.textContent).toContain('side lane');
|
||||
|
||||
await act(async () => {
|
||||
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
|
|
|||
|
|
@ -44,6 +44,44 @@ vi.mock('@renderer/api', () => ({
|
|||
replaceMembers: vi.fn(async () => {}),
|
||||
prepareProvisioning: vi.fn(async () => ({})),
|
||||
},
|
||||
tmux: {
|
||||
getStatus: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
platform: 'win32',
|
||||
nativeSupported: false,
|
||||
checkedAt: '2026-04-25T00:00:00.000Z',
|
||||
host: {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
error: null,
|
||||
},
|
||||
effective: {
|
||||
available: true,
|
||||
location: 'wsl',
|
||||
version: '3.4',
|
||||
binaryPath: '/usr/bin/tmux',
|
||||
runtimeReady: true,
|
||||
detail: 'tmux is ready',
|
||||
},
|
||||
error: null,
|
||||
autoInstall: {
|
||||
supported: false,
|
||||
strategy: 'manual',
|
||||
packageManagerLabel: null,
|
||||
requiresTerminalInput: false,
|
||||
requiresAdmin: false,
|
||||
requiresRestart: false,
|
||||
mayOpenExternalWindow: false,
|
||||
reasonIfUnsupported: null,
|
||||
manualHints: [],
|
||||
},
|
||||
wsl: null,
|
||||
wslPreference: null,
|
||||
})
|
||||
),
|
||||
onProgress: vi.fn(() => vi.fn()),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -313,8 +351,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
|||
computeEffectiveTeamModel: (model: string) => model || undefined,
|
||||
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
||||
[providerId, model, effort].filter(Boolean).join(' '),
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'not teamlead',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON: 'OpenCode is not available for team lead.',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyzeTeammateRuntimeCompatibility } from '@renderer/components/team/dialogs/teammateRuntimeCompatibility';
|
||||
|
||||
import type { TmuxStatus } from '@features/tmux-installer/contracts';
|
||||
|
||||
function buildTmuxStatus(ready: boolean): TmuxStatus {
|
||||
return {
|
||||
platform: 'win32',
|
||||
nativeSupported: false,
|
||||
checkedAt: '2026-04-25T00:00:00.000Z',
|
||||
host: {
|
||||
available: false,
|
||||
version: null,
|
||||
binaryPath: null,
|
||||
error: null,
|
||||
},
|
||||
effective: {
|
||||
available: ready,
|
||||
location: ready ? 'wsl' : null,
|
||||
version: ready ? '3.4' : null,
|
||||
binaryPath: ready ? '/usr/bin/tmux' : null,
|
||||
runtimeReady: ready,
|
||||
detail: ready ? 'tmux is ready' : 'tmux is not available',
|
||||
},
|
||||
error: null,
|
||||
autoInstall: {
|
||||
supported: false,
|
||||
strategy: 'manual',
|
||||
packageManagerLabel: null,
|
||||
requiresTerminalInput: false,
|
||||
requiresAdmin: false,
|
||||
requiresRestart: false,
|
||||
mayOpenExternalWindow: false,
|
||||
reasonIfUnsupported: null,
|
||||
manualHints: [],
|
||||
},
|
||||
wsl: null,
|
||||
wslPreference: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||
it('allows same-provider non-Codex teammates without tmux', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [{ id: 'alice', name: 'alice', providerId: 'anthropic' }],
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
expect(result.memberWarningById).toEqual({});
|
||||
});
|
||||
|
||||
it('blocks mixed-provider teammates when tmux is unavailable', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.details.join('\n')).toContain('Mixed providers');
|
||||
expect(result.memberWarningById.bob).toContain('same provider as the Anthropic lead');
|
||||
});
|
||||
|
||||
it('allows OpenCode secondary-lane teammates without tmux under a non-OpenCode lead', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [{ id: 'bob', name: 'bob', providerId: 'opencode' }],
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
expect(result.memberWarningById).toEqual({});
|
||||
});
|
||||
|
||||
it('blocks OpenCode-led mixed teams independently of tmux readiness', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'opencode',
|
||||
members: [{ id: 'bob', name: 'bob', providerId: 'anthropic' }],
|
||||
tmuxStatus: buildTmuxStatus(true),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
|
||||
expect(result.message).toContain('OpenCode-led mixed teams are not supported');
|
||||
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
||||
});
|
||||
|
||||
it('blocks same-provider Codex native teammates when tmux is unavailable', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'codex',
|
||||
leadProviderBackendId: 'codex-native',
|
||||
members: [{ id: 'jack', name: 'jack', providerId: 'codex' }],
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.title).toBe('Codex teammates need tmux before they can run');
|
||||
expect(result.message).toContain('The Codex lead can run without tmux');
|
||||
expect(result.details.join('\n')).toContain('Codex native teammates');
|
||||
expect(result.memberWarningById.jack).toContain('Codex native teammates require');
|
||||
});
|
||||
|
||||
it('allows separate-process teammate requirements when tmux is ready', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
|
||||
tmuxStatus: buildTmuxStatus(true),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores teammate runtime requirements for solo teams', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'codex',
|
||||
leadProviderBackendId: 'codex-native',
|
||||
members: [{ id: 'jack', name: 'jack', providerId: 'codex' }],
|
||||
soloTeam: true,
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks explicit tmux teammate mode when tmux is unavailable', () => {
|
||||
const result = analyzeTeammateRuntimeCompatibility({
|
||||
leadProviderId: 'anthropic',
|
||||
members: [{ id: 'alice', name: 'alice', providerId: 'anthropic' }],
|
||||
extraCliArgs: '--teammate-mode tmux',
|
||||
tmuxStatus: buildTmuxStatus(false),
|
||||
tmuxStatusLoading: false,
|
||||
tmuxStatusError: null,
|
||||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.details).toContain('Custom CLI args force --teammate-mode tmux.');
|
||||
});
|
||||
});
|
||||
28
test/renderer/hooks/useKeyboardShortcuts.test.ts
Normal file
28
test/renderer/hooks/useKeyboardShortcuts.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isEditableShortcutTarget } from '@renderer/hooks/useKeyboardShortcuts';
|
||||
|
||||
describe('isEditableShortcutTarget', () => {
|
||||
it('treats native form fields as editable shortcut targets', () => {
|
||||
const input = document.createElement('input');
|
||||
const textarea = document.createElement('textarea');
|
||||
const select = document.createElement('select');
|
||||
|
||||
expect(isEditableShortcutTarget(input)).toBe(true);
|
||||
expect(isEditableShortcutTarget(textarea)).toBe(true);
|
||||
expect(isEditableShortcutTarget(select)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats nested contenteditable textboxes as editable shortcut targets', () => {
|
||||
const textbox = document.createElement('div');
|
||||
textbox.setAttribute('role', 'textbox');
|
||||
const child = document.createElement('span');
|
||||
textbox.appendChild(child);
|
||||
|
||||
expect(isEditableShortcutTarget(child)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark regular buttons as editable targets', () => {
|
||||
expect(isEditableShortcutTarget(document.createElement('button'))).toBe(false);
|
||||
});
|
||||
});
|
||||
37
test/renderer/utils/pathDisplay.test.ts
Normal file
37
test/renderer/utils/pathDisplay.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatProjectPath,
|
||||
resolveAbsolutePath,
|
||||
shortenDisplayPath,
|
||||
} from '../../../src/renderer/utils/pathDisplay';
|
||||
|
||||
describe('pathDisplay Windows paths', () => {
|
||||
it('treats lowercase drive paths as absolute', () => {
|
||||
expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe(
|
||||
'c:\\Users\\Alice\\repo\\src\\app.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('shortens project-root relative paths case-insensitively on Windows', () => {
|
||||
expect(shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')).toBe(
|
||||
'src\\app.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats lowercase Windows user paths with a home marker', () => {
|
||||
expect(formatProjectPath('c:\\users\\Alice\\repo')).toBe('~/repo');
|
||||
});
|
||||
|
||||
it('resolves home paths from lowercase Windows user roots', () => {
|
||||
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
|
||||
'c:\\users\\Alice\\repo\\src\\app.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative paths using the project root separator', () => {
|
||||
expect(resolveAbsolutePath('src/app.ts', 'C:\\Users\\Alice\\repo')).toBe(
|
||||
'C:\\Users\\Alice\\repo\\src\\app.ts'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue