Fixes #212 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Add concurrent chat support with per-chat state, chat activity UI, IPC per-chat handling, and accompanying tests. > > - **Frontend (Chat concurrency)** > - Replace global chat atoms with per-chat maps: `chatMessagesByIdAtom`, `isStreamingByIdAtom`, `chatErrorByIdAtom`, `chatStreamCountByIdAtom`, `recentStreamChatIdsAtom`. > - Update `ChatPanel`, `ChatInput`, `MessagesList`, `DyadMarkdownParser`, and `useVersions` to read/write per-chat state. > - Add `useSelectChat` to centralize selecting/navigating chats; wire into `ChatList`. > - **UI** > - Add chat activity popover: `ChatActivityButton` and list; integrate into `preview_panel/ActionHeader` (renamed from `PreviewHeader`) and swap in `TitleBar`. > - **IPC/Main** > - Send error payloads with `chatId` on `chat:response:error`; update `ipc_client` to route errors per chat. > - Persist streaming partial assistant content periodically; improve cancellation/end handling. > - Make `FileUploadsState` per-chat (`addFileUpload({chatId,fileId}, ...)`, `clear(chatId)`, `getFileUploadsForChat(chatId)`); update handlers/processors accordingly. > - **Testing** > - Add e2e `concurrent_chat.spec.ts` and snapshots; extend helpers (`snapshotMessages` timeout, chat activity helpers). > - Fake LLM server: support `tc=` with options, optional sleep delay to simulate concurrency. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9035f30b73a1f2e5a366a0cac1c63411742b16f3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react";
|
|
import { useAtom } from "jotai";
|
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
|
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
|
|
import { IpcClient } from "@/ipc/ipc_client";
|
|
import { showError, showSuccess } from "@/lib/toast";
|
|
import {
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarMenu,
|
|
SidebarMenuItem,
|
|
} from "@/components/ui/sidebar";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useChats } from "@/hooks/useChats";
|
|
import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
|
|
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
|
|
|
|
import { ChatSearchDialog } from "./ChatSearchDialog";
|
|
import { useSelectChat } from "@/hooks/useSelectChat";
|
|
|
|
export function ChatList({ show }: { show?: boolean }) {
|
|
const navigate = useNavigate();
|
|
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
|
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
|
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
|
|
|
|
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
|
const routerState = useRouterState();
|
|
const isChatRoute = routerState.location.pathname === "/chat";
|
|
|
|
// Rename dialog state
|
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
const [renameChatId, setRenameChatId] = useState<number | null>(null);
|
|
const [renameChatTitle, setRenameChatTitle] = useState("");
|
|
|
|
// Delete dialog state
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [deleteChatId, setDeleteChatId] = useState<number | null>(null);
|
|
const [deleteChatTitle, setDeleteChatTitle] = useState("");
|
|
|
|
// search dialog state
|
|
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
|
const { selectChat } = useSelectChat();
|
|
|
|
// Update selectedChatId when route changes
|
|
useEffect(() => {
|
|
if (isChatRoute) {
|
|
const id = routerState.location.search.id;
|
|
if (id) {
|
|
console.log("Setting selected chat id to", id);
|
|
setSelectedChatId(id);
|
|
}
|
|
}
|
|
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
|
|
|
|
if (!show) {
|
|
return;
|
|
}
|
|
|
|
const handleChatClick = ({
|
|
chatId,
|
|
appId,
|
|
}: {
|
|
chatId: number;
|
|
appId: number;
|
|
}) => {
|
|
selectChat({ chatId, appId });
|
|
setIsSearchDialogOpen(false);
|
|
};
|
|
|
|
const handleNewChat = async () => {
|
|
// Only create a new chat if an app is selected
|
|
if (selectedAppId) {
|
|
try {
|
|
// Create a new chat with an empty title for now
|
|
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
|
|
|
|
// Navigate to the new chat
|
|
setSelectedChatId(chatId);
|
|
navigate({
|
|
to: "/chat",
|
|
search: { id: chatId },
|
|
});
|
|
|
|
// Refresh the chat list
|
|
await refreshChats();
|
|
} catch (error) {
|
|
// DO A TOAST
|
|
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
|
}
|
|
} else {
|
|
// If no app is selected, navigate to home page
|
|
navigate({ to: "/" });
|
|
}
|
|
};
|
|
|
|
const handleDeleteChat = async (chatId: number) => {
|
|
try {
|
|
await IpcClient.getInstance().deleteChat(chatId);
|
|
showSuccess("Chat deleted successfully");
|
|
|
|
// If the deleted chat was selected, navigate to home
|
|
if (selectedChatId === chatId) {
|
|
setSelectedChatId(null);
|
|
navigate({ to: "/chat" });
|
|
}
|
|
|
|
// Refresh the chat list
|
|
await refreshChats();
|
|
} catch (error) {
|
|
showError(`Failed to delete chat: ${(error as any).toString()}`);
|
|
}
|
|
};
|
|
|
|
const handleDeleteChatClick = (chatId: number, chatTitle: string) => {
|
|
setDeleteChatId(chatId);
|
|
setDeleteChatTitle(chatTitle);
|
|
setIsDeleteDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (deleteChatId !== null) {
|
|
await handleDeleteChat(deleteChatId);
|
|
setIsDeleteDialogOpen(false);
|
|
setDeleteChatId(null);
|
|
setDeleteChatTitle("");
|
|
}
|
|
};
|
|
|
|
const handleRenameChat = (chatId: number, currentTitle: string) => {
|
|
setRenameChatId(chatId);
|
|
setRenameChatTitle(currentTitle);
|
|
setIsRenameDialogOpen(true);
|
|
};
|
|
|
|
const handleRenameDialogClose = (open: boolean) => {
|
|
setIsRenameDialogOpen(open);
|
|
if (!open) {
|
|
setRenameChatId(null);
|
|
setRenameChatTitle("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SidebarGroup
|
|
className="overflow-y-auto h-[calc(100vh-112px)]"
|
|
data-testid="chat-list-container"
|
|
>
|
|
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<div className="flex flex-col space-y-4">
|
|
<Button
|
|
onClick={handleNewChat}
|
|
variant="outline"
|
|
className="flex items-center justify-start gap-2 mx-2 py-3"
|
|
>
|
|
<PlusCircle size={16} />
|
|
<span>New Chat</span>
|
|
</Button>
|
|
<Button
|
|
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
|
|
variant="outline"
|
|
className="flex items-center justify-start gap-2 mx-2 py-3"
|
|
data-testid="search-chats-button"
|
|
>
|
|
<Search size={16} />
|
|
<span>Search chats</span>
|
|
</Button>
|
|
|
|
{loading ? (
|
|
<div className="py-3 px-4 text-sm text-gray-500">
|
|
Loading chats...
|
|
</div>
|
|
) : chats.length === 0 ? (
|
|
<div className="py-3 px-4 text-sm text-gray-500">
|
|
No chats found
|
|
</div>
|
|
) : (
|
|
<SidebarMenu className="space-y-1">
|
|
{chats.map((chat) => (
|
|
<SidebarMenuItem key={chat.id} className="mb-1">
|
|
<div className="flex w-[175px] items-center">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() =>
|
|
handleChatClick({
|
|
chatId: chat.id,
|
|
appId: chat.appId,
|
|
})
|
|
}
|
|
className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${
|
|
selectedChatId === chat.id
|
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-col w-full">
|
|
<span className="truncate">
|
|
{chat.title || "New Chat"}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{formatDistanceToNow(new Date(chat.createdAt), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
</div>
|
|
</Button>
|
|
|
|
{selectedChatId === chat.id && (
|
|
<DropdownMenu
|
|
modal={false}
|
|
onOpenChange={(open) => setIsDropdownOpen(open)}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="ml-1 w-4"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="space-y-1 p-2"
|
|
>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleRenameChat(chat.id, chat.title || "")
|
|
}
|
|
className="px-3 py-2"
|
|
>
|
|
<Edit3 className="mr-2 h-4 w-4" />
|
|
<span>Rename Chat</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleDeleteChatClick(
|
|
chat.id,
|
|
chat.title || "New Chat",
|
|
)
|
|
}
|
|
className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50"
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
<span>Delete Chat</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
)}
|
|
</div>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
{/* Rename Chat Dialog */}
|
|
{renameChatId !== null && (
|
|
<RenameChatDialog
|
|
chatId={renameChatId}
|
|
currentTitle={renameChatTitle}
|
|
isOpen={isRenameDialogOpen}
|
|
onOpenChange={handleRenameDialogClose}
|
|
onRename={refreshChats}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Chat Dialog */}
|
|
<DeleteChatDialog
|
|
isOpen={isDeleteDialogOpen}
|
|
onOpenChange={setIsDeleteDialogOpen}
|
|
onConfirmDelete={handleConfirmDelete}
|
|
chatTitle={deleteChatTitle}
|
|
/>
|
|
|
|
{/* Chat Search Dialog */}
|
|
<ChatSearchDialog
|
|
open={isSearchDialogOpen}
|
|
onOpenChange={setIsSearchDialogOpen}
|
|
onSelectChat={handleChatClick}
|
|
appId={selectedAppId}
|
|
allChats={chats}
|
|
/>
|
|
</>
|
|
);
|
|
}
|