Files
moreminimore-vibe/src/components/ChatList.tsx
Will Chen 9691c9834b Support concurrent chats (#1478)
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 -->
2025-10-09 10:51:01 -07:00

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}
/>
</>
);
}