From 75f9e6bcec94576cebdafe14d0d998b9bc53125a Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 25 Apr 2026 17:19:30 +0300 Subject: [PATCH 1/2] feat: add teammate runtime compatibility notices and test coverage --- scripts/dev-with-runtime.mjs | 8 +- .../codexAppServer/CodexBinaryResolver.ts | 2 +- .../__tests__/CodexBinaryResolver.test.ts | 80 ++++ src/main/utils/childProcess.ts | 123 +++++- .../runtime/ProviderRuntimeSettingsDialog.tsx | 17 +- .../team/dialogs/CreateTeamDialog.tsx | 64 +++- .../team/dialogs/LaunchTeamDialog.tsx | 84 ++++- .../team/dialogs/TeamModelSelector.tsx | 5 +- .../TeammateRuntimeCompatibilityNotice.tsx | 60 +++ .../dialogs/teammateRuntimeCompatibility.tsx | 357 ++++++++++++++++++ .../team/members/LeadModelRow.test.tsx | 5 +- src/renderer/hooks/useKeyboardShortcuts.ts | 20 + src/renderer/index.css | 14 + src/renderer/index.html | 4 + src/renderer/utils/pathDisplay.ts | 39 +- test/main/utils/childProcess.test.ts | 132 ++++++- .../ProviderRuntimeSettingsDialog.test.ts | 74 +++- .../TeamModelSelectorDisabledState.test.ts | 11 +- .../team/dialogs/LaunchTeamDialog.test.ts | 43 ++- .../teammateRuntimeCompatibility.test.ts | 159 ++++++++ .../hooks/useKeyboardShortcuts.test.ts | 28 ++ test/renderer/utils/pathDisplay.test.ts | 37 ++ 22 files changed, 1312 insertions(+), 54 deletions(-) create mode 100644 src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts create mode 100644 src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx create mode 100644 src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx create mode 100644 test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts create mode 100644 test/renderer/hooks/useKeyboardShortcuts.test.ts create mode 100644 test/renderer/utils/pathDisplay.test.ts diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index ebd5db07..a42857b7 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -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), diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 71627c13..5a108abf 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -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 { diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts new file mode 100644 index 00000000..ec2aeaef --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -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>(); + +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); + }); +}); diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 384731d6..b4faa4f5 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -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 { 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(' '); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index c625526c..7fe5396b 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -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(null); + const apiKeyInputRef = useRef(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} 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(() => { 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([]); 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 = ({

) : null} + { + onClose(); + openDashboard(); + }} + /> +
@@ -1735,6 +1784,7 @@ export const CreateTeamDialog = ({ onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} disableGeminiOption={isGeminiUiFrozen()} leadModelIssueText={leadModelIssueText} + memberWarningById={teammateRuntimeCompatibility.memberWarningById} memberModelIssueById={memberModelIssueById} headerTop={
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 935d9260..4dcf1792 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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( () => !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 = { ...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
) : null} + {isLaunchMode ? ( + { + closeDialog(); + openDashboard(); + }} + /> + ) : null} +
{/* ═══════════════════════════════════════════════════════════════════ 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 diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 216058f3..6a315294 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -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; diff --git a/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx new file mode 100644 index 00000000..73e6b9e4 --- /dev/null +++ b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx @@ -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 ( +
+
+ +
+

{analysis.title}

+

{analysis.message}

+ {analysis.tmuxDetail ? ( +

{analysis.tmuxDetail}

+ ) : null} + {analysis.details.length > 0 ? ( +
    + {analysis.details.map((detail) => ( +
  • {detail}
  • + ))} +
+ ) : null} + {onOpenDashboard ? ( + + ) : null} +
+
+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx new file mode 100644 index 00000000..2b4913ab --- /dev/null +++ b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx @@ -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; +} + +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; +} + +const PROVIDER_LABELS: Record = { + 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 = {}; + + 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(null); + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(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] + ); +} diff --git a/src/renderer/components/team/members/LeadModelRow.test.tsx b/src/renderer/components/team/members/LeadModelRow.test.tsx index 04ed479a..21173533 100644 --- a/src/renderer/components/team/members/LeadModelRow.test.tsx +++ b/src/renderer/components/team/members/LeadModelRow.test.tsx @@ -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'), })); diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 007a01aa..d1c4d10a 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -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) diff --git a/src/renderer/index.css b/src/renderer/index.css index 455760a8..1c3fb8ef 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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 { diff --git a/src/renderer/index.html b/src/renderer/index.html index dd89a9fa..687d6619 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,6 +2,10 @@ + = 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; diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index cc43bdd1..b2bf5f54 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -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', () => { diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index ab517ea9..7e1bf142 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -119,8 +119,9 @@ vi.mock('@renderer/components/ui/dialog', () => ({ })); vi.mock('@renderer/components/ui/input', () => ({ - Input: (props: React.InputHTMLAttributes) => - React.createElement('input', props), + Input: React.forwardRef>( + (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); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index d0edde5c..b63b5f0e 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -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 })); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 68d0c90d..de4af5c8 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -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', () => ({ diff --git a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts new file mode 100644 index 00000000..db9ba6d9 --- /dev/null +++ b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts @@ -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.'); + }); +}); diff --git a/test/renderer/hooks/useKeyboardShortcuts.test.ts b/test/renderer/hooks/useKeyboardShortcuts.test.ts new file mode 100644 index 00000000..c4674875 --- /dev/null +++ b/test/renderer/hooks/useKeyboardShortcuts.test.ts @@ -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); + }); +}); diff --git a/test/renderer/utils/pathDisplay.test.ts b/test/renderer/utils/pathDisplay.test.ts new file mode 100644 index 00000000..20c5976b --- /dev/null +++ b/test/renderer/utils/pathDisplay.test.ts @@ -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' + ); + }); +}); From f2b70242263312ac37a627b24b85f13e145c601f Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 25 Apr 2026 19:12:11 +0300 Subject: [PATCH 2/2] fix(windows): support path mentions and editor launch --- src/main/http/validation.ts | 17 +++- src/main/ipc/config.ts | 80 ++++++++++++++--- src/main/utils/childProcess.ts | 6 +- .../components/chat/UserChatGroup.tsx | 37 ++++---- src/renderer/hooks/useFileSuggestions.ts | 16 ++++ src/renderer/utils/groupTransformer.ts | 86 +++++++++++++------ test/main/utils/childProcess.test.ts | 16 +++- .../renderer/hooks/useFileSuggestions.test.ts | 9 +- test/renderer/utils/fileReferences.test.ts | 21 +++++ 9 files changed, 226 insertions(+), 62 deletions(-) diff --git a/src/main/http/validation.ts b/src/main/http/validation.ts index c9d9ddcd..9b15c606 100644 --- a/src/main/http/validation.ts +++ b/src/main/http/validation.ts @@ -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; } diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index bcc81789..d4b40c2f 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -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 | null; let onAgentLanguageUpdated: ((newLangCode: string) => Promise | 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 { + return new Promise((resolve, reject) => { + const resolvedEditor = resolveWindowsEditorCommand(editor); + const launchOptions = { + detached: true, + stdio: 'ignore' as const, + windowsHide: true, + }; + let child: ReturnType; + 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((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 diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index b4faa4f5..30319e9c 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -192,13 +192,17 @@ function quoteCmdChunk(chunk: string): string { return `"${escaped}"`; } -function quoteArg(arg: string): string { +export function quoteWindowsCmdArg(arg: string): string { if (/[^A-Za-z0-9_\-/.]/.test(arg)) { 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 = { CLAUDE_HOOK_JUDGE_MODE: 'true', diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 4baeff2b..ef7fa9a5 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -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): 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( parts.push(fullMatch); } - lastIndex = match.index + fullMatch.length; + lastIndex = reference.endIndex; } if (lastIndex < text.length) { @@ -456,13 +452,10 @@ const UserChatGroupInner = ({ userGroup }: Readonly): 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): 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); diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 605d99db..15b8541e 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -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), }); } } diff --git a/src/renderer/utils/groupTransformer.ts b/src/renderer/utils/groupTransformer.ts index 0a975adf..274a0699 100644 --- a/src/renderer/utils/groupTransformer.ts +++ b/src/renderer/utils/groupTransformer.ts @@ -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 })); } // ============================================================================= diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index b2bf5f54..ff44665f 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -19,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; @@ -68,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'); diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts index d6966a8e..1f916937 100644 --- a/test/renderer/hooks/useFileSuggestions.test.ts +++ b/test/renderer/hooks/useFileSuggestions.test.ts @@ -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"' + ); + }); }); diff --git a/test/renderer/utils/fileReferences.test.ts b/test/renderer/utils/fileReferences.test.ts index ee7e5c0c..e0f78300 100644 --- a/test/renderer/utils/fileReferences.test.ts +++ b/test/renderer/utils/fileReferences.test.ts @@ -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', () => {