From fa7371f80e7fe0e4755101f21e54c17db63facf9 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 02:10:10 +0000 Subject: [PATCH] feat(26-02): create MobileChatView and wire ChatPanel for responsive layout - MobileChatView: full-screen mobile chat using 100dvh, back button, safe-area input - ChatPanel: conditionally renders MobileChatView on mobile via useMediaQuery - ChatConversationList: wraps ScrollArea in PullToRefresh for mobile - ChatInput: pb-[env(safe-area-inset-bottom)] padding + 44px Send button touch target - ChatConversationItem: min-h-[48px] touch target per UI-SPEC --- ui/src/components/ChatConversationItem.tsx | 2 +- ui/src/components/ChatConversationList.tsx | 81 +++--- ui/src/components/ChatInput.tsx | 4 +- ui/src/components/ChatPanel.tsx | 8 + ui/src/components/MobileChatView.tsx | 283 +++++++++++++++++++++ 5 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 ui/src/components/MobileChatView.tsx diff --git a/ui/src/components/ChatConversationItem.tsx b/ui/src/components/ChatConversationItem.tsx index 7cfb8b72..c5e6e7d8 100644 --- a/ui/src/components/ChatConversationItem.tsx +++ b/ui/src/components/ChatConversationItem.tsx @@ -63,7 +63,7 @@ export function ChatConversationItem({ if (e.key === "Enter" || e.key === " ") onSelect(conversation.id); }} className={cn( - "group flex flex-col gap-0.5 rounded px-2 py-1.5 cursor-pointer relative", + "group flex flex-col gap-0.5 rounded px-2 py-1.5 cursor-pointer relative min-h-[48px] justify-center", isActive ? "bg-accent/60" : "hover:bg-accent", )} > diff --git a/ui/src/components/ChatConversationList.tsx b/ui/src/components/ChatConversationList.tsx index d6df810d..0b2b3624 100644 --- a/ui/src/components/ChatConversationList.tsx +++ b/ui/src/components/ChatConversationList.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { GitBranch, Plus, Search, X } from "lucide-react"; import { useChatConversations } from "../hooks/useChatConversations"; import { useChatPanel } from "../context/ChatPanelContext"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { PullToRefresh } from "./PullToRefresh"; import { ChatConversationItem } from "./ChatConversationItem"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; @@ -40,7 +42,8 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) { return () => window.removeEventListener("nexus:focus-chat-search", handler); }, []); - const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation } = + const isMobile = !useMediaQuery("(min-width: 768px)"); + const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation, refetch } = useChatConversations(companyId, { search: debouncedSearch || undefined }); const [deletingId, setDeletingId] = useState(null); @@ -153,45 +156,47 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) { - -
- {isLoading ? ( - // Loading skeletons - Array.from({ length: 5 }).map((_, i) => ( - - )) - ) : sorted.length === 0 ? ( -
-

No conversations yet

-

- Start a conversation to get help from your agents. -

-
- ) : ( - sorted.map((conversation) => ( -
- {conversation.parentConversationId && ( - - )} -
- -
+ { void refetch(); }} enabled={isMobile}> + +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: 5 }).map((_, i) => ( + + )) + ) : sorted.length === 0 ? ( +
+

No conversations yet

+

+ Start a conversation to get help from your agents. +

- )) - )} + ) : ( + sorted.map((conversation) => ( +
+ {conversation.parentConversationId && ( + + )} +
+ +
+
+ )) + )} - {/* Infinite scroll sentinel */} -
-
- + {/* Infinite scroll sentinel */} +
+
+ + {/* Delete confirmation dialog */} !open && setDeletingId(null)}> diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index 979bdd90..b5e1545d 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -176,7 +176,7 @@ export function ChatInput({ e.preventDefault(); submit(); }} - className="flex items-end gap-2" + className="flex items-end gap-2 pb-[env(safe-area-inset-bottom)]" > {/* Slash command takes priority over mention popover */}
@@ -255,7 +255,7 @@ export function ChatInput({ size="icon" disabled={isEmpty || isSubmitting || disabled} aria-label="Send message" - className="shrink-0" + className="shrink-0 min-h-[44px] min-w-[44px]" > {isSubmitting ? ( diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index 26b4ce88..427b42f6 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -12,6 +12,7 @@ import { ChatStopButton } from "./ChatStopButton"; import { ChatSearchDialog } from "./ChatSearchDialog"; import { ChatBranchSelector } from "./ChatBranchSelector"; import { ChatBookmarkList } from "./ChatBookmarkList"; +import { MobileChatView } from "./MobileChatView"; import { Button } from "@/components/ui/button"; import { chatApi } from "../api/chat"; import { agentsApi } from "../api/agents"; @@ -20,10 +21,12 @@ import { useStreamingChat } from "../hooks/useStreamingChat"; import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; import { useChatFileUpload } from "../hooks/useChatFileUpload"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { resolveAgentFromContent } from "../lib/slash-commands"; import type { AgentRole } from "@paperclipai/shared"; export function ChatPanel() { + const isDesktop = useMediaQuery("(min-width: 768px)"); const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel(); const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -246,6 +249,11 @@ export function ChatPanel() { startStream(lastUserContent, activeAgentId ?? undefined); }; + // On mobile, render the full-screen MobileChatView instead of the desktop slide-in panel + if (!isDesktop) { + return ; + } + return (