mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||
|
|
import { useToast } from "@/components/hooks/use-toast";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||
|
|
import { ResizablePanel } from "@/components/ui/resizable";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||
|
|
import { deleteChat, updateChatName } from "@/features/chat/actions";
|
||
|
|
import { useDomain } from "@/hooks/useDomain";
|
||
|
|
import { cn, isServiceError } from "@/lib/utils";
|
||
|
|
import { CirclePlusIcon, EllipsisIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { useCallback, useRef, useState } from "react";
|
||
|
|
import { useHotkeys } from "react-hotkeys-hook";
|
||
|
|
import {
|
||
|
|
GoSidebarCollapse as ExpandIcon,
|
||
|
|
} from "react-icons/go";
|
||
|
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||
|
|
import { useChatId } from "../useChatId";
|
||
|
|
import { RenameChatDialog } from "./renameChatDialog";
|
||
|
|
import { DeleteChatDialog } from "./deleteChatDialog";
|
||
|
|
import Link from "next/link";
|
||
|
|
|
||
|
|
interface ChatSidePanelProps {
|
||
|
|
order: number;
|
||
|
|
chatHistory: {
|
||
|
|
id: string;
|
||
|
|
name: string | null;
|
||
|
|
createdAt: Date;
|
||
|
|
}[];
|
||
|
|
isAuthenticated: boolean;
|
||
|
|
isCollapsedInitially: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const ChatSidePanel = ({
|
||
|
|
order,
|
||
|
|
chatHistory,
|
||
|
|
isAuthenticated,
|
||
|
|
isCollapsedInitially,
|
||
|
|
}: ChatSidePanelProps) => {
|
||
|
|
const domain = useDomain();
|
||
|
|
const [isCollapsed, setIsCollapsed] = useState(isCollapsedInitially);
|
||
|
|
const sidePanelRef = useRef<ImperativePanelHandle>(null);
|
||
|
|
const router = useRouter();
|
||
|
|
const { toast } = useToast();
|
||
|
|
const chatId = useChatId();
|
||
|
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||
|
|
const [chatIdToRename, setChatIdToRename] = useState<string | null>(null);
|
||
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||
|
|
const [chatIdToDelete, setChatIdToDelete] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useHotkeys("mod+b", () => {
|
||
|
|
if (isCollapsed) {
|
||
|
|
sidePanelRef.current?.expand();
|
||
|
|
} else {
|
||
|
|
sidePanelRef.current?.collapse();
|
||
|
|
}
|
||
|
|
}, {
|
||
|
|
enableOnFormTags: true,
|
||
|
|
enableOnContentEditable: true,
|
||
|
|
description: "Toggle side panel",
|
||
|
|
});
|
||
|
|
|
||
|
|
const onRenameChat = useCallback(async (name: string, chatId: string) => {
|
||
|
|
if (!chatId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await updateChatName({
|
||
|
|
chatId,
|
||
|
|
name: name,
|
||
|
|
}, domain);
|
||
|
|
|
||
|
|
if (isServiceError(response)) {
|
||
|
|
toast({
|
||
|
|
description: `❌ Failed to rename chat. Reason: ${response.message}`
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
toast({
|
||
|
|
description: `✅ Chat renamed successfully`
|
||
|
|
});
|
||
|
|
router.refresh();
|
||
|
|
}
|
||
|
|
}, [router, toast, domain]);
|
||
|
|
|
||
|
|
const onDeleteChat = useCallback(async (chatIdToDelete: string) => {
|
||
|
|
if (!chatIdToDelete) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await deleteChat({ chatId: chatIdToDelete }, domain);
|
||
|
|
|
||
|
|
if (isServiceError(response)) {
|
||
|
|
toast({
|
||
|
|
description: `❌ Failed to delete chat. Reason: ${response.message}`
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
toast({
|
||
|
|
description: `✅ Chat deleted successfully`
|
||
|
|
});
|
||
|
|
|
||
|
|
// If we just deleted the current chat, navigate to new chat
|
||
|
|
if (chatIdToDelete === chatId) {
|
||
|
|
router.push(`/${domain}/chat`);
|
||
|
|
}
|
||
|
|
|
||
|
|
router.refresh();
|
||
|
|
}
|
||
|
|
}, [chatId, router, toast, domain]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ResizablePanel
|
||
|
|
ref={sidePanelRef}
|
||
|
|
order={order}
|
||
|
|
minSize={10}
|
||
|
|
maxSize={15}
|
||
|
|
defaultSize={isCollapsed ? 0 : 15}
|
||
|
|
collapsible={true}
|
||
|
|
id="chat-side-panel"
|
||
|
|
onCollapse={() => setIsCollapsed(true)}
|
||
|
|
onExpand={() => setIsCollapsed(false)}
|
||
|
|
>
|
||
|
|
<div className="flex flex-col h-full py-4">
|
||
|
|
<div className="px-2.5 mb-4">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="w-full"
|
||
|
|
onClick={() => {
|
||
|
|
router.push(`/${domain}/chat`);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<CirclePlusIcon className="w-4 h-4 mr-1" />
|
||
|
|
New Chat
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<ScrollArea className="flex flex-col h-full px-2.5">
|
||
|
|
<p className="text-sm font-medium mb-4">Recent Chats</p>
|
||
|
|
<div className="flex flex-col gap-1">
|
||
|
|
{!isAuthenticated ? (
|
||
|
|
<div className="flex flex-col">
|
||
|
|
<p className="text-sm text-muted-foreground mb-4">
|
||
|
|
<Link
|
||
|
|
href={`/login?callbackUrl=${encodeURIComponent(`/${domain}/chat`)}`}
|
||
|
|
className="text-sm text-link hover:underline cursor-pointer"
|
||
|
|
>
|
||
|
|
Sign in
|
||
|
|
</Link> to access your chat history.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
) : chatHistory.length === 0 ? (
|
||
|
|
<div className="mx-auto w-full h-52 border border-dashed border-muted-foreground rounded-md flex items-center justify-center p-6">
|
||
|
|
<p className="text-sm text-muted-foreground text-center">Recent chats will appear here.</p>
|
||
|
|
</div>
|
||
|
|
) : chatHistory.map((chat) => (
|
||
|
|
<div
|
||
|
|
key={chat.id}
|
||
|
|
className={cn("group flex flex-row items-center justify-between hover:bg-muted rounded-md px-2 py-1.5 cursor-pointer",
|
||
|
|
chat.id === chatId && "bg-muted"
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
router.push(`/${domain}/chat/${chat.id}`);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<span className="text-sm truncate">{chat.name ?? 'Untitled chat'}</span>
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-5 w-5 z-10 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted-accent"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<EllipsisIcon className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent
|
||
|
|
align="start"
|
||
|
|
className="z-20"
|
||
|
|
>
|
||
|
|
<DropdownMenuItem
|
||
|
|
className="cursor-pointer"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
setChatIdToRename(chat.id);
|
||
|
|
setIsRenameDialogOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<PencilIcon className="w-4 h-4 mr-2" />
|
||
|
|
Rename
|
||
|
|
</DropdownMenuItem>
|
||
|
|
<DropdownMenuItem
|
||
|
|
className="cursor-pointer"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
setChatIdToDelete(chat.id);
|
||
|
|
setIsDeleteDialogOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<TrashIcon className="w-4 h-4 mr-2" />
|
||
|
|
Delete
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</ResizablePanel >
|
||
|
|
{isCollapsed && (
|
||
|
|
<div className="flex flex-col items-center h-full p-2">
|
||
|
|
<Tooltip
|
||
|
|
delayDuration={100}
|
||
|
|
>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => {
|
||
|
|
sidePanelRef.current?.expand();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<ExpandIcon className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
|
||
|
|
<KeyboardShortcutHint shortcut="⌘ B" />
|
||
|
|
<Separator orientation="vertical" className="h-4" />
|
||
|
|
<span>Open side panel</span>
|
||
|
|
</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<RenameChatDialog
|
||
|
|
isOpen={isRenameDialogOpen}
|
||
|
|
onOpenChange={setIsRenameDialogOpen}
|
||
|
|
onRename={(name) => {
|
||
|
|
if (chatIdToRename) {
|
||
|
|
onRenameChat(name, chatIdToRename);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
currentName={chatHistory?.find((chat) => chat.id === chatIdToRename)?.name ?? ""}
|
||
|
|
/>
|
||
|
|
<DeleteChatDialog
|
||
|
|
isOpen={isDeleteDialogOpen}
|
||
|
|
onOpenChange={setIsDeleteDialogOpen}
|
||
|
|
onDelete={() => {
|
||
|
|
if (chatIdToDelete) {
|
||
|
|
onDeleteChat(chatIdToDelete);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|