feat(21-03): add ChatPanel, ChatConversationList, ChatMessageList, and Layout integration
- Create ChatConversationList with infinite scroll (IntersectionObserver), inline rename/delete confirmation, pin/archive/unarchive actions via DropdownMenu - Create ChatMessageList with role=log, aria-live, ChatMarkdownMessage for assistant messages, auto-scroll to bottom - Create ChatPanel right-side drawer shell composing conversation list and message area with width transition (chatOpen ? 380 : 0) - Integrate ChatPanel and MessageSquare toggle button into Layout.tsx - Add effect to close PropertiesPanel when chat opens
This commit is contained in:
parent
2a0724839b
commit
7868b0739b
4 changed files with 537 additions and 2 deletions
322
ui/src/components/ChatConversationList.tsx
Normal file
322
ui/src/components/ChatConversationList.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MoreHorizontal, Pin, Plus, X } from "lucide-react";
|
||||
import { useChatConversations, useConversationActions } from "../hooks/useChatConversations";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { ChatConversation } from "@paperclipai/shared";
|
||||
|
||||
interface ChatConversationListProps {
|
||||
companyId: string;
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} else if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: ChatConversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
actions: ReturnType<typeof useConversationActions>;
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation, isActive, onSelect, actions }: ConversationItemProps) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(conversation.title ?? "");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const title = conversation.title ?? "New conversation";
|
||||
const isPinned = conversation.pinnedAt !== null;
|
||||
const isArchived = conversation.archivedAt !== null;
|
||||
|
||||
const handleRenameStart = useCallback(() => {
|
||||
setRenameValue(conversation.title ?? "");
|
||||
setIsRenaming(true);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
}, [conversation.title]);
|
||||
|
||||
const handleRenameConfirm = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== conversation.title) {
|
||||
actions.rename.mutate({ id: conversation.id, title: trimmed });
|
||||
}
|
||||
setIsRenaming(false);
|
||||
}, [renameValue, conversation.id, conversation.title, actions.rename]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameConfirm();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setIsRenaming(false);
|
||||
}
|
||||
},
|
||||
[handleRenameConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center px-3 py-3 cursor-pointer min-h-[48px]",
|
||||
"hover:bg-sidebar-accent/50 transition-colors",
|
||||
isActive && "border-l-2 border-primary bg-sidebar-accent",
|
||||
)}
|
||||
onClick={() => !isRenaming && onSelect(conversation.id)}
|
||||
onDoubleClick={() => !isRenaming && handleRenameStart()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isRenaming) onSelect(conversation.id);
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isPinned && <Pin className="h-3 w-3 text-primary shrink-0" fill="currentColor" />}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleRenameConfirm}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
className="flex-1 min-w-0 text-[13px] bg-transparent border-b border-primary outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate text-[13px]">{title}</span>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">archived</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(conversation.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isRenaming && (
|
||||
<div className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ml-1">
|
||||
{confirmDelete ? (
|
||||
<div
|
||||
className="flex items-center gap-1 bg-popover border border-border rounded-md px-2 py-1 shadow-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Delete this conversation?
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.remove.mutate(conversation.id);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
Delete conversation
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
Keep conversation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Conversation actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleRenameStart()}
|
||||
>
|
||||
Rename conversation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
isPinned
|
||||
? actions.unpin.mutate(conversation.id)
|
||||
: actions.pin.mutate(conversation.id)
|
||||
}
|
||||
>
|
||||
{isPinned ? "Unpin conversation" : "Pin conversation"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
isArchived
|
||||
? actions.unarchive.mutate(conversation.id)
|
||||
: actions.archive.mutate(conversation.id)
|
||||
}
|
||||
>
|
||||
{isArchived ? "Unarchive conversation" : "Archive conversation"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete conversation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatConversationList({
|
||||
companyId,
|
||||
activeId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onClose,
|
||||
}: ChatConversationListProps) {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useChatConversations(companyId);
|
||||
const actions = useConversationActions();
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allConversations = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<nav aria-label="Conversations" className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border h-12 shrink-0">
|
||||
<span className="text-base font-semibold">Chat</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New conversation</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onClose}
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close chat</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div aria-busy="true" className="flex flex-col gap-1 p-2">
|
||||
<Skeleton className="h-12 mx-0 my-1" />
|
||||
<Skeleton className="h-12 mx-0 my-1" />
|
||||
<Skeleton className="h-12 mx-0 my-1" />
|
||||
</div>
|
||||
) : allConversations.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-4 text-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">No conversations yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start a conversation to get help with your work.
|
||||
</p>
|
||||
<Button size="sm" onClick={onNew}>
|
||||
New conversation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div role="list">
|
||||
{allConversations.map((conversation) => (
|
||||
<div key={conversation.id} role="listitem">
|
||||
<ConversationItem
|
||||
conversation={conversation}
|
||||
isActive={conversation.id === activeId}
|
||||
onSelect={onSelect}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<Skeleton className="h-12 mx-3 my-1" />
|
||||
<Skeleton className="h-12 mx-3 my-1" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
81
ui/src/components/ChatMessageList.tsx
Normal file
81
ui/src/components/ChatMessageList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useChatMessages } from "../hooks/useChatMessages";
|
||||
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
const { data, isLoading } = useChatMessages(conversationId);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [allMessages.length]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a message to start the conversation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Conversation messages"
|
||||
className="p-4 gap-4 flex flex-col overflow-y-auto flex-1"
|
||||
>
|
||||
{allMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"group flex flex-col gap-1",
|
||||
msg.role === "user" ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm",
|
||||
msg.role === "user"
|
||||
? "ml-auto bg-secondary text-secondary-foreground max-w-[75%]"
|
||||
: "max-w-[85%]",
|
||||
)}
|
||||
>
|
||||
{msg.role === "user" ? (
|
||||
<span>{msg.content}</span>
|
||||
) : (
|
||||
<ChatMarkdownMessage content={msg.content} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
ui/src/components/ChatPanel.tsx
Normal file
106
ui/src/components/ChatPanel.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useCallback, useEffect } from "react";
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useCreateConversation } from "../hooks/useChatConversations";
|
||||
import { useSendMessage } from "../hooks/useChatMessages";
|
||||
import { ChatConversationList } from "./ChatConversationList";
|
||||
import { ChatMessageList } from "./ChatMessageList";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
|
||||
export function ChatPanel() {
|
||||
const { chatOpen, setChatOpen, activeConversationId, setActiveConversationId } = useChatPanel();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const createConversation = useCreateConversation(selectedCompanyId);
|
||||
const sendMessage = useSendMessage(activeConversationId);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
// Small delay to allow render to settle
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('[aria-label="Message input"]');
|
||||
textarea?.focus();
|
||||
}, 50);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatOpen) {
|
||||
focusInput();
|
||||
}
|
||||
}, [chatOpen, focusInput]);
|
||||
|
||||
const handleNew = useCallback(async () => {
|
||||
if (!selectedCompanyId) return;
|
||||
try {
|
||||
const conversation = await createConversation.mutateAsync(undefined);
|
||||
setActiveConversationId(conversation.id);
|
||||
focusInput();
|
||||
} catch {
|
||||
// Ignore errors here — handled by mutation state
|
||||
}
|
||||
}, [selectedCompanyId, createConversation, setActiveConversationId, focusInput]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeConversationId) {
|
||||
// Create conversation first if none selected
|
||||
if (!selectedCompanyId) return;
|
||||
try {
|
||||
const conversation = await createConversation.mutateAsync(undefined);
|
||||
setActiveConversationId(conversation.id);
|
||||
// Send after creating
|
||||
await sendMessage.mutateAsync(content);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
await sendMessage.mutateAsync(content);
|
||||
}
|
||||
},
|
||||
[activeConversationId, selectedCompanyId, createConversation, setActiveConversationId, sendMessage],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setChatOpen(false);
|
||||
}, [setChatOpen]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Chat"
|
||||
className="flex-shrink-0 border-l border-border overflow-hidden transition-[width] duration-100 ease-out flex flex-col"
|
||||
style={{ width: chatOpen ? 380 : 0 }}
|
||||
>
|
||||
{chatOpen && selectedCompanyId && (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden" style={{ width: 380 }}>
|
||||
{/* Conversation list sidebar */}
|
||||
<div className="flex flex-col shrink-0 bg-sidebar border-r border-border overflow-hidden" style={{ width: 240 }}>
|
||||
<ChatConversationList
|
||||
companyId={selectedCompanyId}
|
||||
activeId={activeConversationId}
|
||||
onSelect={setActiveConversationId}
|
||||
onNew={handleNew}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message area */}
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{activeConversationId ? (
|
||||
<ChatMessageList conversationId={activeConversationId} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a conversation or start a new one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onClose={handleClose}
|
||||
isSubmitting={sendMessage.isPending || createConversation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||
import { BookOpen, MessageSquare, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
|
|
@ -18,6 +19,7 @@ import { WorktreeBanner } from "./WorktreeBanner";
|
|||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||
|
|
@ -49,7 +51,8 @@ function readRememberedInstanceSettingsPath(): string {
|
|||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const { togglePanelVisible, setPanelVisible } = usePanel();
|
||||
const { chatOpen, toggleChat } = useChatPanel();
|
||||
const {
|
||||
companies,
|
||||
loading: companiesLoading,
|
||||
|
|
@ -144,6 +147,13 @@ export function Layout() {
|
|||
|
||||
const togglePanel = togglePanelVisible;
|
||||
|
||||
// Close PropertiesPanel when chat opens to avoid competing for space
|
||||
useEffect(() => {
|
||||
if (chatOpen) {
|
||||
setPanelVisible(false);
|
||||
}
|
||||
}, [chatOpen, setPanelVisible]);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
|
|
@ -400,6 +410,21 @@ export function Layout() {
|
|||
>
|
||||
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleChat}
|
||||
aria-label={chatOpen ? "Close chat" : "Open chat"}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -432,6 +457,7 @@ export function Layout() {
|
|||
)}
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue