merge: integrate runtime compatibility notices
This commit is contained in:
commit
427f48dd71
29 changed files with 1538 additions and 116 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),
|
||||
|
|
|
|||
|
|
@ -20,11 +20,20 @@ const logger = createLogger('HTTP:validation');
|
|||
* Prevents path traversal attacks.
|
||||
*/
|
||||
function isPathContained(fullPath: string, basePath: string): boolean {
|
||||
const normalizedFull = path.normalize(fullPath);
|
||||
const normalizedBase = path.normalize(basePath);
|
||||
const normalizedFull = normalizeForContainment(fullPath);
|
||||
const normalizedBase = normalizeForContainment(basePath);
|
||||
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
|
||||
}
|
||||
|
||||
function normalizeForContainment(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function resolveProjectPath(projectPath: string, requestedPath: string): string {
|
||||
return path.isAbsolute(requestedPath) ? requestedPath : path.join(projectPath, requestedPath);
|
||||
}
|
||||
|
||||
export function registerValidationRoutes(app: FastifyInstance): void {
|
||||
// Validate path
|
||||
app.post<{ Body: { relativePath: string; projectPath: string } }>(
|
||||
|
|
@ -32,7 +41,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
|
|||
async (request) => {
|
||||
try {
|
||||
const { relativePath, projectPath } = request.body;
|
||||
const fullPath = path.join(projectPath, relativePath);
|
||||
const fullPath = resolveProjectPath(projectPath, relativePath);
|
||||
|
||||
if (!isPathContained(fullPath, projectPath)) {
|
||||
logger.warn('validate-path blocked path traversal attempt:', relativePath);
|
||||
|
|
@ -57,7 +66,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
|
|||
// Validate all mentions in parallel with async I/O
|
||||
const entries = await Promise.all(
|
||||
mentions.map(async (mention) => {
|
||||
const fullPath = path.join(projectPath, mention.value);
|
||||
const fullPath = resolveProjectPath(projectPath, mention.value);
|
||||
if (!isPathContained(fullPath, projectPath)) {
|
||||
return [`@${mention.value}`, false] as const;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@
|
|||
*/
|
||||
|
||||
import { syncTelemetryFlag } from '@main/sentry';
|
||||
import { quoteWindowsCmdArg } from '@main/utils/childProcess';
|
||||
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
import { execFile, execFileSync, spawn } from 'child_process';
|
||||
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -57,6 +58,72 @@ let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> |
|
|||
null;
|
||||
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
|
||||
|
||||
function isPathLikeCommand(command: string): boolean {
|
||||
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
|
||||
}
|
||||
|
||||
function resolveWindowsEditorCommand(editor: string): string {
|
||||
if (process.platform !== 'win32' || isPathLikeCommand(editor)) {
|
||||
return editor;
|
||||
}
|
||||
|
||||
try {
|
||||
const whereExe = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe');
|
||||
const output = execFileSync(whereExe, [editor], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return output.trim().split(/\r?\n/)[0] || editor;
|
||||
} catch {
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
|
||||
function needsWindowsShell(command: string): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
const extension = path.extname(command).toLowerCase();
|
||||
return extension === '.cmd' || extension === '.bat';
|
||||
}
|
||||
|
||||
function launchExternalEditor(editor: string, configPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resolvedEditor = resolveWindowsEditorCommand(editor);
|
||||
const launchOptions = {
|
||||
detached: true,
|
||||
stdio: 'ignore' as const,
|
||||
windowsHide: true,
|
||||
};
|
||||
let child: ReturnType<typeof spawn>;
|
||||
if (needsWindowsShell(resolvedEditor)) {
|
||||
const command = [resolvedEditor, configPath].map(quoteWindowsCmdArg).join(' ');
|
||||
// eslint-disable-next-line sonarjs/os-command -- Windows .cmd launchers require cmd.exe; editor path is resolved via where.exe and args are cmd-escaped.
|
||||
child = spawn(command, {
|
||||
...launchOptions,
|
||||
shell: true,
|
||||
});
|
||||
} else {
|
||||
child = spawn(resolvedEditor, [configPath], launchOptions);
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
function settle(fn: () => void): void {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.unref();
|
||||
settle(() => resolve());
|
||||
}, 500);
|
||||
|
||||
child.on('error', (err) => {
|
||||
settle(() => reject(err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes config handlers with callbacks that require app-level services.
|
||||
*/
|
||||
|
|
@ -607,16 +674,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
|
|||
|
||||
for (const editor of editors) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = execFile(editor, [configPath], { timeout: 5000 });
|
||||
// If the process spawns successfully, resolve after a short delay
|
||||
// (editors typically fork and the parent exits quickly)
|
||||
const timer = setTimeout(() => resolve(), 500);
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
await launchExternalEditor(editor, configPath);
|
||||
return { success: true };
|
||||
} catch {
|
||||
// Editor not found, try next
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
@ -83,14 +84,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,21 +180,33 @@ 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 quoteArg(arg: string): string {
|
||||
function quoteCmdChunk(chunk: string): string {
|
||||
const escaped = chunk
|
||||
.replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
|
||||
.replace(/(\\+)$/g, '$1$1');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
export function quoteWindowsCmdArg(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;
|
||||
}
|
||||
|
||||
function quoteArg(arg: string): string {
|
||||
return quoteWindowsCmdArg(arg);
|
||||
}
|
||||
|
||||
/** Env vars injected into every spawned Claude CLI process. */
|
||||
const CLI_ENV_DEFAULTS: Record<string, string> = {
|
||||
CLAUDE_HOOK_JUDGE_MODE: 'true',
|
||||
|
|
@ -176,6 +270,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)) {
|
||||
|
|
@ -213,6 +316,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(' ');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { extractFileReferenceTokens } from '@renderer/utils/groupTransformer';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
|
|
@ -33,9 +34,6 @@ import type { UserGroup } from '@renderer/types/groups';
|
|||
|
||||
const logger = createLogger('Component:UserChatGroup');
|
||||
|
||||
// Pattern for @paths only (file references)
|
||||
const PATH_PATTERN = /@([^\s,)}\]]+)/g;
|
||||
|
||||
interface UserChatGroupProps {
|
||||
userGroup: UserGroup;
|
||||
}
|
||||
|
|
@ -46,24 +44,22 @@ interface UserChatGroupProps {
|
|||
*/
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
|
||||
function highlightTextNode(text: string, validatedPaths: Record<string, boolean>): React.ReactNode {
|
||||
const pathPattern = /@[^\s,)}\]]+/g;
|
||||
const pathReferences = extractFileReferenceTokens(text);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
pathPattern.lastIndex = 0;
|
||||
while ((match = pathPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
for (const reference of pathReferences) {
|
||||
if (reference.startIndex > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, reference.startIndex));
|
||||
}
|
||||
|
||||
const fullMatch = match[0];
|
||||
const fullMatch = reference.raw;
|
||||
const isValid = validatedPaths[fullMatch] === true;
|
||||
|
||||
if (isValid) {
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
key={reference.startIndex}
|
||||
style={{
|
||||
backgroundColor: 'var(--chat-user-tag-bg)',
|
||||
color: 'var(--chat-user-tag-text)',
|
||||
|
|
@ -81,7 +77,7 @@ function highlightTextNode(text: string, validatedPaths: Record<string, boolean>
|
|||
parts.push(fullMatch);
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
lastIndex = reference.endIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
|
|
@ -456,13 +452,10 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
// Extract @path mentions from text
|
||||
const pathMentions = useMemo(() => {
|
||||
if (!textContent) return [];
|
||||
const result: { value: string; raw: string }[] = [];
|
||||
const pathPattern = new RegExp(PATH_PATTERN.source, PATH_PATTERN.flags);
|
||||
let match;
|
||||
while ((match = pathPattern.exec(textContent)) !== null) {
|
||||
result.push({ value: match[1], raw: match[0] });
|
||||
}
|
||||
return result;
|
||||
return extractFileReferenceTokens(textContent).map((reference) => ({
|
||||
value: reference.path,
|
||||
raw: reference.raw,
|
||||
}));
|
||||
}, [textContent]);
|
||||
|
||||
// Validate @path mentions via IPC
|
||||
|
|
@ -475,7 +468,11 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
|
||||
const results = await api.validateMentions(toValidate, projectPath);
|
||||
if (isCurrent) {
|
||||
setValidatedPaths(results);
|
||||
setValidatedPaths(
|
||||
Object.fromEntries(
|
||||
pathMentions.map((mention) => [mention.raw, results[`@${mention.value}`] === true])
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Path validation failed:', err);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatCodexCreditsValue,
|
||||
|
|
@ -563,6 +563,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);
|
||||
|
|
@ -801,6 +802,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) {
|
||||
|
|
@ -1736,6 +1750,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
|
||||
|
|
|
|||
|
|
@ -65,8 +65,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'),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { QuickOpenFile } from '@shared/types/editor';
|
|||
|
||||
const MAX_FILE_SUGGESTIONS = 8;
|
||||
const MAX_FOLDER_SUGGESTIONS = 5;
|
||||
const MENTION_PATH_QUOTE_NEEDED = /[\s,)}\]"']/;
|
||||
|
||||
export interface UseFileSuggestionsResult {
|
||||
suggestions: MentionSuggestion[];
|
||||
|
|
@ -35,6 +36,19 @@ interface DerivedFolder {
|
|||
absolutePath: string;
|
||||
}
|
||||
|
||||
export function formatFileMentionPath(relativePath: string): string {
|
||||
if (!MENTION_PATH_QUOTE_NEEDED.test(relativePath)) {
|
||||
return relativePath;
|
||||
}
|
||||
if (!relativePath.includes('"')) {
|
||||
return `"${relativePath}"`;
|
||||
}
|
||||
if (!relativePath.includes("'")) {
|
||||
return `'${relativePath}'`;
|
||||
}
|
||||
return `"${relativePath.replace(/"/g, '')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique directories from a list of file paths.
|
||||
* Returns directories sorted by depth (shallower first), then alphabetically.
|
||||
|
|
@ -94,6 +108,7 @@ export function filterFileSuggestions(files: QuickOpenFile[], query: string): Me
|
|||
type: 'file',
|
||||
filePath: f.path,
|
||||
relativePath: f.relativePath,
|
||||
insertText: formatFileMentionPath(f.relativePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +142,7 @@ export function filterFolderSuggestions(
|
|||
type: 'folder',
|
||||
filePath: f.absolutePath,
|
||||
relativePath: f.relativePath,
|
||||
insertText: formatFileMentionPath(f.relativePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -353,17 +353,21 @@ const KNOWN_DIRS = new Set([
|
|||
'node_modules',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Simple pattern for detecting @ mentions that could be file paths.
|
||||
* The filtering logic in extractFileReferences determines validity.
|
||||
*/
|
||||
const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g;
|
||||
export type FileReferenceToken = FileReference & {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
};
|
||||
|
||||
const UNQUOTED_FILE_REF_STOP = /[\s,)}\]]/;
|
||||
|
||||
/**
|
||||
* Checks if a path looks like a valid file reference.
|
||||
* Must start with known dir, contain /, or start with ./ or ../
|
||||
*/
|
||||
function isValidFileRef(path: string): boolean {
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')) {
|
||||
return true;
|
||||
}
|
||||
// Check for relative path indicators
|
||||
if (isRelativePath(path)) {
|
||||
return true;
|
||||
|
|
@ -385,6 +389,58 @@ function isValidFileRef(path: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function readFileRefAt(text: string, atIndex: number): FileReferenceToken | null {
|
||||
const valueStart = atIndex + 1;
|
||||
const firstChar = text[valueStart];
|
||||
if (!firstChar) return null;
|
||||
|
||||
let path = '';
|
||||
let endIndex = valueStart;
|
||||
|
||||
if (firstChar === '"' || firstChar === "'") {
|
||||
const quote = firstChar;
|
||||
const quotedStart = valueStart + 1;
|
||||
const quotedEnd = text.indexOf(quote, quotedStart);
|
||||
if (quotedEnd < 0) return null;
|
||||
path = text.slice(quotedStart, quotedEnd);
|
||||
endIndex = quotedEnd + 1;
|
||||
} else {
|
||||
while (endIndex < text.length && !UNQUOTED_FILE_REF_STOP.test(text[endIndex])) {
|
||||
endIndex += 1;
|
||||
}
|
||||
path = text.slice(valueStart, endIndex);
|
||||
}
|
||||
|
||||
if (!path || !isValidFileRef(path)) return null;
|
||||
return {
|
||||
path,
|
||||
raw: text.slice(atIndex, endIndex),
|
||||
startIndex: atIndex,
|
||||
endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractFileReferenceTokens(text: string): FileReferenceToken[] {
|
||||
if (!text) return [];
|
||||
|
||||
const references: FileReferenceToken[] = [];
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
const atIndex = text.indexOf('@', index);
|
||||
if (atIndex < 0) break;
|
||||
|
||||
const reference = readFileRefAt(text, atIndex);
|
||||
if (reference) {
|
||||
references.push(reference);
|
||||
index = reference.endIndex;
|
||||
} else {
|
||||
index = atIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts file references (@file.ts) from text.
|
||||
*
|
||||
|
|
@ -392,25 +448,7 @@ function isValidFileRef(path: string): boolean {
|
|||
* @returns Array of FileReference objects
|
||||
*/
|
||||
export function extractFileReferences(text: string): FileReference[] {
|
||||
if (!text) return [];
|
||||
|
||||
const references: FileReference[] = [];
|
||||
// Reset regex state before use
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = FILE_REF_PATTERN.exec(text)) !== null) {
|
||||
const [fullMatch, path] = match;
|
||||
// Only include if it looks like a valid file reference
|
||||
if (isValidFileRef(path)) {
|
||||
references.push({
|
||||
path,
|
||||
raw: fullMatch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
return extractFileReferenceTokens(text).map(({ path, raw }) => ({ path, raw }));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,7 +19,12 @@ vi.mock('child_process', async (importOriginal) => {
|
|||
|
||||
// Import after the mock call so that the mocked module is returned.
|
||||
import * as child from 'child_process';
|
||||
import { execCli, killTrackedCliProcesses, spawnCli } from '@main/utils/childProcess';
|
||||
import {
|
||||
execCli,
|
||||
killTrackedCliProcesses,
|
||||
quoteWindowsCmdArg,
|
||||
spawnCli,
|
||||
} from '@main/utils/childProcess';
|
||||
|
||||
type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
|
|
@ -31,6 +40,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();
|
||||
|
|
@ -40,6 +73,15 @@ describe('cli child process helpers', () => {
|
|||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
describe('quoteWindowsCmdArg', () => {
|
||||
it('keeps percent signs literal in cmd.exe command strings', () => {
|
||||
const quoted = quoteWindowsCmdArg('C:\\Users\\Alice\\a%PATH%b.txt');
|
||||
expect(quoted).toContain('"C:\\Users\\Alice\\a"^%"PATH"^%"b.txt"');
|
||||
expect(quoted).not.toContain('%PATH%');
|
||||
expect(quoted).not.toContain('%%PATH%%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnCli', () => {
|
||||
it('calls spawn directly when path is ascii on windows', () => {
|
||||
setPlatform('win32');
|
||||
|
|
@ -79,6 +121,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 +243,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 +309,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', () => {
|
||||
|
|
|
|||
|
|
@ -122,8 +122,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', () => ({
|
||||
|
|
@ -441,6 +442,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);
|
||||
|
|
@ -566,6 +577,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);
|
||||
|
|
|
|||
|
|
@ -948,10 +948,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',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
@ -962,8 +963,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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { filterFileSuggestions } from '@renderer/hooks/useFileSuggestions';
|
||||
import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions';
|
||||
|
||||
import type { QuickOpenFile } from '@shared/types/editor';
|
||||
|
||||
|
|
@ -43,6 +43,7 @@ describe('filterFileSuggestions', () => {
|
|||
expect(results[0].type).toBe('file');
|
||||
expect(results[0].filePath).toBe('/project/src/test.ts');
|
||||
expect(results[0].relativePath).toBe('src/test.ts');
|
||||
expect(results[0].insertText).toBe('src/test.ts');
|
||||
});
|
||||
|
||||
it('filters by relative path', () => {
|
||||
|
|
@ -94,4 +95,10 @@ describe('filterFileSuggestions', () => {
|
|||
const results = filterFileSuggestions(FILES, '.ts');
|
||||
expect(results[0].name).toBe('index.ts');
|
||||
});
|
||||
|
||||
it('quotes inserted paths that contain spaces', () => {
|
||||
expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
|
||||
'"src/My Component/App.tsx"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -82,6 +82,27 @@ describe('extractFileReferences', () => {
|
|||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].path).toBe('~/projects/foo');
|
||||
});
|
||||
|
||||
it('accepts Windows backslash paths', () => {
|
||||
const refs = extractFileReferences('Check @src\\components\\App.tsx');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].path).toBe('src\\components\\App.tsx');
|
||||
});
|
||||
|
||||
it('accepts Windows drive absolute paths', () => {
|
||||
const refs = extractFileReferences('Open @C:\\Users\\Alice\\project\\src\\App.tsx');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].path).toBe('C:\\Users\\Alice\\project\\src\\App.tsx');
|
||||
});
|
||||
|
||||
it('accepts quoted paths with spaces', () => {
|
||||
const refs = extractFileReferences('Open @"src/My Component/App.tsx" now');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toEqual({
|
||||
path: 'src/My Component/App.tsx',
|
||||
raw: '@"src/My Component/App.tsx"',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejects npm scoped packages', () => {
|
||||
|
|
|
|||
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