diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx index 2b91eca4..c5e4ca7b 100644 --- a/frontend/src/components/ProjectDetailView.tsx +++ b/frontend/src/components/ProjectDetailView.tsx @@ -15,7 +15,7 @@ import { import { useOpenSecret, type Conversation } from "@opensecret/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Sidebar, SidebarToggle } from "@/components/Sidebar"; -import { useIsMobile } from "@/utils/utils"; +import { useIsMobile, useIsLandscapeMobile } from "@/utils/utils"; import { useLocalState } from "@/state/useLocalState"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -146,10 +146,19 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { const userId = os.auth.user?.user.id; const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; const { setSelectedProjectId } = useLocalState(); const hasAuthUser = !!os.auth.user; - const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile); + const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); + + useEffect(() => { + if (isLandscapeMobile && isSidebarOpen) { + setIsSidebarOpen(false); + } + }, [isLandscapeMobile]); // eslint-disable-line react-hooks/exhaustive-deps + const [conversations, setConversations] = useState([]); const [hasMoreConversations, setHasMoreConversations] = useState(false); const [lastConversationId, setLastConversationId] = useState(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index fc768fb2..96b661cc 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,7 +14,7 @@ import { ChatHistoryList } from "./ChatHistoryList"; import { AccountMenu } from "./AccountMenu"; import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect, useState } from "react"; import { flushSync } from "react-dom"; -import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; +import { cn, useClickOutside, useIsMobile, useIsLandscapeMobile } from "@/utils/utils"; import { MapleWordmark } from "@/components/MapleWordmark"; import { Input } from "./ui/input"; import { useLocalState } from "@/state/useLocalState"; @@ -74,8 +74,8 @@ export function Sidebar({ }, [selectedIds.size]); async function addChat() { - // If sidebar is open on mobile, close it - if (isOpen && isMobile) { + // If sidebar is open on compact layout, close it + if (isOpen && isCompactLayout) { onToggle(); } @@ -136,14 +136,16 @@ export function Sidebar({ const sidebarRef = useRef(null); const historyContainerRef = useRef(null); - // Use the centralized hook for mobile detection + // Use the centralized hooks for mobile/compact detection const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; // Modified click outside handler to ignore clicks in dropdowns and dialogs // Only applies on mobile - desktop users use the toggle button const handleClickOutside = useCallback( (event: MouseEvent | TouchEvent) => { - if (isOpen && isMobile) { + if (isOpen && isCompactLayout) { // Check if the click was inside a dropdown or dialog const target = event.target as HTMLElement; const isInDropdown = target.closest('[role="menu"]'); @@ -155,7 +157,7 @@ export function Sidebar({ } } }, - [isOpen, onToggle, isMobile] + [isOpen, onToggle, isCompactLayout] ); useClickOutside(sidebarRef, handleClickOutside); @@ -172,8 +174,8 @@ export function Sidebar({ // This effect closes the sidebar on mobile when navigating, // but preserves search state between navigations useEffect(() => { - // Only subscribe if we're on mobile and sidebar is open - if (!isMobile || !isOpen) return; + // Only subscribe if we're on compact layout and sidebar is open + if (!isCompactLayout || !isOpen) return; const unsubscribe = router.subscribe("onResolved", () => { // Use a microtask to avoid state updates during render @@ -182,7 +184,7 @@ export function Sidebar({ if (!isMountedRef.current) return; // Double-check conditions after async boundary - if (isOpen && isMobile) { + if (isOpen && isCompactLayout) { onToggle(); } }); @@ -191,14 +193,14 @@ export function Sidebar({ return () => { unsubscribe(); }; - }, [router, isOpen, onToggle, isMobile]); + }, [router, isOpen, onToggle, isCompactLayout]); return (
@@ -314,7 +316,7 @@ export function Sidebar({ -
+
{message.content.map((part, partIdx) => { @@ -1381,10 +1381,10 @@ const MessageList = memo(
-
+
Maple
@@ -1418,9 +1418,9 @@ const MessageList = memo( {/* Loading indicator - only show while waiting for the first assistant item (TTFT) */} {shouldShowInitialAssistantLoader && ( -
+
-
+
Maple
@@ -1446,6 +1446,8 @@ MessageList.displayName = "MessageList"; export function UnifiedChat() { const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; const openai = useOpenAI(); const localState = useLocalState(); const { selectedProjectId, setSelectedProjectId } = localState; @@ -1466,7 +1468,7 @@ export function UnifiedChat() { const [input, setInput] = useState(""); const [draftProjectId, setDraftProjectId] = useState(() => selectedProjectId); const [isGenerating, setIsGenerating] = useState(false); - const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile); + const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); const [error, setError] = useState(null); const [lastSeenItemId, setLastSeenItemId] = useState(); const [isNewConversationJustCreated, setIsNewConversationJustCreated] = useState(false); @@ -1508,6 +1510,13 @@ export function UnifiedChat() { }); const [isFullscreenAnimating, setIsFullscreenAnimating] = useState(false); + // Close sidebar when rotating to landscape on a short screen + useEffect(() => { + if (isLandscapeMobile && isSidebarOpen) { + setIsSidebarOpen(false); + } + }, [isLandscapeMobile]); // eslint-disable-line react-hooks/exhaustive-deps + // Save fullscreen preference to localStorage when it changes useEffect(() => { localStorage.setItem("chatFullscreen", isFullscreen.toString()); @@ -1586,11 +1595,11 @@ export function UnifiedChat() { }; }, []); - // Auto-focus textbox on desktop (not mobile to avoid keyboard popup interrupting reading) + // Auto-focus textbox on desktop (not mobile/landscape-mobile to avoid keyboard popup interrupting reading) // Focus when: app launches, new chat, conversation loads, or assistant finishes streaming useEffect(() => { - // Skip on mobile to avoid keyboard popup - if (isMobile) return; + // Skip on compact layouts (mobile + landscape mobile) to avoid keyboard popup + if (isCompactLayout) return; // Focus when not generating and textbox is not disabled if (!isGenerating && textareaRef.current && !textareaRef.current.disabled) { @@ -1599,7 +1608,7 @@ export function UnifiedChat() { textareaRef.current?.focus(); }, 100); } - }, [isMobile, isGenerating, messages.length, chatId]); + }, [isCompactLayout, isGenerating, messages.length, chatId]); // Improved scroll detection - track if user is near bottom const handleScroll = useCallback(() => { @@ -3231,7 +3240,7 @@ export function UnifiedChat() { const handleKeyDown = (e: React.KeyboardEvent) => { // On desktop: Enter submits, Shift+Enter for new line // On mobile: Enter for new line, no keyboard shortcut to submit (use button) - if (e.key === "Enter" && !e.shiftKey && !isMobile) { + if (e.key === "Enter" && !e.shiftKey && !isCompactLayout) { e.preventDefault(); handleSendMessage(); } @@ -3280,8 +3289,8 @@ export function UnifiedChat() {
)} - {/* Sidebar toggle + wordmark — fixed except on mobile while chatting (two-row header below) */} - {!isSidebarOpen && !(isMobile && messages.length > 0) && ( + {/* Sidebar toggle + wordmark — fixed except on compact layouts while chatting (two-row header below) */} + {!isSidebarOpen && !(isCompactLayout && messages.length > 0) && (
0 && - (isMobile && !isSidebarOpen ? ( + (isLandscapeMobile && !isSidebarOpen ? ( +
+ +
+ +
+

+ {conversation?.metadata?.title || "Chat"} +

+ +
+ ) : isMobile && !isSidebarOpen ? (
@@ -3352,7 +3384,7 @@ export function UnifiedChat() { > {/* Only show messages when there are messages */} {messages.length > 0 && ( -
+
{/* Message list with modern ChatGPT/Claude style */}
- {!isFullscreen &&
} + {!isFullscreen &&
}
{!isFullscreen && ( -

+

Research anything...

)} @@ -3632,11 +3664,11 @@ export function UnifiedChat() { ) : ( // Fixed at bottom when there are messages
-
+
-
+
{(draftImages.length > 0 || documentName) && ( -
+
{draftImages.length > 0 && (
{draftImages.map((file, i) => ( @@ -3688,12 +3720,12 @@ export function UnifiedChat() { onKeyDown={handleKeyDown} placeholder="Message Maple..." disabled={isGenerating || isRecording} - className="w-full min-h-[52px] max-h-[200px] resize-none border-0 bg-transparent py-3.5 pl-4 pr-2 leading-6 focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60" + className="w-full min-h-[52px] landscape-short:min-h-[40px] max-h-[200px] landscape-short:max-h-[100px] resize-none border-0 bg-transparent py-3.5 landscape-short:py-2 pl-4 pr-2 leading-6 focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60" rows={1} id="message" /> -
+
-

+

AI can make mistakes. Check important info.

diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index bd208880..8a58c316 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -6,7 +6,7 @@ import { Markdown } from "@/components/markdown"; import { Sidebar, SidebarToggle } from "@/components/Sidebar"; import { Button } from "@/components/ui/button"; import { useNavigate } from "@tanstack/react-router"; -import { useIsMobile } from "@/utils/utils"; +import { useIsMobile, useIsLandscapeMobile } from "@/utils/utils"; import { useQuery } from "@tanstack/react-query"; import { SIDEBAR_GRID_COLUMNS_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout"; @@ -164,8 +164,18 @@ function ChatComponent() { const { getChatById } = useLocalState(); const navigate = useNavigate(); const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; const [showScrollButton, setShowScrollButton] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + // Close sidebar when rotating to landscape on a short screen + useEffect(() => { + if (isLandscapeMobile && isSidebarOpen) { + setIsSidebarOpen(false); + } + }, [isLandscapeMobile]); // eslint-disable-line react-hooks/exhaustive-deps + const chatContainerRef = useRef(null); // Fetch chat from KV store @@ -256,7 +266,7 @@ function ChatComponent() {
{!isSidebarOpen && ( -
+
)} @@ -265,7 +275,7 @@ function ChatComponent() { className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col relative" >
- {isMobile && ( + {isCompactLayout && (