diff --git a/src/app/folder/layout.tsx b/src/app/folder/layout.tsx index 515d2d8..86c8e5c 100644 --- a/src/app/folder/layout.tsx +++ b/src/app/folder/layout.tsx @@ -51,6 +51,8 @@ import { import type { AgentType } from "@/lib/types" import { cn } from "@/lib/utils" import { useFolderContext } from "@/contexts/folder-context" +import { useIsMobile } from "@/hooks/use-mobile" +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" function FolderDocumentTitle() { const { folder } = useFolderContext() @@ -246,6 +248,90 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { ) } +function MobileWorkspaceContent({ children }: { children: React.ReactNode }) { + const { mode } = useWorkspaceContext() + + // On mobile, fusion mode falls back to conversation view + const showConversation = mode === "conversation" || mode === "fusion" + + return ( +
+ {showConversation ? ( +
+ +
+ {children} +
+
+ ) : ( +
+ +
+ +
+
+ )} +
+ ) +} + +function MobileFolderWorkspaceShell({ + children, +}: { + children: React.ReactNode +}) { + const { isOpen: sidebarOpen, toggle: toggleSidebar } = useSidebarContext() + const { isOpen: auxOpen, toggle: toggleAux } = useAuxPanelContext() + const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() + + return ( +
+ {/* Sidebar Sheet (left) */} + + + Sidebar + + + + + {/* Main workspace */} +
+ {children} +
+ + {/* Aux panel Sheet (right) */} + + + Panel + + + + + {/* Terminal Sheet (bottom) */} + + + Terminal +
+ +
+
+
+
+ ) +} + function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { const { isOpen: sidebarOpen, @@ -661,6 +747,27 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ) } +function FolderLayoutShell({ children }: { children: React.ReactNode }) { + const isMobile = useIsMobile() + + return ( +
+ + {isMobile ? ( + {children} + ) : ( + {children} + )} + + +
+ ) +} + function FolderLayoutInner({ children }: { children: React.ReactNode }) { const searchParams = useSearchParams() const folderId = Number(searchParams.get("id") ?? "0") @@ -693,18 +800,7 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) { folderId={normalizedFolderId} > -
- - - {children} - - - -
+ {children}
diff --git a/src/app/globals.css b/src/app/globals.css index eab6a05..b9f0d81 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -167,6 +167,10 @@ } body { @apply bg-background text-foreground; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2ecacb6..e142808 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import "./globals.css" import { JetBrains_Mono } from "next/font/google" import { NextIntlClientProvider } from "next-intl" @@ -13,6 +13,12 @@ const jetbrainsMono = JetBrains_Mono({ variable: "--font-sans", }) +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", +} + export const metadata: Metadata = { title: "codeg", description: "AI Coding Agent Conversation Manager", diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 92cc87f..2ca9423 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -69,7 +69,7 @@ export default function LoginPage() { onChange={(e) => setToken(e.target.value)} placeholder="Access Token" autoFocus - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 72e7314..4fe035b 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1493,7 +1493,7 @@ export function MessageInput({ onPaste={handlePaste} onFocus={onFocus} placeholder={resolvedPlaceholder} - className="min-h-0 flex-1 overflow-y-auto rounded-none border-0 bg-transparent text-sm shadow-none focus-visible:border-0 focus-visible:ring-0" + className="min-h-0 flex-1 overflow-y-auto rounded-none border-0 bg-transparent text-base md:text-sm shadow-none focus-visible:border-0 focus-visible:ring-0" autoFocus={autoFocus} />
diff --git a/src/components/layout/app-title-bar.tsx b/src/components/layout/app-title-bar.tsx index 094903a..edf53b5 100644 --- a/src/components/layout/app-title-bar.tsx +++ b/src/components/layout/app-title-bar.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react" import { usePlatform } from "@/hooks/use-platform" +import { useIsMobile } from "@/hooks/use-mobile" import { cn } from "@/lib/utils" import { WindowControls } from "./window-controls" @@ -25,6 +26,7 @@ export function AppTitleBar({ showWindowControls = true, }: AppTitleBarProps) { const { isMac, isWindows } = usePlatform() + const isMobile = useIsMobile() const isDesktopRuntime = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window const hasDesktopWindowChrome = showWindowControls && isDesktopRuntime @@ -38,7 +40,8 @@ export function AppTitleBar({ return (
diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index d523f02..e33f30c 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -9,7 +9,9 @@ import { } from "react" import { Columns2, + EllipsisVertical, FileCode2, + Menu, MessageSquare, PanelLeft, PanelRight, @@ -40,6 +42,13 @@ import { CommandDropdown } from "./command-dropdown" import { SearchCommandDialog } from "@/components/conversations/search-command-dialog" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { cn } from "@/lib/utils" +import { useIsMobile } from "@/hooks/use-mobile" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" const MODE_TABS = [ { @@ -196,6 +205,7 @@ export function FolderTitleBar() { setBranch(null) } }, [folderPath]) + const isMobile = useIsMobile() const modeContainerRef = useRef(null) const modeItemRefs = useRef>(new Map()) const [modeIndicator, setModeIndicator] = useState<{ @@ -243,168 +253,234 @@ export function FolderTitleBar() { [setMode] ) + const modeTabsElement = ( +
+ {modeIndicator && ( +
+ )} + {MODE_TABS.map((item) => { + const Icon = item.icon + const isActive = mode === item.mode + const title = tModes(item.titleKey) + return ( +
{ + if (el) { + modeItemRefs.current.set(item.mode, el) + } else { + modeItemRefs.current.delete(item.mode) + } + }} + role="tab" + tabIndex={0} + className={cn( + "relative z-10 m-0 flex h-[23px] cursor-pointer select-none items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 align-middle text-xs font-medium leading-none transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60", + isActive ? "px-2.5" : "px-2", + isActive + ? "text-foreground" + : "text-muted-foreground hover:text-foreground/70" + )} + onClick={() => setMode(item.mode)} + onKeyDown={(event) => handleModeKeyDown(event, item.mode)} + onMouseDown={(event) => event.preventDefault()} + title={!isActive ? title : undefined} + aria-label={title} + aria-selected={isActive} + > + + {/* Hide text labels on mobile to save space */} + {!isMobile && ( + + + {title} + + + )} +
+ ) + })} +
+ ) + return ( <> - - -
-
- } - center={ -
- {modeIndicator && ( -
- )} - {MODE_TABS.map((item) => { - const Icon = item.icon - const isActive = mode === item.mode - const title = tModes(item.titleKey) - return ( -
{ - if (el) { - modeItemRefs.current.set(item.mode, el) - } else { - modeItemRefs.current.delete(item.mode) - } - }} - role="tab" - tabIndex={0} - className={cn( - "relative z-10 m-0 flex h-[23px] cursor-pointer select-none items-center justify-center gap-1 rounded-full border-0 bg-transparent p-0 align-middle text-xs font-medium leading-none transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60", - isActive ? "px-2.5" : "px-2", - isActive - ? "text-foreground" - : "text-muted-foreground hover:text-foreground/70" - )} - onClick={() => setMode(item.mode)} - onKeyDown={(event) => handleModeKeyDown(event, item.mode)} - onMouseDown={(event) => event.preventDefault()} - title={!isActive ? title : undefined} - aria-label={title} - aria-selected={isActive} - > - - - - {title} - - -
- ) - })} -
- } - right={ -
-
- -
-
+ isMobile ? ( +
- - - - + +
-
+ ) : ( +
+ + +
+
+ ) + } + center={isMobile ? undefined : modeTabsElement} + right={ + isMobile ? ( +
+ + + + + + + + + + {tTitleBar("toggleAuxPanel")} + + toggleTerminal()}> + + {tTitleBar("toggleTerminal")} + + + + {tTitleBar("openSettings")} + + + +
+ ) : ( +
+
+ +
+
+ + + + + +
+
+ ) } /> diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 1479fcb..5eb4fa1 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -11,12 +11,14 @@ import { type SidebarConversationListHandle, } from "@/components/conversations/sidebar-conversation-list" import { Button } from "@/components/ui/button" +import { useIsMobile } from "@/hooks/use-mobile" export function Sidebar() { const t = useTranslations("Folder.sidebar") const { folder } = useFolderContext() const { openNewConversationTab } = useTabContext() - const { isOpen } = useSidebarContext() + const { isOpen, toggle } = useSidebarContext() + const isMobile = useIsMobile() const listRef = useRef(null) const handleNewConversation = useCallback(() => { @@ -70,7 +72,22 @@ export function Sidebar() {
- + {/* On mobile, clicking a conversation card auto-closes the Sheet */} +
{ + const target = e.target as HTMLElement + if (target.closest("[data-conversation-id]")) { + toggle() + } + } + : undefined + } + > + +
) } diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 6b97b66..363981f 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -6,8 +6,23 @@ import { StatusBarTasks } from "@/components/layout/status-bar-tasks" import { StatusBarTokens } from "@/components/layout/status-bar-tokens" import { StatusBarConnection } from "@/components/layout/status-bar-connection" import { StatusBarAlerts } from "@/components/layout/status-bar-alerts" +import { useIsMobile } from "@/hooks/use-mobile" export function StatusBar() { + const isMobile = useIsMobile() + + if (isMobile) { + return ( +
+ +
+ + +
+
+ ) + } + return (
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 08b9b62..bf2cfd0 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, + useState, type ComponentType, type ReactNode, } from "react" @@ -12,6 +13,7 @@ import { GitBranch, Globe, Keyboard, + Menu, SendHorizontal, Palette, PlugZap, @@ -26,6 +28,8 @@ import { AppToaster } from "@/components/ui/app-toaster" import { cn } from "@/lib/utils" import { detectEnvironment } from "@/lib/transport/detect" import { AppTitleBar } from "@/components/layout/app-title-bar" +import { useIsMobile } from "@/hooks/use-mobile" +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" interface SettingsNavItem { href: string @@ -118,6 +122,8 @@ export function SettingsShell({ children }: SettingsShellProps) { const pathname = usePathname() const router = useRouter() const normalizedPathname = normalizePath(pathname) + const isMobile = useIsMobile() + const [navOpen, setNavOpen] = useState(false) useEffect(() => { document.title = `${t("title")} - codeg` @@ -129,67 +135,108 @@ export function SettingsShell({ children }: SettingsShellProps) { const target = normalizePath(href) const current = normalizePath(window.location.pathname) - if (current === target) return + if (current === target) { + setNavOpen(false) + return + } if (isWindowsRuntime()) { - // WebView2 on Windows: hard navigation is more reliable than client routing. window.location.assign(target) return } - // macOS/Linux: keep client-side routing for snappier transitions. router.push(target) + setNavOpen(false) }, - [router] + [router, setNavOpen] + ) + + const filteredNavItems = SETTINGS_NAV_ITEMS.filter( + (item) => + !(item.labelKey === "web_service" && detectEnvironment() === "web") + ) + + const navContent = ( + <> +
+ {t("preferences")} +
+ + ) return (
setNavOpen(true)} + > + + + ) : undefined + } center={
{t("title")}
} />
- + {/* Desktop sidebar */} + {!isMobile && ( + + )} -
{children}
+ {/* Mobile navigation Sheet */} + {isMobile && ( + + + {t("title")} + {navContent} + + + )} + +
+ {children} +
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 10087cf..652d5d3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -15,9 +15,9 @@ const buttonVariants = cva( secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: - "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index b52b7d4..611a140 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -34,7 +34,7 @@ function CommandDialog({ }) { return ( - + {title} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..ee50fca --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/welcome/welcome-screen.tsx b/src/components/welcome/welcome-screen.tsx index bf9ed23..5ae1470 100644 --- a/src/components/welcome/welcome-screen.tsx +++ b/src/components/welcome/welcome-screen.tsx @@ -15,11 +15,13 @@ import { Button } from "@/components/ui/button" import { AppToaster } from "@/components/ui/app-toaster" import { resolveWelcomeError } from "@/components/welcome/error-utils" import { AppTitleBar } from "@/components/layout/app-title-bar" +import { useIsMobile } from "@/hooks/use-mobile" export function WelcomeScreen() { const t = useTranslations("WelcomePage") const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) + const isMobile = useIsMobile() const { shortcuts } = useShortcutSettings() const handleOpenSettings = useCallback(() => { @@ -83,8 +85,20 @@ export function WelcomeScreen() { } /> -
-
+
+
diff --git a/src/contexts/sidebar-context.tsx b/src/contexts/sidebar-context.tsx index ec2efbe..2d76bfc 100644 --- a/src/contexts/sidebar-context.tsx +++ b/src/contexts/sidebar-context.tsx @@ -64,9 +64,12 @@ export function SidebarProvider({ children, folderId }: SidebarProviderProps) { useEffect(() => { const stored = loadPersistedPanelState(storageKey) + // On mobile (< 768px), always start closed regardless of persisted state. + const isMobileViewport = window.innerWidth < 768 + const defaultOpen = isMobileViewport ? false : DEFAULT_IS_OPEN // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. // eslint-disable-next-line react-hooks/set-state-in-effect - setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN) + setIsOpen(isMobileViewport ? false : (stored?.isOpen ?? defaultOpen)) setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH)) setRestored(true) }, [storageKey]) diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts new file mode 100644 index 0000000..0fde6d4 --- /dev/null +++ b/src/hooks/use-media-query.ts @@ -0,0 +1,15 @@ +"use client" + +import { useSyncExternalStore } from "react" + +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (callback) => { + const mql = window.matchMedia(query) + mql.addEventListener("change", callback) + return () => mql.removeEventListener("change", callback) + }, + () => window.matchMedia(query).matches, + () => false + ) +} diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts new file mode 100644 index 0000000..ed459cc --- /dev/null +++ b/src/hooks/use-mobile.ts @@ -0,0 +1,9 @@ +"use client" + +import { useMediaQuery } from "./use-media-query" + +const MOBILE_BREAKPOINT = "(max-width: 767px)" + +export function useIsMobile(): boolean { + return useMediaQuery(MOBILE_BREAKPOINT) +}