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
512a4aa448
commit
6b7e54bbf9
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);
|
if (e.key === "Enter" || e.key === " ") onSelect(conversation.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
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 { GitBranch, Plus, Search, X } from "lucide-react";
|
||||||
import { useChatConversations } from "../hooks/useChatConversations";
|
import { useChatConversations } from "../hooks/useChatConversations";
|
||||||
import { useChatPanel } from "../context/ChatPanelContext";
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||||
|
import { PullToRefresh } from "./PullToRefresh";
|
||||||
import { ChatConversationItem } from "./ChatConversationItem";
|
import { ChatConversationItem } from "./ChatConversationItem";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
@ -40,7 +42,8 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) {
|
||||||
return () => window.removeEventListener("nexus:focus-chat-search", handler);
|
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 });
|
useChatConversations(companyId, { search: debouncedSearch || undefined });
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
@ -153,45 +156,47 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<PullToRefresh onRefresh={() => { void refetch(); }} enabled={isMobile}>
|
||||||
<div className="p-1 space-y-0.5">
|
<ScrollArea className="flex-1">
|
||||||
{isLoading ? (
|
<div className="p-1 space-y-0.5">
|
||||||
// Loading skeletons
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
// Loading skeletons
|
||||||
<Skeleton key={i} className="h-10 w-full rounded" />
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
))
|
<Skeleton key={i} className="h-10 w-full rounded" />
|
||||||
) : sorted.length === 0 ? (
|
))
|
||||||
<div className="p-4 text-center">
|
) : sorted.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No conversations yet</p>
|
<div className="p-4 text-center">
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground">No conversations yet</p>
|
||||||
Start a conversation to get help from your agents.
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</p>
|
Start a conversation to get help from your agents.
|
||||||
</div>
|
</p>
|
||||||
) : (
|
|
||||||
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>
|
</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 */}
|
{/* Infinite scroll sentinel */}
|
||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</PullToRefresh>
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
<Dialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
|
<Dialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ export function ChatInput({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
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 */}
|
{/* Slash command takes priority over mention popover */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
|
@ -255,7 +255,7 @@ export function ChatInput({
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={isEmpty || isSubmitting || disabled}
|
disabled={isEmpty || isSubmitting || disabled}
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
className="shrink-0"
|
className="shrink-0 min-h-[44px] min-w-[44px]"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { ChatStopButton } from "./ChatStopButton";
|
||||||
import { ChatSearchDialog } from "./ChatSearchDialog";
|
import { ChatSearchDialog } from "./ChatSearchDialog";
|
||||||
import { ChatBranchSelector } from "./ChatBranchSelector";
|
import { ChatBranchSelector } from "./ChatBranchSelector";
|
||||||
import { ChatBookmarkList } from "./ChatBookmarkList";
|
import { ChatBookmarkList } from "./ChatBookmarkList";
|
||||||
|
import { MobileChatView } from "./MobileChatView";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chatApi } from "../api/chat";
|
import { chatApi } from "../api/chat";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -20,10 +21,12 @@ import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||||
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
||||||
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
||||||
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
||||||
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||||
import { resolveAgentFromContent } from "../lib/slash-commands";
|
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||||
import type { AgentRole } from "@paperclipai/shared";
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel();
|
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId, setScrollToMessageId } = useChatPanel();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -246,6 +249,11 @@ export function ChatPanel() {
|
||||||
startStream(lastUserContent, activeAgentId ?? undefined);
|
startStream(lastUserContent, activeAgentId ?? undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// On mobile, render the full-screen MobileChatView instead of the desktop slide-in panel
|
||||||
|
if (!isDesktop) {
|
||||||
|
return <MobileChatView />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Chat"
|
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