From c3df875b3bff859bff4b0f9749719ee6228eeac1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:30:41 +0000 Subject: [PATCH 1/4] fix: dogfood onboarding + narrow-viewport fixes - SplashScreen: cap dialog height (85vh) with scrollable body so tall wizard steps keep Next/Skip clickable at short viewports; declare no dialog description (aria-describedby={undefined}) to fix Radix a11y warning - ProjectService.create: map EACCES/EPERM/EROFS/ENOTDIR mkdir/stat failures to friendly errors instead of leaking raw Node errno strings - ProjectAddForm/wizard: report inline error state upward and hide the 'Click Next to add this project.' nudge while an error is shown - Sidebar overlay now gates on width (max-width: 768px) instead of width + coarse pointer, so a narrow non-touch window no longer crushes main content --- .../components/LeftSidebar/LeftSidebar.tsx | 14 ++-- .../ProjectCreateModal/ProjectCreateModal.tsx | 46 +++++++++--- .../SplashScreens/OnboardingWizardSplash.tsx | 16 +++-- .../features/SplashScreens/SplashScreen.tsx | 11 ++- src/browser/styles/globals.css | 72 +++++++++---------- src/node/services/projectService.ts | 34 ++++++++- tests/ipc/projects/create.test.ts | 55 ++++++++++++++ 7 files changed, 188 insertions(+), 60 deletions(-) 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 +120,23 @@ export const ProjectCreateForm = React.forwardRef { + setErrorState(next); + onErrorChange?.(next.length > 0); + }, + [onErrorChange] + ); + useEffect(() => { setPath(initialPath ?? ""); }, [initialPath]); @@ -141,7 +152,7 @@ export const ProjectCreateForm = React.forwardRef { setPath(""); setError(""); - }, []); + }, [setError]); const handleCancel = useCallback(() => { reset(); @@ -211,7 +222,7 @@ export const ProjectCreateForm = React.forwardRef { @@ -297,6 +308,8 @@ interface ProjectCloneFormProps { onIsCreatingChange?: (isCreating: boolean) => void; hideFooter?: boolean; autoFocus?: boolean; + /** Notifies the parent when an inline error appears/clears (e.g. to hide contradictory helper text). */ + onErrorChange?: (hasError: boolean) => void; } export interface ProjectCloneFormHandle { @@ -375,7 +388,15 @@ const ProjectCloneForm = React.forwardRef { + setErrorState(next); + props.onErrorChange?.(next.length > 0); + }, + [props] + ); const [destinationExistsPath, setDestinationExistsPath] = useState(null); const [isCreating, setIsCreating] = useState(false); const [cloneOutput, setCloneOutput] = useState(""); @@ -402,7 +423,7 @@ const ProjectCloneForm = React.forwardRef { if (!abortControllerRef.current) { @@ -552,7 +573,7 @@ const ProjectCloneForm = React.forwardRef { if (!api || !destinationExistsPath) { @@ -593,13 +614,13 @@ const ProjectCloneForm = React.forwardRef { setError(""); setCloneOutput(""); rawOutputRef.current = ""; - }, []); + }, [setError]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -794,6 +815,8 @@ interface ProjectAddFormProps { autoFocus?: boolean; hideFooter?: boolean; showCancelButton?: boolean; + /** Notifies the parent when the active form shows an inline error (e.g. to hide contradictory helper text). */ + onErrorChange?: (hasError: boolean) => void; } export const ProjectAddForm = React.forwardRef( @@ -871,11 +894,14 @@ export const ProjectAddForm = React.forwardRef ) : ( @@ -940,6 +967,7 @@ export const ProjectAddForm = React.forwardRef diff --git a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx index 11baee93f8..0930316e12 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, 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..82e457845d 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -319,6 +319,26 @@ interface FileCompletionsCacheEntry { refreshing?: Promise; } +/** + * Translate common filesystem errno failures into friendly, user-facing messages. + * Without this, raw Node errno strings (e.g. "EACCES: permission denied, mkdir '/x'") + * leak into the project-add UI. Returns null for codes we don't recognize. + */ +function friendlyFsError(error: unknown, action: string, targetPath: string): string | null { + const code = (error as NodeJS.ErrnoException | null)?.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 +455,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 +483,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..c074766bf9 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 }); }); + // Regression tests for friendly filesystem error mapping: raw Node errno + // strings (e.g. "EACCES: permission denied, mkdir '/x'") must not leak + // into the project-add UI. + test.concurrent("should return a friendly error when folder creation is denied", async () => { + // Root bypasses permission bits, so this scenario cannot be reproduced as root. + if (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-")); From 640bbce91e22141481196b64c53371334114c8fd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:35:29 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20onboa?= =?UTF-8?q?rding=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Mux worked on behalf of Mike. --- .../ProjectCreateModal/ProjectCreateModal.tsx | 7 ++----- src/node/services/projectService.ts | 8 ++------ tests/ipc/projects/create.test.ts | 10 +++++----- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx index 6aa341b937..a918c69768 100644 --- a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx @@ -97,7 +97,6 @@ interface ProjectCreateFormProps { placeholder?: string; /** Hide the footer actions (submit/cancel buttons). */ hideFooter?: boolean; - /** Notifies the parent when an inline error appears/clears (e.g. to hide contradictory helper text). */ onErrorChange?: (hasError: boolean) => void; } @@ -308,7 +307,6 @@ interface ProjectCloneFormProps { onIsCreatingChange?: (isCreating: boolean) => void; hideFooter?: boolean; autoFocus?: boolean; - /** Notifies the parent when an inline error appears/clears (e.g. to hide contradictory helper text). */ onErrorChange?: (hasError: boolean) => void; } @@ -395,7 +393,7 @@ const ProjectCloneForm = React.forwardRef 0); }, - [props] + [props.onErrorChange] ); const [destinationExistsPath, setDestinationExistsPath] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -815,7 +813,6 @@ interface ProjectAddFormProps { autoFocus?: boolean; hideFooter?: boolean; showCancelButton?: boolean; - /** Notifies the parent when the active form shows an inline error (e.g. to hide contradictory helper text). */ onErrorChange?: (hasError: boolean) => void; } @@ -901,7 +898,7 @@ export const ProjectAddForm = React.forwardRef; } -/** - * Translate common filesystem errno failures into friendly, user-facing messages. - * Without this, raw Node errno strings (e.g. "EACCES: permission denied, mkdir '/x'") - * leak into the project-add UI. Returns null for codes we don't recognize. - */ +// 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 | null)?.code; + const code = (error as NodeJS.ErrnoException).code; switch (code) { case "EACCES": case "EPERM": diff --git a/tests/ipc/projects/create.test.ts b/tests/ipc/projects/create.test.ts index c074766bf9..51eccf6ca3 100644 --- a/tests/ipc/projects/create.test.ts +++ b/tests/ipc/projects/create.test.ts @@ -187,12 +187,12 @@ describeIntegration("PROJECT_CREATE IPC Handler", () => { await fs.rm(tempProjectDir, { recursive: true, force: true }); }); - // Regression tests for friendly filesystem error mapping: raw Node errno - // strings (e.g. "EACCES: permission denied, mkdir '/x'") must not leak - // into the project-add UI. test.concurrent("should return a friendly error when folder creation is denied", async () => { - // Root bypasses permission bits, so this scenario cannot be reproduced as root. - if (typeof process.getuid === "function" && process.getuid() === 0) { + // This scenario relies on POSIX permission bits and a non-root user. + if ( + process.platform === "win32" || + (typeof process.getuid === "function" && process.getuid() === 0) + ) { return; } From a82c9d705383803c09c359837d53d77a1fc9db2b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:38:17 +0000 Subject: [PATCH 3/4] lint: extract onErrorChange prop for exhaustive-deps --- .../ProjectCreateModal/ProjectCreateModal.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx index a918c69768..76ef5d7a76 100644 --- a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx @@ -388,12 +388,13 @@ const ProjectCloneForm = React.forwardRef { setErrorState(next); - props.onErrorChange?.(next.length > 0); + onErrorChange?.(next.length > 0); }, - [props.onErrorChange] + [onErrorChange] ); const [destinationExistsPath, setDestinationExistsPath] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -884,6 +885,7 @@ export const ProjectAddForm = React.forwardRef { if (nextMode !== "pick-folder" && nextMode !== "clone") { @@ -893,12 +895,12 @@ export const ProjectAddForm = React.forwardRef Date: Tue, 14 Jul 2026 07:30:04 +0000 Subject: [PATCH 4/4] fix: clear stale project-form error flag on wizard step change --- src/browser/features/SplashScreens/OnboardingWizardSplash.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx index 0930316e12..396d9b1ab7 100644 --- a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx +++ b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx @@ -1054,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)); }; @@ -1062,6 +1065,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { return; } setDirection("forward"); + setProjectFormHasError(false); setStepIndex((i) => Math.min(totalSteps - 1, i + 1)); };