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
This commit is contained in:
Nexus Dev 2026-04-02 02:10:10 +00:00
parent a8cbc090fd
commit fa7371f80e
5 changed files with 337 additions and 41 deletions

View file

@ -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",
)}
>

View file

@ -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<string | null>(null);
@ -153,45 +156,47 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) {
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-1 space-y-0.5">
{isLoading ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded" />
))
) : sorted.length === 0 ? (
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground">No conversations yet</p>
<p className="text-xs text-muted-foreground mt-1">
Start a conversation to get help from your agents.
</p>
</div>
) : (
sorted.map((conversation) => (
<div key={conversation.id} className="relative">
{conversation.parentConversationId && (
<GitBranch className="absolute left-1 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10" />
)}
<div className={conversation.parentConversationId ? "pl-4" : undefined}>
<ChatConversationItem
conversation={conversation}
isActive={activeConversationId === conversation.id}
onSelect={setActiveConversationId}
onRename={handleRename}
onPin={handlePin}
onArchive={handleArchive}
onDelete={handleDeleteRequest}
/>
</div>
<PullToRefresh onRefresh={() => { void refetch(); }} enabled={isMobile}>
<ScrollArea className="flex-1">
<div className="p-1 space-y-0.5">
{isLoading ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded" />
))
) : sorted.length === 0 ? (
<div className="p-4 text-center">
<p className="text-xs text-muted-foreground">No conversations yet</p>
<p className="text-xs text-muted-foreground mt-1">
Start a conversation to get help from your agents.
</p>
</div>
))
)}
) : (
sorted.map((conversation) => (
<div key={conversation.id} className="relative">
{conversation.parentConversationId && (
<GitBranch className="absolute left-1 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none z-10" />
)}
<div className={conversation.parentConversationId ? "pl-4" : undefined}>
<ChatConversationItem
conversation={conversation}
isActive={activeConversationId === conversation.id}
onSelect={setActiveConversationId}
onRename={handleRename}
onPin={handlePin}
onArchive={handleArchive}
onDelete={handleDeleteRequest}
/>
</div>
</div>
))
)}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-1" />
</div>
</ScrollArea>
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-1" />
</div>
</ScrollArea>
</PullToRefresh>
{/* Delete confirmation dialog */}
<Dialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>

View file

@ -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 */}
<div className="flex-1 relative">
@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin" />

View file

@ -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 <MobileChatView />;
}
return (
<aside
aria-label="Chat"

View file

@ -0,0 +1,283 @@
import { useCallback, useMemo, useState } from "react";
import { ChevronLeft } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useChatPanel } from "../context/ChatPanelContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { useChatMessages } from "../hooks/useChatMessages";
import { useStreamingChat } from "../hooks/useStreamingChat";
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
import { useChatFileUpload } from "../hooks/useChatFileUpload";
import { useChatConversations } from "../hooks/useChatConversations";
import { useQuery } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
import { agentsApi } from "../api/agents";
import { resolveAgentFromContent } from "../lib/slash-commands";
import { ChatMessageList } from "./ChatMessageList";
import { ChatInput } from "./ChatInput";
import { ChatAgentSelector } from "./ChatAgentSelector";
import { ChatConversationList } from "./ChatConversationList";
import { ChatStopButton } from "./ChatStopButton";
import { PullToRefresh } from "./PullToRefresh";
import { Button } from "@/components/ui/button";
import type { AgentRole } from "@paperclipai/shared";
/**
* Full-screen mobile chat layout.
* - When no conversation is active: shows conversation list with pull-to-refresh
* - When a conversation is active: shows header + message list + sticky input
*
* Does NOT render any MobileNavBar relies on the global MobileBottomNav in Layout.tsx.
*/
export function MobileChatView() {
const { activeConversationId, setActiveConversationId } = useChatPanel();
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [isSending, setIsSending] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const { messages } = useChatMessages(activeConversationId);
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);
const brainstormerDefaultId = useBrainstormerDefault();
// useChatConversations for refetch (pull-to-refresh)
const { refetch } = useChatConversations(selectedCompanyId);
// Auto-select brainstormer for new conversations
const effectiveBrainstormerId =
activeAgentId === null && brainstormerDefaultId !== null && !activeConversationId
? brainstormerDefaultId
: activeAgentId;
// Load agents for routing and identity
const { data: agents = [], isLoading: agentsLoading } = useQuery({
queryKey: ["agents", selectedCompanyId],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Agent map for identity bars
const agentMap = useMemo(() => {
const map = new Map<string, { name: string; icon: string | null; role: AgentRole | null }>();
for (const a of agents) {
map.set(a.id, { name: a.name, icon: a.icon, role: (a.role as AgentRole) ?? null });
}
return map;
}, [agents]);
const streamingAgent = effectiveBrainstormerId
? agentMap.get(effectiveBrainstormerId)
: undefined;
// Bookmark state
const { data: bookmarkData } = useChatBookmarks(selectedCompanyId, activeConversationId ?? undefined);
const { toggleBookmark } = useToggleBookmark();
const bookmarkedMessageIds = useMemo(() => {
const ids = new Set<string>();
for (const b of bookmarkData?.items ?? []) ids.add(b.message.id);
return ids;
}, [bookmarkData]);
// Get active conversation title for header
const { data: conversationsData } = useQuery({
queryKey: ["chat", "conversations", selectedCompanyId, ""],
queryFn: () => chatApi.listConversations(selectedCompanyId!, {}),
enabled: !!selectedCompanyId && !!activeConversationId,
});
const activeConversation = conversationsData?.items.find((c) => c.id === activeConversationId);
const conversationTitle = activeConversation?.title ?? "New Conversation";
const handleHandoff = useCallback(
async (spec: { what: string; why: string; constraints: string; success: string }) => {
if (!activeConversationId) return;
try {
await chatApi.handoffSpec(activeConversationId, spec, "pm");
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
} catch {
pushToast({ title: "Could not send to PM. Try again.", tone: "error" });
}
},
[activeConversationId, queryClient, pushToast],
);
const handleSend = async (content: string) => {
if (!selectedCompanyId) return;
const resolvedAgentId = resolveAgentFromContent(content, agents, effectiveBrainstormerId);
const fileIdsToAttach = [...completedFileIds];
setIsSending(true);
try {
if (!activeConversationId) {
const newConvo = await chatApi.createConversation(selectedCompanyId, {
agentId: resolvedAgentId ?? undefined,
});
setActiveConversationId(newConvo.id);
const message = await chatApi.postMessage(newConvo.id, { role: "user", content });
if (fileIdsToAttach.length > 0) {
await chatApi.attachFilesToMessage(fileIdsToAttach, message.id);
clearCompleted();
queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] });
}
queryClient.invalidateQueries({ queryKey: ["chat"] });
} else {
const message = await chatApi.postMessage(activeConversationId, { role: "user", content });
if (fileIdsToAttach.length > 0) {
await chatApi.attachFilesToMessage(fileIdsToAttach, message.id);
clearCompleted();
}
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(content, resolvedAgentId ?? undefined);
}
} finally {
setIsSending(false);
}
};
const handleEdit = async (messageId: string, newContent: string) => {
if (!activeConversationId) return;
const editedIdx = messages.findIndex((m) => m.id === messageId);
const hasSubsequentMessages = editedIdx >= 0 && editedIdx < messages.length - 1;
if (hasSubsequentMessages) {
try {
const newConv = await chatApi.branchConversation(activeConversationId, messageId);
setActiveConversationId(newConv.id);
queryClient.invalidateQueries({ queryKey: ["chat", "branches"] });
queryClient.invalidateQueries({ queryKey: ["chat"] });
await chatApi.editMessage(newConv.id, messageId, newContent);
await chatApi.truncateMessagesAfter(newConv.id, messageId);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConv.id] });
startStream(newContent, effectiveBrainstormerId ?? undefined);
} catch {
pushToast({ title: "Could not create branch. Try again.", tone: "error" });
}
} else {
await chatApi.editMessage(activeConversationId, messageId, newContent);
await chatApi.truncateMessagesAfter(activeConversationId, messageId);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(newContent, effectiveBrainstormerId ?? undefined);
}
};
const handleRetry = async (assistantMessageId: string) => {
if (!activeConversationId || !messages) return;
const assistantIdx = messages.findIndex((m) => m.id === assistantMessageId);
if (assistantIdx < 0) return;
let lastUserContent = "";
let userMessageId = "";
for (let i = assistantIdx - 1; i >= 0; i--) {
if (messages[i]!.role === "user") {
lastUserContent = messages[i]!.content;
userMessageId = messages[i]!.id;
break;
}
}
if (!lastUserContent || !userMessageId) return;
await chatApi.truncateMessagesAfter(activeConversationId, userMessageId);
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
startStream(lastUserContent, effectiveBrainstormerId ?? undefined);
};
const handleBookmark = useCallback(
(messageId: string) => {
if (!activeConversationId) return;
toggleBookmark({ conversationId: activeConversationId, messageId });
},
[activeConversationId, toggleBookmark],
);
const handleRefresh = async () => {
await refetch();
};
// Conversation list view (no active conversation)
if (!activeConversationId) {
return (
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
{/* pb-16 accounts for the existing MobileBottomNav (h-16) at the bottom of Layout */}
<div className="flex-1 overflow-hidden pb-16">
{selectedCompanyId ? (
<PullToRefresh onRefresh={handleRefresh} enabled={true}>
<ChatConversationList companyId={selectedCompanyId} />
</PullToRefresh>
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-sm text-muted-foreground">No workspace selected</p>
</div>
)}
</div>
</div>
);
}
// Active conversation view
return (
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
{/* Header: 48px tall */}
<div className="flex h-12 flex-shrink-0 items-center gap-2 border-b border-border px-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setActiveConversationId(null)}
aria-label="Back to conversations"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<span className="flex-1 truncate text-sm font-medium">{conversationTitle}</span>
{selectedCompanyId && (
<ChatAgentSelector
companyId={selectedCompanyId}
conversationId={activeConversationId}
agentId={effectiveBrainstormerId}
onAgentChange={setActiveAgentId}
/>
)}
</div>
{/* Message list: fills remaining space */}
<div className="flex-1 overflow-hidden">
<ChatMessageList
conversationId={activeConversationId}
streamingContent={streamingContent}
isStreaming={isStreaming}
streamingAgentName={streamingAgent?.name ?? null}
streamingAgentIcon={streamingAgent?.icon ?? null}
streamingAgentRole={streamingAgent?.role ?? null}
onEdit={handleEdit}
onRetry={handleRetry}
onHandoff={handleHandoff}
agentMap={agentMap}
onBookmark={handleBookmark}
bookmarkedMessageIds={bookmarkedMessageIds}
/>
</div>
{/* Stop button (shown during streaming) */}
{isStreaming && <ChatStopButton onStop={stop} />}
{/* Sticky input bar with safe area inset */}
<div className="sticky bottom-0 border-t border-border bg-background pb-[env(safe-area-inset-bottom)]">
<div className="px-3 py-2">
<ChatInput
onSend={handleSend}
isSubmitting={isSending}
disabled={isStreaming}
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
agents={agents}
agentsLoading={agentsLoading}
pendingFiles={pendingFiles}
onRemoveFile={removeFile}
onFilesPicked={(files) => files.forEach(addFile)}
enableVoiceInput={true}
/>
</div>
</div>
</div>
);
}