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 -->
This commit is contained in:
@@ -28,11 +28,12 @@ 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, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
|
||||
|
||||
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
||||
@@ -51,6 +52,7 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
|
||||
// search dialog state
|
||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||
const { selectChat } = useSelectChat();
|
||||
|
||||
// Update selectedChatId when route changes
|
||||
useEffect(() => {
|
||||
@@ -74,13 +76,8 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
chatId: number;
|
||||
appId: number;
|
||||
}) => {
|
||||
setSelectedChatId(chatId);
|
||||
setSelectedAppId(appId);
|
||||
selectChat({ chatId, appId });
|
||||
setIsSearchDialogOpen(false);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, chatStreamCountAtom } from "../atoms/chatAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
chatMessagesByIdAtom,
|
||||
chatStreamCountByIdAtom,
|
||||
} from "../atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
import { ChatHeader } from "./chat/ChatHeader";
|
||||
@@ -20,10 +23,11 @@ export function ChatPanel({
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
}: ChatPanelProps) {
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const messagesById = useAtomValue(chatMessagesByIdAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamCount = useAtomValue(chatStreamCountAtom);
|
||||
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
|
||||
// Reference to store the processed prompt so we don't submit it twice
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -60,9 +64,10 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
|
||||
console.log("streamCount", streamCount);
|
||||
scrollToBottom();
|
||||
}, [streamCount]);
|
||||
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
@@ -82,17 +87,22 @@ export function ChatPanel({
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
// no-op when no chat
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}, [chatId, setMessagesById]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatMessages();
|
||||
}, [fetchChatMessages]);
|
||||
|
||||
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
145
src/components/chat/ChatActivity.tsx
Normal file
145
src/components/chat/ChatActivity.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Bell, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { getAllChats } from "@/lib/chat";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
isStreamingByIdAtom,
|
||||
recentStreamChatIdsAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useSelectChat } from "@/hooks/useSelectChat";
|
||||
|
||||
export function ChatActivityButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||
const isAnyStreaming = useMemo(() => {
|
||||
for (const v of isStreamingById.values()) {
|
||||
if (v) return true;
|
||||
}
|
||||
return false;
|
||||
}, [isStreamingById]);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="no-app-region-drag relative flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
data-testid="chat-activity-button"
|
||||
>
|
||||
{isAnyStreaming && (
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="block size-7 rounded-full border-3 border-blue-500/60 border-t-transparent animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Recent chat activity</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-80 p-0 max-h-[50vh] overflow-y-auto"
|
||||
>
|
||||
<ChatActivityList onSelect={() => setOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatActivityList({ onSelect }: { onSelect?: () => void }) {
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||
const recentStreamChatIds = useAtomValue(recentStreamChatIdsAtom);
|
||||
const apps = useLoadApps();
|
||||
const { selectChat } = useSelectChat();
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const all = await getAllChats();
|
||||
if (!mounted) return;
|
||||
const recent = Array.from(recentStreamChatIds)
|
||||
.map((id) => all.find((c) => c.id === id))
|
||||
.filter((c) => c !== undefined);
|
||||
// Sort recent first
|
||||
setChats([...recent].reverse());
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [recentStreamChatIds]);
|
||||
|
||||
const rows = useMemo(() => chats.slice(0, 30), [chats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Loading activity…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">No recent chats</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1" data-testid="chat-activity-list">
|
||||
{rows.map((c) => {
|
||||
const inProgress = isStreamingById.get(c.id) === true;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className="w-full text-left px-3 py-2 flex items-center justify-between gap-2 rounded-md hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lighter)] transition-colors"
|
||||
onClick={() => {
|
||||
onSelect?.();
|
||||
selectChat({ chatId: c.id, appId: c.appId });
|
||||
}}
|
||||
data-testid={`chat-activity-list-item-${c.id}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{c.title ?? `Chat #${c.id}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{apps.apps.find((a) => a.id === c.appId)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{inProgress ? (
|
||||
<div className="flex items-center text-purple-600">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-emerald-600">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import {
|
||||
chatInputValueAtom,
|
||||
chatMessagesAtom,
|
||||
chatMessagesByIdAtom,
|
||||
selectedChatIdAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
FileChange,
|
||||
SqlQuery,
|
||||
} from "@/lib/schemas";
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { AutoApproveSwitch } from "../AutoApproveSwitch";
|
||||
@@ -80,7 +80,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const [showError, setShowError] = useState(true);
|
||||
const [isApproving, setIsApproving] = useState(false); // State for approving
|
||||
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
|
||||
const [messages, setMessages] = useAtom<Message[]>(chatMessagesAtom);
|
||||
const messagesById = useAtomValue(chatMessagesByIdAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
||||
const [selectedComponent, setSelectedComponent] = useAtom(
|
||||
@@ -110,7 +111,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const { proposal, messageId } = proposalResult ?? {};
|
||||
useChatModeToggle();
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
|
||||
const disableSendButton =
|
||||
lastMessage?.role === "assistant" &&
|
||||
!lastMessage.approvalState &&
|
||||
@@ -126,12 +127,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}, [chatId, setMessagesById]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
||||
import { DyadThink } from "./DyadThink";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { isStreamingByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { DyadOutput } from "./DyadOutput";
|
||||
import { DyadProblemSummary } from "./DyadProblemSummary";
|
||||
@@ -79,7 +79,8 @@ export const VanillaMarkdownParser = ({ content }: { content: string }) => {
|
||||
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const isStreaming = useAtomValue(isStreamingAtom);
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const isStreaming = useAtomValue(isStreamingByIdAtom).get(chatId!) ?? false;
|
||||
// Extract content pieces (markdown and custom tags)
|
||||
const contentPieces = useMemo(() => {
|
||||
return parseCustomTags(content);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useVersions } from "@/hooks/useVersions";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { showError, showWarning } from "@/lib/toast";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatMessagesAtom } from "@/atoms/chatAtoms";
|
||||
import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
@@ -31,7 +31,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders();
|
||||
const { settings } = useSettings();
|
||||
const setMessages = useSetAtom(chatMessagesAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const [isUndoLoading, setIsUndoLoading] = useState(false);
|
||||
const [isRetryLoading, setIsRetryLoading] = useState(false);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
@@ -107,7 +107,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
await IpcClient.getInstance().getChat(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages(chat.messages);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedChatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const chat =
|
||||
@@ -120,7 +124,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
await IpcClient.getInstance().deleteMessages(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages([]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedChatId, []);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { ChatActivityButton } from "@/components/chat/ChatActivity";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
@@ -44,7 +45,7 @@ const BUTTON_CLASS_NAME =
|
||||
"no-app-region-drag cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-[13px] font-medium z-10 hover:bg-[var(--background)]";
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
export const PreviewHeader = () => {
|
||||
export const ActionHeader = () => {
|
||||
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
@@ -58,7 +59,7 @@ export const PreviewHeader = () => {
|
||||
const { problemReport } = useCheckProblems(selectedAppId);
|
||||
const { restartApp, refreshAppIframe } = useRunApp();
|
||||
|
||||
const isCompact = windowWidth < 860;
|
||||
const isCompact = windowWidth < 888;
|
||||
|
||||
// Track window width
|
||||
useEffect(() => {
|
||||
@@ -257,7 +258,9 @@ export const PreviewHeader = () => {
|
||||
"publish-mode-button",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Chat activity bell */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatActivityButton />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
Reference in New Issue
Block a user