diff --git a/src/browser/components/LeftSidebar/LeftSidebar.tsx b/src/browser/components/LeftSidebar/LeftSidebar.tsx index a0080b5910..debdae22cd 100644 --- a/src/browser/components/LeftSidebar/LeftSidebar.tsx +++ b/src/browser/components/LeftSidebar/LeftSidebar.tsx @@ -26,15 +26,15 @@ export function LeftSidebar(props: LeftSidebarProps) { ...projectSidebarProps } = props; const isDesktop = isDesktopMode(); - // Match the CSS gate for the mobile "overlay" sidebar; we don't show a drag handle in that mode. - const isMobileTouch = - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + // Match the CSS gate for the mobile "overlay" sidebar (width-only, any pointer + // type); we don't show a drag handle in that mode since CSS pins the width. + const isMobileOverlay = + typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches; const handleBeforeOpenSettings = () => { - // Keep settings navigation escapable on touch devices by dismissing the + // Keep settings navigation escapable on narrow viewports by dismissing the // off-canvas sidebar as soon as the user opens settings from this sidebar. - if (!collapsed && isMobileTouch) { + if (!collapsed && isMobileOverlay) { onToggleCollapsed(); } }; @@ -77,7 +77,7 @@ export function LeftSidebar(props: LeftSidebarProps) { onToggleCollapsed={onToggleCollapsed} /> - {!collapsed && !isMobileTouch && onStartResize && ( + {!collapsed && !isMobileOverlay && onStartResize && (
void; } export interface ProjectCreateFormHandle { @@ -118,14 +119,23 @@ export const ProjectCreateForm = React.forwardRef { + setErrorState(next); + onErrorChange?.(next.length > 0); + }, + [onErrorChange] + ); + useEffect(() => { setPath(initialPath ?? ""); }, [initialPath]); @@ -141,7 +151,7 @@ export const ProjectCreateForm = React.forwardRef { setPath(""); setError(""); - }, []); + }, [setError]); const handleCancel = useCallback(() => { reset(); @@ -211,7 +221,7 @@ export const ProjectCreateForm = React.forwardRef { @@ -297,6 +307,7 @@ interface ProjectCloneFormProps { onIsCreatingChange?: (isCreating: boolean) => void; hideFooter?: boolean; autoFocus?: boolean; + onErrorChange?: (hasError: boolean) => void; } export interface ProjectCloneFormHandle { @@ -375,7 +386,16 @@ const ProjectCloneForm = React.forwardRef { + setErrorState(next); + onErrorChange?.(next.length > 0); + }, + [onErrorChange] + ); const [destinationExistsPath, setDestinationExistsPath] = useState(null); const [isCreating, setIsCreating] = useState(false); const [cloneOutput, setCloneOutput] = useState(""); @@ -402,7 +422,7 @@ const ProjectCloneForm = React.forwardRef { if (!abortControllerRef.current) { @@ -552,7 +572,7 @@ const ProjectCloneForm = React.forwardRef { if (!api || !destinationExistsPath) { @@ -593,13 +613,13 @@ const ProjectCloneForm = React.forwardRef { setError(""); setCloneOutput(""); rawOutputRef.current = ""; - }, []); + }, [setError]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -794,6 +814,7 @@ interface ProjectAddFormProps { autoFocus?: boolean; hideFooter?: boolean; showCancelButton?: boolean; + onErrorChange?: (hasError: boolean) => void; } export const ProjectAddForm = React.forwardRef( @@ -864,6 +885,7 @@ export const ProjectAddForm = React.forwardRef { if (nextMode !== "pick-folder" && nextMode !== "clone") { @@ -871,11 +893,14 @@ export const ProjectAddForm = React.forwardRef ) : ( @@ -940,6 +966,7 @@ export const ProjectAddForm = React.forwardRef diff --git a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx index 11baee93f8..396d9b1ab7 100644 --- a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx +++ b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx @@ -210,6 +210,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { const projectAddFormRef = useRef(null); const [isProjectCreating, setIsProjectCreating] = useState(false); + const [projectFormHasError, setProjectFormHasError] = useState(false); const [direction, setDirection] = useState("forward"); @@ -843,6 +844,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { autoFocus={userProjects.size === 0} hideFooter onIsCreatingChange={setIsProjectCreating} + onErrorChange={setProjectFormHasError} onSuccess={(normalizedPath, projectConfig) => { addProject(normalizedPath, projectConfig); updatePersistedState(getAgentsInitNudgeKey(normalizedPath), true); @@ -852,11 +854,14 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { />
-

- {userProjects.size > 0 - ? "Add another folder or repo, or leave this blank and click Next to continue." - : "Click Next to add this project."} -

+ {/* Hide the "click Next" nudge while the form shows an error; the two contradict. */} + {!projectFormHasError && ( +

+ {userProjects.size > 0 + ? "Add another folder or repo, or leave this blank and click Next to continue." + : "Click Next to add this project."} +

+ )} ), }); @@ -1011,6 +1016,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { muxGatewayLoginInProgress, muxGatewayLoginStatus, openProvidersSettings, + projectFormHasError, userProjects.size, providersConfig, refreshMuxGatewayAccountStatus, @@ -1048,6 +1054,9 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { return; } setDirection("back"); + // Changing steps remounts the project form without its inline error, so + // clear the stale flag or the "click Next" nudge stays hidden on return. + setProjectFormHasError(false); setStepIndex((i) => Math.max(0, i - 1)); }; @@ -1056,6 +1065,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { return; } setDirection("forward"); + setProjectFormHasError(false); setStepIndex((i) => Math.min(totalSteps - 1, i + 1)); }; diff --git a/src/browser/features/SplashScreens/SplashScreen.tsx b/src/browser/features/SplashScreens/SplashScreen.tsx index 8b73b57491..0ba4827a4d 100644 --- a/src/browser/features/SplashScreens/SplashScreen.tsx +++ b/src/browser/features/SplashScreens/SplashScreen.tsx @@ -42,11 +42,18 @@ export function SplashScreen(props: SplashScreenProps) { return ( !open && props.onDismiss()}> - e.preventDefault()}> + {/* aria-describedby={undefined}: splash bodies are rich content, not a short description. */} + {/* Cap height and scroll the body (footer pinned) so tall steps keep Next/Skip clickable. */} + e.preventDefault()} + > {props.title} - {props.children} +
{props.children}
{props.footer ?? ( <> diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 9307113278..f3b038ff62 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1140,7 +1140,40 @@ body, min-width: 44px; } - /* Show mobile menu button only on touch devices */ + /* Keep workspace header visible when keyboard opens - fixed positioning + because iOS Safari scrolls the visual viewport, not a scroll container. + Left/right safe-area insets ensure header content avoids notch in landscape. + min-height 44px for touch targets, but allow growth for wrapped controls. */ + .mobile-sticky-header { + position: fixed !important; + top: env(safe-area-inset-top, 0px) !important; + left: env(safe-area-inset-left, 0px) !important; + right: env(safe-area-inset-right, 0px) !important; + z-index: 50 !important; + min-height: 44px !important; + height: auto !important; + flex-wrap: wrap !important; + } + + /* Add padding to content below fixed header so it's not hidden underneath. + Use generous padding (88px = 2x touch targets) to accommodate header wrapping. + A true dynamic solution would require JS to measure header height. */ + .mobile-header-spacer { + padding-top: 88px !important; + } + + /* Hide right sidebar on mobile - too narrow for useful interaction */ + .mobile-hide-right-sidebar { + display: none !important; + } +} + +/* Tailwind utility extensions for dark theme surfaces */ + +/* Narrow viewports, any pointer type: the expanded sidebar overlays the main + pane instead of crushing it (a 288px in-flow sidebar leaves ~90px of content + at 375px), and the menu button provides a way to reopen it once hidden. */ +@media (max-width: 768px) { .mobile-menu-btn { display: inline-flex !important; justify-content: flex-start !important; @@ -1151,12 +1184,10 @@ body, padding-left: 0 !important; } - /* Show mobile overlay only on touch devices */ .mobile-overlay { display: block !important; } - /* Mobile sidebar positioning only on touch devices */ .mobile-sidebar { position: fixed !important; left: 0 !important; @@ -1177,7 +1208,7 @@ body, box-shadow: none !important; } - /* Mobile layout - stack vertically on touch devices */ + /* Stack vertically so main content gets the full viewport width */ .mobile-layout { flex-direction: column !important; } @@ -1186,38 +1217,7 @@ body, width: 100% !important; } - /* Keep workspace header visible when keyboard opens - fixed positioning - because iOS Safari scrolls the visual viewport, not a scroll container. - Left/right safe-area insets ensure header content avoids notch in landscape. - min-height 44px for touch targets, but allow growth for wrapped controls. */ - .mobile-sticky-header { - position: fixed !important; - top: env(safe-area-inset-top, 0px) !important; - left: env(safe-area-inset-left, 0px) !important; - right: env(safe-area-inset-right, 0px) !important; - z-index: 50 !important; - min-height: 44px !important; - height: auto !important; - flex-wrap: wrap !important; - } - - /* Add padding to content below fixed header so it's not hidden underneath. - Use generous padding (88px = 2x touch targets) to accommodate header wrapping. - A true dynamic solution would require JS to measure header height. */ - .mobile-header-spacer { - padding-top: 88px !important; - } - - /* Hide right sidebar on mobile - too narrow for useful interaction */ - .mobile-hide-right-sidebar { - display: none !important; - } -} - -/* Tailwind utility extensions for dark theme surfaces */ - -/* Hide right sidebar on small screens so it never stacks below the chat pane. */ -@media (max-width: 768px) { + /* Hide right sidebar on small screens so it never stacks below the chat pane. */ .mobile-hide-right-sidebar { display: none !important; } diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 6d9e95fc46..3390417b6e 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -319,6 +319,22 @@ interface FileCompletionsCacheEntry { refreshing?: Promise; } +// Keep raw Node errno details out of project-add errors. +function friendlyFsError(error: unknown, action: string, targetPath: string): string | null { + const code = (error as NodeJS.ErrnoException).code; + switch (code) { + case "EACCES": + case "EPERM": + return `Cannot ${action} "${targetPath}": permission denied`; + case "EROFS": + return `Cannot ${action} "${targetPath}": the file system is read-only`; + case "ENOTDIR": + return `Cannot ${action} "${targetPath}": part of the path is not a folder`; + default: + return null; + } +} + async function resolveRealProjectPath(projectPath: string): Promise { return stripTrailingSlashes(await fsPromises.realpath(projectPath)); } @@ -435,6 +451,10 @@ export class ProjectService { } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== "ENOENT") { + const friendly = friendlyFsError(error, "access", normalizedPath); + if (friendly) { + return Err(friendly); + } throw error; } } @@ -459,7 +479,15 @@ export class ProjectService { // Create the directory if it doesn't exist (like mkdir -p). Keep the user-facing // path stable in config; Windows realpath may expand 8.3 short names and surprise callers. - await fsPromises.mkdir(normalizedPath, { recursive: true }); + try { + await fsPromises.mkdir(normalizedPath, { recursive: true }); + } catch (error) { + const friendly = friendlyFsError(error, "create folder", normalizedPath); + if (friendly) { + return Err(friendly); + } + throw error; + } const canonicalPath = await resolveRealProjectPath(normalizedPath); if (config.projects.has(canonicalPath)) { diff --git a/tests/ipc/projects/create.test.ts b/tests/ipc/projects/create.test.ts index da4f70e8aa..51eccf6ca3 100644 --- a/tests/ipc/projects/create.test.ts +++ b/tests/ipc/projects/create.test.ts @@ -187,6 +187,61 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { await fs.rm(tempProjectDir, { recursive: true, force: true }); }); + test.concurrent("should return a friendly error when folder creation is denied", async () => { + // This scenario relies on POSIX permission bits and a non-root user. + if ( + process.platform === "win32" || + (typeof process.getuid === "function" && process.getuid() === 0) + ) { + return; + } + + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-")); + const readOnlyDir = path.join(tempProjectDir, "readonly"); + await fs.mkdir(readOnlyDir, { mode: 0o555 }); + const client = resolveOrpcClient(env); + + try { + const result = await client.projects.create({ + projectPath: path.join(readOnlyDir, "child"), + }); + + if (result.success) { + throw new Error("Expected failure but got success"); + } + expect(result.error).toContain("permission denied"); + expect(result.error).not.toContain("EACCES"); + } finally { + await fs.chmod(readOnlyDir, 0o755); + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + } + }); + + test.concurrent("should return a friendly error when the path runs through a file", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-")); + const occupiedFile = path.join(tempProjectDir, "occupied.txt"); + await fs.writeFile(occupiedFile, "content"); + const client = resolveOrpcClient(env); + + try { + const result = await client.projects.create({ + projectPath: path.join(occupiedFile, "child"), + }); + + if (result.success) { + throw new Error("Expected failure but got success"); + } + expect(result.error).toContain("not a folder"); + expect(result.error).not.toContain("ENOTDIR"); + } finally { + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + } + }); + test.concurrent("should create non-existent tilde path", async () => { const env = await createTestEnvironment(); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-test-"));