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:
parent
a8cbc090fd
commit
fa7371f80e
5 changed files with 337 additions and 41 deletions
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
283
ui/src/components/MobileChatView.tsx
Normal file
283
ui/src/components/MobileChatView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue