From 9691c9834bd96752f244fe5355498618998e10cd Mon Sep 17 00:00:00 2001 From: Will Chen Date: Thu, 9 Oct 2025 10:51:01 -0700 Subject: [PATCH] Support concurrent chats (#1478) Fixes #212 --- > [!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. > > 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). --- e2e-tests/concurrent_chat.spec.ts | 25 +++ e2e-tests/helpers/test_helper.ts | 17 +- ...nt_chat.spec.ts_concurrent-chat-1.aria.yml | 8 + ...nt_chat.spec.ts_concurrent-chat-2.aria.yml | 8 + src/app/TitleBar.tsx | 4 +- src/atoms/chatAtoms.ts | 13 +- src/components/ChatList.tsx | 11 +- src/components/ChatPanel.tsx | 26 +++- src/components/chat/ChatActivity.tsx | 145 ++++++++++++++++++ src/components/chat/ChatInput.tsx | 18 ++- src/components/chat/DyadMarkdownParser.tsx | 5 +- src/components/chat/MessagesList.tsx | 16 +- .../{PreviewHeader.tsx => ActionHeader.tsx} | 9 +- src/hooks/useSelectChat.ts | 21 +++ src/hooks/useStreamChat.ts | 115 +++++++++++--- src/hooks/useVersions.ts | 12 +- src/ipc/handlers/chat_stream_handlers.ts | 57 ++++--- src/ipc/ipc_client.ts | 20 ++- src/ipc/processors/response_processor.ts | 2 +- src/ipc/utils/file_uploads_state.ts | 65 ++++---- .../fake-llm-server/chatCompletionHandler.ts | 15 +- 21 files changed, 487 insertions(+), 125 deletions(-) create mode 100644 e2e-tests/concurrent_chat.spec.ts create mode 100644 e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml create mode 100644 e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml create mode 100644 src/components/chat/ChatActivity.tsx rename src/components/preview_panel/{PreviewHeader.tsx => ActionHeader.tsx} (97%) create mode 100644 src/hooks/useSelectChat.ts diff --git a/e2e-tests/concurrent_chat.spec.ts b/e2e-tests/concurrent_chat.spec.ts new file mode 100644 index 0000000..88a1f67 --- /dev/null +++ b/e2e-tests/concurrent_chat.spec.ts @@ -0,0 +1,25 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("concurrent chat", async ({ po }) => { + await po.setUp(); + await po.sendPrompt("tc=chat1 [sleep=medium]", { + skipWaitForCompletion: true, + }); + // Need a short wait otherwise the click on Apps tab is ignored. + await po.sleep(2_000); + + await po.goToAppsTab(); + await po.sendPrompt("tc=chat2"); + await po.snapshotMessages(); + await po.clickChatActivityButton(); + + // Chat #1 will be the last in the list + expect( + await po.page.getByTestId(`chat-activity-list-item-1`).textContent(), + ).toContain("Chat #1"); + await po.page.getByTestId(`chat-activity-list-item-1`).click(); + await po.snapshotMessages({ timeout: 12_000 }); + + // +}); diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index ec22aee..9e783c1 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -400,7 +400,8 @@ export class PageObject { async snapshotMessages({ replaceDumpPath = false, - }: { replaceDumpPath?: boolean } = {}) { + timeout, + }: { replaceDumpPath?: boolean; timeout?: number } = {}) { if (replaceDumpPath) { // Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path // which is stable across runs. @@ -417,7 +418,9 @@ export class PageObject { ); }); } - await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot(); + await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot({ + timeout, + }); } async approveProposal() { @@ -461,6 +464,16 @@ export class PageObject { await this.page.getByTestId(`${mode}-mode-button`).click(); } + async clickChatActivityButton() { + await this.page.getByTestId("chat-activity-button").click(); + } + + async snapshotChatActivityList() { + await expect( + this.page.getByTestId("chat-activity-list"), + ).toMatchAriaSnapshot(); + } + async clickRecheckProblems() { await this.page.getByTestId("recheck-button").click(); } diff --git a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml new file mode 100644 index 0000000..7c2eb48 --- /dev/null +++ b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-1.aria.yml @@ -0,0 +1,8 @@ +- paragraph: tc=chat2 +- paragraph: chat2 +- button: + - img +- img +- text: less than a minute ago +- button "Retry": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml new file mode 100644 index 0000000..8d9fa85 --- /dev/null +++ b/e2e-tests/snapshots/concurrent_chat.spec.ts_concurrent-chat-2.aria.yml @@ -0,0 +1,8 @@ +- paragraph: tc=chat1 [sleep=medium] +- paragraph: chat1 +- button: + - img +- img +- text: less than a minute ago +- button "Retry": + - img \ No newline at end of file diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx index e91d4cb..a5a587e 100644 --- a/src/app/TitleBar.tsx +++ b/src/app/TitleBar.tsx @@ -20,7 +20,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { PreviewHeader } from "@/components/preview_panel/PreviewHeader"; +import { ActionHeader } from "@/components/preview_panel/ActionHeader"; export const TitleBar = () => { const [selectedAppId] = useAtom(selectedAppIdAtom); @@ -97,7 +97,7 @@ export const TitleBar = () => { {/* Preview Header */} {location.pathname === "/chat" && (
- +
)} diff --git a/src/atoms/chatAtoms.ts b/src/atoms/chatAtoms.ts index 36aac80..bccb010 100644 --- a/src/atoms/chatAtoms.ts +++ b/src/atoms/chatAtoms.ts @@ -2,14 +2,14 @@ import type { Message } from "@/ipc/ipc_types"; import { atom } from "jotai"; import type { ChatSummary } from "@/lib/schemas"; -// Atom to hold the chat history -export const chatMessagesAtom = atom([]); -export const chatErrorAtom = atom(null); +// Per-chat atoms implemented with maps keyed by chatId +export const chatMessagesByIdAtom = atom>(new Map()); +export const chatErrorByIdAtom = atom>(new Map()); // Atom to hold the currently selected chat ID export const selectedChatIdAtom = atom(null); -export const isStreamingAtom = atom(false); +export const isStreamingByIdAtom = atom>(new Map()); export const chatInputValueAtom = atom(""); export const homeChatInputValueAtom = atom(""); @@ -17,5 +17,6 @@ export const homeChatInputValueAtom = atom(""); export const chatsAtom = atom([]); export const chatsLoadingAtom = atom(false); -// Used for scrolling to the bottom of the chat messages -export const chatStreamCountAtom = atom(0); +// Used for scrolling to the bottom of the chat messages (per chat) +export const chatStreamCountByIdAtom = atom>(new Map()); +export const recentStreamChatIdsAtom = atom>(new Set()); diff --git a/src/components/ChatList.tsx b/src/components/ChatList.tsx index 0dd1691..f66f44b 100644 --- a/src/components/ChatList.tsx +++ b/src/components/ChatList.tsx @@ -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 () => { diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 50ee448..1e3385b 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -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(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(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 ( diff --git a/src/components/chat/ChatActivity.tsx b/src/components/chat/ChatActivity.tsx new file mode 100644 index 0000000..d7b4203 --- /dev/null +++ b/src/components/chat/ChatActivity.tsx @@ -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 ( + + + + + + + + Recent chat activity + + + setOpen(false)} /> + + + ); +} + +function ChatActivityList({ onSelect }: { onSelect?: () => void }) { + const [chats, setChats] = useState([]); + 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 ( +
+ + Loading activity… +
+ ); + } + + if (rows.length === 0) { + return ( +
No recent chats
+ ); + } + + return ( +
+ {rows.map((c) => { + const inProgress = isStreamingById.get(c.id) === true; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 53cb9e3..4a3bbaf 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -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(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 ( diff --git a/src/components/chat/DyadMarkdownParser.tsx b/src/components/chat/DyadMarkdownParser.tsx index 2c2596a..0f45dfa 100644 --- a/src/components/chat/DyadMarkdownParser.tsx +++ b/src/components/chat/DyadMarkdownParser.tsx @@ -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 = ({ 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); diff --git a/src/components/chat/MessagesList.tsx b/src/components/chat/MessagesList.tsx index 4f5a9ca..cf36521 100644 --- a/src/components/chat/MessagesList.tsx +++ b/src/components/chat/MessagesList.tsx @@ -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( 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( 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( await IpcClient.getInstance().deleteMessages( selectedChatId, ); - setMessages([]); + setMessagesById((prev) => { + const next = new Map(prev); + next.set(selectedChatId, []); + return next; + }); } catch (err) { showError(err); } diff --git a/src/components/preview_panel/PreviewHeader.tsx b/src/components/preview_panel/ActionHeader.tsx similarity index 97% rename from src/components/preview_panel/PreviewHeader.tsx rename to src/components/preview_panel/ActionHeader.tsx index 81c5690..998eaa9 100644 --- a/src/components/preview_panel/PreviewHeader.tsx +++ b/src/components/preview_panel/ActionHeader.tsx @@ -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", )} -
+ {/* Chat activity bell */} +
+