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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { CompanyRail } from "./CompanyRail";
|
import { CompanyRail } from "./CompanyRail";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { InstanceSidebar } from "./InstanceSidebar";
|
import { InstanceSidebar } from "./InstanceSidebar";
|
||||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||||
import { PropertiesPanel } from "./PropertiesPanel";
|
import { PropertiesPanel } from "./PropertiesPanel";
|
||||||
|
import { ChatPanel } from "./ChatPanel";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { NewIssueDialog } from "./NewIssueDialog";
|
import { NewIssueDialog } from "./NewIssueDialog";
|
||||||
import { NewProjectDialog } from "./NewProjectDialog";
|
import { NewProjectDialog } from "./NewProjectDialog";
|
||||||
|
|
@ -18,6 +19,7 @@ import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useTheme, THEME_META } from "../context/ThemeContext";
|
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||||
|
|
@ -49,7 +51,8 @@ function readRememberedInstanceSettingsPath(): string {
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialog();
|
||||||
const { togglePanelVisible } = usePanel();
|
const { togglePanelVisible, setPanelVisible } = usePanel();
|
||||||
|
const { chatOpen, toggleChat } = useChatPanel();
|
||||||
const {
|
const {
|
||||||
companies,
|
companies,
|
||||||
loading: companiesLoading,
|
loading: companiesLoading,
|
||||||
|
|
@ -144,6 +147,13 @@ export function Layout() {
|
||||||
|
|
||||||
const togglePanel = togglePanelVisible;
|
const togglePanel = togglePanelVisible;
|
||||||
|
|
||||||
|
// Close PropertiesPanel when chat opens to avoid competing for space
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatOpen) {
|
||||||
|
setPanelVisible(false);
|
||||||
|
}
|
||||||
|
}, [chatOpen, setPanelVisible]);
|
||||||
|
|
||||||
useCompanyPageMemory();
|
useCompanyPageMemory();
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
|
|
@ -400,6 +410,21 @@ export function Layout() {
|
||||||
>
|
>
|
||||||
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -432,6 +457,7 @@ export function Layout() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
<PropertiesPanel />
|
<PropertiesPanel />
|
||||||
|
<ChatPanel />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue