From 2438c7d6fd7c98e38ef0fe6525416183b5ddc00c Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 6 Nov 2025 20:28:45 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20improve=20UI=20densi?= =?UTF-8?q?ty=20and=20fix=20mobile-only=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project Sidebar: Single-line display with styled basename (increases density) - Previously showed project name + abbreviated path on two lines - Now shows abbreviated path with bold basename on one line - Added splitAbbreviatedPath() utility with tests - Applied to both project items and drag preview - ChatInput: Fix responsive controls hiding - Replace broken container query syntax (max-@[Xpx]) with media queries - Mode switch hides at 550px, thinking slider at 550px, context at 450px - Prevents line-breaking of controls on constrained viewports - Mobile features: Only apply to touch devices (pointer: coarse) - 44px min-height/width buttons only on touch devices - Hamburger menu only shows on touch devices - Sidebar overlay only on touch devices - Mobile layout stacking only on touch devices - Fixes flashing sidebar when resizing desktop browser - Fixes chat disappearing on small desktop viewports Desktop users can now resize browser windows without triggering mobile UI. Touch devices still get optimized mobile interface. _Generated with `cmux`_ --- src/App.tsx | 6 ++-- src/components/ChatInput.tsx | 7 ++-- src/components/LeftSidebar.tsx | 13 +++---- src/components/ProjectSidebar.tsx | 38 +++++++++++--------- src/styles/globals.css | 39 +++++++++++++++++++-- src/utils/ui/pathAbbreviation.test.ts | 50 ++++++++++++++++++++++++++- src/utils/ui/pathAbbreviation.ts | 20 +++++++++++ 7 files changed, 139 insertions(+), 34 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d7b1ea208..26fc5d5d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -618,7 +618,7 @@ function AppInner() { return ( <> -
+
-
-
+
+
{selectedWorkspace ? ( = ({ return (
@@ -850,14 +849,14 @@ export const ChatInput: React.FC = ({ {/* Thinking Slider - hide on small viewports */}
{/* Context 1M Checkbox - hide on smaller viewports */} -
+
{preferredModel && ( @@ -876,7 +875,7 @@ export const ChatInput: React.FC = ({
)} -
+
@@ -65,11 +65,8 @@ export function LeftSidebar(props: LeftSidebarProps) { "h-screen bg-separator border-r border-dark flex flex-col shrink-0", "transition-all duration-200 overflow-hidden relative z-[100]", collapsed ? "w-8" : "w-72", - "max-md:fixed max-md:left-0 max-md:top-0 max-md:w-72 max-md:z-[1000]", - "max-md:transition-transform max-md:duration-300", - collapsed - ? "max-md:-translate-x-full max-md:shadow-none" - : "max-md:translate-x-0 max-md:shadow-[2px_0_8px_rgba(0,0,0,0.5)]" + "mobile-sidebar", + collapsed && "mobile-sidebar-collapsed" )} > {!collapsed && } diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 17ada1e8e..43a8b708b 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -9,7 +9,7 @@ import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend"; import { useDrag, useDrop, useDragLayer } from "react-dnd"; import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering"; import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; -import { abbreviatePath } from "@/utils/ui/pathAbbreviation"; +import { abbreviatePath, splitAbbreviatedPath } from "@/utils/ui/pathAbbreviation"; import { partitionWorkspacesByAge, formatOldWorkspaceThreshold, @@ -78,7 +78,7 @@ const DraggableProjectItemBase: React.FC = ({
drag(drop(node))} className={cn( - "py-1 px-3 flex items-center border-l-transparent transition-all duration-150 bg-separator", + "py-2 px-3 flex items-center border-l-transparent transition-all duration-150 bg-separator", isDragging ? "cursor-grabbing opacity-40 [&_*]:!cursor-grabbing" : "cursor-grab", isOver && "bg-accent/[0.08]", selected && "bg-hover border-l-accent", @@ -130,21 +130,19 @@ const ProjectDragLayer: React.FC = () => { if (!isDragging || !currentOffset || !item?.projectPath) return null; - const name = item.projectPath.split("/").pop() ?? item.projectPath; const abbrevPath = abbreviatePath(item.projectPath); + const { dirPath, basename } = splitAbbreviatedPath(abbrevPath); return (
- +
-
- {name} -
-
- {abbrevPath} +
+ {dirPath} + {basename}
@@ -489,18 +487,26 @@ const ProjectSidebarInner: React.FC = ({ -
-
- {projectName} -
+
-
- {abbreviatePath(projectPath)} +
+ {(() => { + const abbrevPath = abbreviatePath(projectPath); + const { dirPath, basename } = splitAbbreviatedPath(abbrevPath); + return ( + <> + {dirPath} + + {basename} + + + ); + })()}
{projectPath} diff --git a/src/styles/globals.css b/src/styles/globals.css index c90651b0b..c598f6ead 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -256,8 +256,8 @@ body, overflow: hidden; } -/* Mobile improvements */ -@media (max-width: 768px) { +/* Mobile improvements - only apply to touch devices */ +@media (max-width: 768px) and (pointer: coarse) { html { -webkit-text-size-adjust: 100%; touch-action: manipulation; @@ -273,6 +273,41 @@ body, min-height: 44px; min-width: 44px; } + + /* Show mobile menu button only on touch devices */ + .mobile-menu-btn { + display: flex !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; + top: 0 !important; + width: 18rem !important; + z-index: 1000 !important; + transition: transform 0.3s !important; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.5) !important; + } + + .mobile-sidebar-collapsed { + transform: translateX(-100%) !important; + box-shadow: none !important; + } + + /* Mobile layout - stack vertically on touch devices */ + .mobile-layout { + flex-direction: column !important; + } + + .mobile-main-content { + width: 100% !important; + } } code { diff --git a/src/utils/ui/pathAbbreviation.test.ts b/src/utils/ui/pathAbbreviation.test.ts index 321dbbaff..188b31a4f 100644 --- a/src/utils/ui/pathAbbreviation.test.ts +++ b/src/utils/ui/pathAbbreviation.test.ts @@ -1,4 +1,4 @@ -import { abbreviatePath } from "./pathAbbreviation"; +import { abbreviatePath, splitAbbreviatedPath } from "./pathAbbreviation"; describe("abbreviatePath", () => { it("should abbreviate all directory components except the last one", () => { @@ -32,3 +32,51 @@ describe("abbreviatePath", () => { ); }); }); + +describe("splitAbbreviatedPath", () => { + it("should split abbreviated path into directory and basename", () => { + expect(splitAbbreviatedPath("/U/a/P/c/cmux")).toEqual({ + dirPath: "/U/a/P/c/", + basename: "cmux", + }); + }); + + it("should handle paths without leading slash", () => { + expect(splitAbbreviatedPath("U/a/P/c/cmux")).toEqual({ + dirPath: "U/a/P/c/", + basename: "cmux", + }); + }); + + it("should handle single directory paths", () => { + expect(splitAbbreviatedPath("/Users")).toEqual({ + dirPath: "/", + basename: "Users", + }); + expect(splitAbbreviatedPath("Users")).toEqual({ + dirPath: "", + basename: "Users", + }); + }); + + it("should handle root path", () => { + expect(splitAbbreviatedPath("/")).toEqual({ + dirPath: "/", + basename: "", + }); + }); + + it("should handle empty string", () => { + expect(splitAbbreviatedPath("")).toEqual({ + dirPath: "", + basename: "", + }); + }); + + it("should handle paths with long basenames", () => { + expect(splitAbbreviatedPath("/U/a/very-long-project-name")).toEqual({ + dirPath: "/U/a/", + basename: "very-long-project-name", + }); + }); +}); diff --git a/src/utils/ui/pathAbbreviation.ts b/src/utils/ui/pathAbbreviation.ts index a1ba11b4f..3f474abcf 100644 --- a/src/utils/ui/pathAbbreviation.ts +++ b/src/utils/ui/pathAbbreviation.ts @@ -31,3 +31,23 @@ export function abbreviatePath(path: string): string { return abbreviated.join("/"); } + +/** + * Split an abbreviated path into directory path and basename + * Example: /U/a/P/c/cmux -> { dirPath: "/U/a/P/c/", basename: "cmux" } + */ +export function splitAbbreviatedPath(path: string): { dirPath: string; basename: string } { + if (!path || typeof path !== "string") { + return { dirPath: "", basename: path }; + } + + const lastSlashIndex = path.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return { dirPath: "", basename: path }; + } + + return { + dirPath: path.slice(0, lastSlashIndex + 1), // Include the trailing slash + basename: path.slice(lastSlashIndex + 1), + }; +}