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:
Will Chen
2025-10-09 10:51:01 -07:00
committed by GitHub
parent 263f401172
commit 9691c9834b
21 changed files with 487 additions and 125 deletions

View File

@@ -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 () => {

View File

@@ -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 (

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

View File

@@ -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 (

View File

@@ -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);

View File

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

View File

@@ -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