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

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

View File

@@ -400,7 +400,8 @@ export class PageObject {
async snapshotMessages({ async snapshotMessages({
replaceDumpPath = false, replaceDumpPath = false,
}: { replaceDumpPath?: boolean } = {}) { timeout,
}: { replaceDumpPath?: boolean; timeout?: number } = {}) {
if (replaceDumpPath) { if (replaceDumpPath) {
// Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path // Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path
// which is stable across runs. // 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() { async approveProposal() {
@@ -461,6 +464,16 @@ export class PageObject {
await this.page.getByTestId(`${mode}-mode-button`).click(); 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() { async clickRecheckProblems() {
await this.page.getByTestId("recheck-button").click(); await this.page.getByTestId("recheck-button").click();
} }

View File

@@ -0,0 +1,8 @@
- paragraph: tc=chat2
- paragraph: chat2
- button:
- img
- img
- text: less than a minute ago
- button "Retry":
- img

View File

@@ -0,0 +1,8 @@
- paragraph: tc=chat1 [sleep=medium]
- paragraph: chat1
- button:
- img
- img
- text: less than a minute ago
- button "Retry":
- img

View File

@@ -20,7 +20,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { PreviewHeader } from "@/components/preview_panel/PreviewHeader"; import { ActionHeader } from "@/components/preview_panel/ActionHeader";
export const TitleBar = () => { export const TitleBar = () => {
const [selectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom);
@@ -97,7 +97,7 @@ export const TitleBar = () => {
{/* Preview Header */} {/* Preview Header */}
{location.pathname === "/chat" && ( {location.pathname === "/chat" && (
<div className="flex-1 flex justify-end"> <div className="flex-1 flex justify-end">
<PreviewHeader /> <ActionHeader />
</div> </div>
)} )}

View File

@@ -2,14 +2,14 @@ import type { Message } from "@/ipc/ipc_types";
import { atom } from "jotai"; import { atom } from "jotai";
import type { ChatSummary } from "@/lib/schemas"; import type { ChatSummary } from "@/lib/schemas";
// Atom to hold the chat history // Per-chat atoms implemented with maps keyed by chatId
export const chatMessagesAtom = atom<Message[]>([]); export const chatMessagesByIdAtom = atom<Map<number, Message[]>>(new Map());
export const chatErrorAtom = atom<string | null>(null); export const chatErrorByIdAtom = atom<Map<number, string | null>>(new Map());
// Atom to hold the currently selected chat ID // Atom to hold the currently selected chat ID
export const selectedChatIdAtom = atom<number | null>(null); export const selectedChatIdAtom = atom<number | null>(null);
export const isStreamingAtom = atom<boolean>(false); export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map());
export const chatInputValueAtom = atom<string>(""); export const chatInputValueAtom = atom<string>("");
export const homeChatInputValueAtom = atom<string>(""); export const homeChatInputValueAtom = atom<string>("");
@@ -17,5 +17,6 @@ export const homeChatInputValueAtom = atom<string>("");
export const chatsAtom = atom<ChatSummary[]>([]); export const chatsAtom = atom<ChatSummary[]>([]);
export const chatsLoadingAtom = atom<boolean>(false); export const chatsLoadingAtom = atom<boolean>(false);
// Used for scrolling to the bottom of the chat messages // Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountAtom = atom<number>(0); export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());

View File

@@ -28,11 +28,12 @@ import { RenameChatDialog } from "@/components/chat/RenameChatDialog";
import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog"; import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog";
import { ChatSearchDialog } from "./ChatSearchDialog"; import { ChatSearchDialog } from "./ChatSearchDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
export function ChatList({ show }: { show?: boolean }) { export function ChatList({ show }: { show?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { chats, loading, refreshChats } = useChats(selectedAppId); const { chats, loading, refreshChats } = useChats(selectedAppId);
@@ -51,6 +52,7 @@ export function ChatList({ show }: { show?: boolean }) {
// search dialog state // search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
const { selectChat } = useSelectChat();
// Update selectedChatId when route changes // Update selectedChatId when route changes
useEffect(() => { useEffect(() => {
@@ -74,13 +76,8 @@ export function ChatList({ show }: { show?: boolean }) {
chatId: number; chatId: number;
appId: number; appId: number;
}) => { }) => {
setSelectedChatId(chatId); selectChat({ chatId, appId });
setSelectedAppId(appId);
setIsSearchDialogOpen(false); setIsSearchDialogOpen(false);
navigate({
to: "/chat",
search: { id: chatId },
});
}; };
const handleNewChat = async () => { const handleNewChat = async () => {

View File

@@ -1,6 +1,9 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { chatMessagesAtom, chatStreamCountAtom } from "../atoms/chatAtoms"; import {
chatMessagesByIdAtom,
chatStreamCountByIdAtom,
} from "../atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { ChatHeader } from "./chat/ChatHeader"; import { ChatHeader } from "./chat/ChatHeader";
@@ -20,10 +23,11 @@ export function ChatPanel({
isPreviewOpen, isPreviewOpen,
onTogglePreview, onTogglePreview,
}: ChatPanelProps) { }: ChatPanelProps) {
const [messages, setMessages] = useAtom(chatMessagesAtom); const messagesById = useAtomValue(chatMessagesByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false); const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
const [error, setError] = useState<string | null>(null); 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 // Reference to store the processed prompt so we don't submit it twice
const messagesEndRef = useRef<HTMLDivElement | null>(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
@@ -60,9 +64,10 @@ export function ChatPanel({
}; };
useEffect(() => { useEffect(() => {
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
console.log("streamCount", streamCount); console.log("streamCount", streamCount);
scrollToBottom(); scrollToBottom();
}, [streamCount]); }, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
useEffect(() => { useEffect(() => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
@@ -82,17 +87,22 @@ export function ChatPanel({
const fetchChatMessages = useCallback(async () => { const fetchChatMessages = useCallback(async () => {
if (!chatId) { if (!chatId) {
setMessages([]); // no-op when no chat
return; return;
} }
const chat = await IpcClient.getInstance().getChat(chatId); const chat = await IpcClient.getInstance().getChat(chatId);
setMessages(chat.messages); setMessagesById((prev) => {
}, [chatId, setMessages]); const next = new Map(prev);
next.set(chatId, chat.messages);
return next;
});
}, [chatId, setMessagesById]);
useEffect(() => { useEffect(() => {
fetchChatMessages(); fetchChatMessages();
}, [fetchChatMessages]); }, [fetchChatMessages]);
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
// Auto-scroll effect when messages change // Auto-scroll effect when messages change
useEffect(() => { useEffect(() => {
if ( 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 { IpcClient } from "@/ipc/ipc_client";
import { import {
chatInputValueAtom, chatInputValueAtom,
chatMessagesAtom, chatMessagesByIdAtom,
selectedChatIdAtom, selectedChatIdAtom,
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai"; import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
@@ -39,7 +39,7 @@ import {
FileChange, FileChange,
SqlQuery, SqlQuery,
} from "@/lib/schemas"; } from "@/lib/schemas";
import type { Message } from "@/ipc/ipc_types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useRunApp } from "@/hooks/useRunApp"; import { useRunApp } from "@/hooks/useRunApp";
import { AutoApproveSwitch } from "../AutoApproveSwitch"; import { AutoApproveSwitch } from "../AutoApproveSwitch";
@@ -80,7 +80,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const [showError, setShowError] = useState(true); const [showError, setShowError] = useState(true);
const [isApproving, setIsApproving] = useState(false); // State for approving const [isApproving, setIsApproving] = useState(false); // State for approving
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting 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 setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom); const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
const [selectedComponent, setSelectedComponent] = useAtom( const [selectedComponent, setSelectedComponent] = useAtom(
@@ -110,7 +111,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const { proposal, messageId } = proposalResult ?? {}; const { proposal, messageId } = proposalResult ?? {};
useChatModeToggle(); useChatModeToggle();
const lastMessage = messages.at(-1); const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
const disableSendButton = const disableSendButton =
lastMessage?.role === "assistant" && lastMessage?.role === "assistant" &&
!lastMessage.approvalState && !lastMessage.approvalState &&
@@ -126,12 +127,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const fetchChatMessages = useCallback(async () => { const fetchChatMessages = useCallback(async () => {
if (!chatId) { if (!chatId) {
setMessages([]);
return; return;
} }
const chat = await IpcClient.getInstance().getChat(chatId); const chat = await IpcClient.getInstance().getChat(chatId);
setMessages(chat.messages); setMessagesById((prev) => {
}, [chatId, setMessages]); const next = new Map(prev);
next.set(chatId, chat.messages);
return next;
});
}, [chatId, setMessagesById]);
const handleSubmit = async () => { const handleSubmit = async () => {
if ( if (

View File

@@ -12,7 +12,7 @@ import { DyadCodebaseContext } from "./DyadCodebaseContext";
import { DyadThink } from "./DyadThink"; import { DyadThink } from "./DyadThink";
import { CodeHighlight } from "./CodeHighlight"; import { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms"; import { isStreamingByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes"; import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput"; import { DyadOutput } from "./DyadOutput";
import { DyadProblemSummary } from "./DyadProblemSummary"; import { DyadProblemSummary } from "./DyadProblemSummary";
@@ -79,7 +79,8 @@ export const VanillaMarkdownParser = ({ content }: { content: string }) => {
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({ export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
content, content,
}) => { }) => {
const isStreaming = useAtomValue(isStreamingAtom); const chatId = useAtomValue(selectedChatIdAtom);
const isStreaming = useAtomValue(isStreamingByIdAtom).get(chatId!) ?? false;
// Extract content pieces (markdown and custom tags) // Extract content pieces (markdown and custom tags)
const contentPieces = useMemo(() => { const contentPieces = useMemo(() => {
return parseCustomTags(content); return parseCustomTags(content);

View File

@@ -13,7 +13,7 @@ import { useVersions } from "@/hooks/useVersions";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError, showWarning } from "@/lib/toast"; import { showError, showWarning } from "@/lib/toast";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { chatMessagesAtom } from "@/atoms/chatAtoms"; import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders"; import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
@@ -31,7 +31,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
const { streamMessage, isStreaming } = useStreamChat(); const { streamMessage, isStreaming } = useStreamChat();
const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders(); const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders();
const { settings } = useSettings(); const { settings } = useSettings();
const setMessages = useSetAtom(chatMessagesAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isUndoLoading, setIsUndoLoading] = useState(false); const [isUndoLoading, setIsUndoLoading] = useState(false);
const [isRetryLoading, setIsRetryLoading] = useState(false); const [isRetryLoading, setIsRetryLoading] = useState(false);
const selectedChatId = useAtomValue(selectedChatIdAtom); const selectedChatId = useAtomValue(selectedChatIdAtom);
@@ -107,7 +107,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
await IpcClient.getInstance().getChat( await IpcClient.getInstance().getChat(
selectedChatId, selectedChatId,
); );
setMessages(chat.messages); setMessagesById((prev) => {
const next = new Map(prev);
next.set(selectedChatId, chat.messages);
return next;
});
} }
} else { } else {
const chat = const chat =
@@ -120,7 +124,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
await IpcClient.getInstance().deleteMessages( await IpcClient.getInstance().deleteMessages(
selectedChatId, selectedChatId,
); );
setMessages([]); setMessagesById((prev) => {
const next = new Map(prev);
next.set(selectedChatId, []);
return next;
});
} catch (err) { } catch (err) {
showError(err); showError(err);
} }

View File

@@ -12,6 +12,7 @@ import {
Wrench, Wrench,
Globe, Globe,
} from "lucide-react"; } from "lucide-react";
import { ChatActivityButton } from "@/components/chat/ChatActivity";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react"; 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)]"; "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 // Preview Header component with preview mode toggle
export const PreviewHeader = () => { export const ActionHeader = () => {
const [previewMode, setPreviewMode] = useAtom(previewModeAtom); const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom); const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
@@ -58,7 +59,7 @@ export const PreviewHeader = () => {
const { problemReport } = useCheckProblems(selectedAppId); const { problemReport } = useCheckProblems(selectedAppId);
const { restartApp, refreshAppIframe } = useRunApp(); const { restartApp, refreshAppIframe } = useRunApp();
const isCompact = windowWidth < 860; const isCompact = windowWidth < 888;
// Track window width // Track window width
useEffect(() => { useEffect(() => {
@@ -257,7 +258,9 @@ export const PreviewHeader = () => {
"publish-mode-button", "publish-mode-button",
)} )}
</div> </div>
<div className="flex items-center"> {/* Chat activity bell */}
<div className="flex items-center gap-1">
<ChatActivityButton />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button

View File

@@ -0,0 +1,21 @@
import { useSetAtom } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useNavigate } from "@tanstack/react-router";
export function useSelectChat() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const navigate = useNavigate();
return {
selectChat: ({ chatId, appId }: { chatId: number; appId: number }) => {
setSelectedChatId(chatId);
setSelectedAppId(appId);
navigate({
to: "/chat",
search: { id: chatId },
});
},
};
}

View File

@@ -4,12 +4,13 @@ import type {
Message, Message,
FileAttachment, FileAttachment,
} from "@/ipc/ipc_types"; } from "@/ipc/ipc_types";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import {
chatErrorAtom, chatErrorByIdAtom,
chatMessagesAtom, chatMessagesByIdAtom,
chatStreamCountAtom, chatStreamCountByIdAtom,
isStreamingAtom, isStreamingByIdAtom,
recentStreamChatIdsAtom,
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
@@ -35,20 +36,24 @@ export function getRandomNumberId() {
export function useStreamChat({ export function useStreamChat({
hasChatId = true, hasChatId = true,
}: { hasChatId?: boolean } = {}) { }: { hasChatId?: boolean } = {}) {
const [, setMessages] = useAtom(chatMessagesAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isStreaming, setIsStreaming] = useAtom(isStreamingAtom); const isStreamingById = useAtomValue(isStreamingByIdAtom);
const [error, setError] = useAtom(chatErrorAtom); const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const errorById = useAtomValue(chatErrorByIdAtom);
const setErrorById = useSetAtom(chatErrorByIdAtom);
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom);
const { refreshChats } = useChats(selectedAppId); const { refreshChats } = useChats(selectedAppId);
const { refreshApp } = useLoadApp(selectedAppId); const { refreshApp } = useLoadApp(selectedAppId);
const setStreamCount = useSetAtom(chatStreamCountAtom);
const setStreamCountById = useSetAtom(chatStreamCountByIdAtom);
const { refreshVersions } = useVersions(selectedAppId); const { refreshVersions } = useVersions(selectedAppId);
const { refreshAppIframe } = useRunApp(); const { refreshAppIframe } = useRunApp();
const { countTokens } = useCountTokens(); const { countTokens } = useCountTokens();
const { refetchUserBudget } = useUserBudgetInfo(); const { refetchUserBudget } = useUserBudgetInfo();
const { checkProblems } = useCheckProblems(selectedAppId); const { checkProblems } = useCheckProblems(selectedAppId);
const { settings } = useSettings(); const { settings } = useSettings();
const setRecentStreamChatIds = useSetAtom(recentStreamChatIdsAtom);
const posthog = usePostHog(); const posthog = usePostHog();
let chatId: number | undefined; let chatId: number | undefined;
@@ -79,8 +84,22 @@ export function useStreamChat({
return; return;
} }
setError(null); setRecentStreamChatIds((prev) => {
setIsStreaming(true); const next = new Set(prev);
next.add(chatId);
return next;
});
setErrorById((prev) => {
const next = new Map(prev);
next.set(chatId, null);
return next;
});
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, true);
return next;
});
let hasIncrementedStreamCount = false; let hasIncrementedStreamCount = false;
try { try {
@@ -91,11 +110,19 @@ export function useStreamChat({
attachments, attachments,
onUpdate: (updatedMessages: Message[]) => { onUpdate: (updatedMessages: Message[]) => {
if (!hasIncrementedStreamCount) { if (!hasIncrementedStreamCount) {
setStreamCount((streamCount) => streamCount + 1); setStreamCountById((prev) => {
const next = new Map(prev);
next.set(chatId, (prev.get(chatId) ?? 0) + 1);
return next;
});
hasIncrementedStreamCount = true; hasIncrementedStreamCount = true;
} }
setMessages(updatedMessages); setMessagesById((prev) => {
const next = new Map(prev);
next.set(chatId, updatedMessages);
return next;
});
}, },
onEnd: (response: ChatResponseEnd) => { onEnd: (response: ChatResponseEnd) => {
if (response.updatedFiles) { if (response.updatedFiles) {
@@ -117,7 +144,11 @@ export function useStreamChat({
refetchUserBudget(); refetchUserBudget();
// Keep the same as below // Keep the same as below
setIsStreaming(false); setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
refreshChats(); refreshChats();
refreshApp(); refreshApp();
refreshVersions(); refreshVersions();
@@ -125,10 +156,18 @@ export function useStreamChat({
}, },
onError: (errorMessage: string) => { onError: (errorMessage: string) => {
console.error(`[CHAT] Stream error for ${chatId}:`, errorMessage); console.error(`[CHAT] Stream error for ${chatId}:`, errorMessage);
setError(errorMessage); setErrorById((prev) => {
const next = new Map(prev);
next.set(chatId, errorMessage);
return next;
});
// Keep the same as above // Keep the same as above
setIsStreaming(false); setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
refreshChats(); refreshChats();
refreshApp(); refreshApp();
refreshVersions(); refreshVersions();
@@ -137,13 +176,25 @@ export function useStreamChat({
}); });
} catch (error) { } catch (error) {
console.error("[CHAT] Exception during streaming setup:", error); console.error("[CHAT] Exception during streaming setup:", error);
setIsStreaming(false); setIsStreamingById((prev) => {
setError(error instanceof Error ? error.message : String(error)); const next = new Map(prev);
if (chatId) next.set(chatId, false);
return next;
});
setErrorById((prev) => {
const next = new Map(prev);
if (chatId)
next.set(
chatId,
error instanceof Error ? error.message : String(error),
);
return next;
});
} }
}, },
[ [
setMessages, setMessagesById,
setIsStreaming, setIsStreamingById,
setIsPreviewOpen, setIsPreviewOpen,
checkProblems, checkProblems,
selectedAppId, selectedAppId,
@@ -154,9 +205,25 @@ export function useStreamChat({
return { return {
streamMessage, streamMessage,
isStreaming, isStreaming:
error, hasChatId && chatId !== undefined
setError, ? (isStreamingById.get(chatId) ?? false)
setIsStreaming, : false,
error:
hasChatId && chatId !== undefined
? (errorById.get(chatId) ?? null)
: null,
setError: (value: string | null) =>
setErrorById((prev) => {
const next = new Map(prev);
if (chatId !== undefined) next.set(chatId, value);
return next;
}),
setIsStreaming: (value: boolean) =>
setIsStreamingById((prev) => {
const next = new Map(prev);
if (chatId !== undefined) next.set(chatId, value);
return next;
}),
}; };
} }

View File

@@ -1,9 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { versionsListAtom } from "@/atoms/appAtoms"; import { versionsListAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { chatMessagesAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; import { chatMessagesByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { RevertVersionResponse, Version } from "@/ipc/ipc_types"; import type { RevertVersionResponse, Version } from "@/ipc/ipc_types";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -11,7 +11,7 @@ import { toast } from "sonner";
export function useVersions(appId: number | null) { export function useVersions(appId: number | null) {
const [, setVersionsAtom] = useAtom(versionsListAtom); const [, setVersionsAtom] = useAtom(versionsListAtom);
const selectedChatId = useAtomValue(selectedChatIdAtom); const selectedChatId = useAtomValue(selectedChatIdAtom);
const [, setMessages] = useAtom(chatMessagesAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
@@ -67,7 +67,11 @@ export function useVersions(appId: number | null) {
}); });
if (selectedChatId) { if (selectedChatId) {
const chat = await IpcClient.getInstance().getChat(selectedChatId); const chat = await IpcClient.getInstance().getChat(selectedChatId);
setMessages(chat.messages); setMessagesById((prev) => {
const next = new Map(prev);
next.set(selectedChatId, chat.messages);
return next;
});
} }
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["problems", appId], queryKey: ["problems", appId],

View File

@@ -206,7 +206,6 @@ export function registerChatStreamHandlers() {
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => { ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
try { try {
const fileUploadsState = FileUploadsState.getInstance(); const fileUploadsState = FileUploadsState.getInstance();
fileUploadsState.initialize({ chatId: req.chatId });
// Create an AbortController for this stream // Create an AbortController for this stream
const abortController = new AbortController(); const abortController = new AbortController();
@@ -288,10 +287,13 @@ export function registerChatStreamHandlers() {
// For upload-to-codebase, create a unique file ID and store the mapping // For upload-to-codebase, create a unique file ID and store the mapping
const fileId = `DYAD_ATTACHMENT_${index}`; const fileId = `DYAD_ATTACHMENT_${index}`;
fileUploadsState.addFileUpload(fileId, { fileUploadsState.addFileUpload(
filePath, { chatId: req.chatId, fileId },
originalName: attachment.name, {
}); filePath,
originalName: attachment.name,
},
);
// Add instruction for AI to use dyad-write tag // Add instruction for AI to use dyad-write tag
attachmentInfo += `\n\nFile to upload to codebase: ${attachment.name} (file id: ${fileId})\n`; attachmentInfo += `\n\nFile to upload to codebase: ${attachment.name} (file id: ${fileId})\n`;
@@ -793,10 +795,10 @@ This conversation includes one or more image attachments. When the user uploads
const requestIdPrefix = isEngineEnabled const requestIdPrefix = isEngineEnabled
? `[Request ID: ${dyadRequestId}] ` ? `[Request ID: ${dyadRequestId}] `
: ""; : "";
event.sender.send( event.sender.send("chat:response:error", {
"chat:response:error", chatId: req.chatId,
`Sorry, there was an error from the AI: ${requestIdPrefix}${message}`, error: `Sorry, there was an error from the AI: ${requestIdPrefix}${message}`,
); });
// Clean up the abort controller // Clean up the abort controller
activeStreams.delete(req.chatId); activeStreams.delete(req.chatId);
}, },
@@ -804,6 +806,8 @@ This conversation includes one or more image attachments. When the user uploads
}); });
}; };
let lastDbSaveAt = 0;
const processResponseChunkUpdate = async ({ const processResponseChunkUpdate = async ({
fullResponse, fullResponse,
}: { }: {
@@ -823,6 +827,16 @@ This conversation includes one or more image attachments. When the user uploads
} }
// Store the current partial response // Store the current partial response
partialResponses.set(req.chatId, fullResponse); partialResponses.set(req.chatId, fullResponse);
// Save to DB (in case user is switching chats during the stream)
const now = Date.now();
if (now - lastDbSaveAt >= 150) {
await db
.update(messages)
.set({ content: fullResponse })
.where(eq(messages.id, placeholderAssistantMessage.id));
lastDbSaveAt = now;
}
// Update the placeholder assistant message content in the messages array // Update the placeholder assistant message content in the messages array
const currentMessages = [...updatedChat.messages]; const currentMessages = [...updatedChat.messages];
@@ -1143,11 +1157,10 @@ ${problemReport.problems
}); });
if (status.error) { if (status.error) {
safeSend( safeSend(event.sender, "chat:response:error", {
event.sender, chatId: req.chatId,
"chat:response:error", error: `Sorry, there was an error applying the AI's changes: ${status.error}`,
`Sorry, there was an error applying the AI's changes: ${status.error}`, });
);
} }
// Signal that the stream has completed // Signal that the stream has completed
@@ -1190,15 +1203,14 @@ ${problemReport.problems
return req.chatId; return req.chatId;
} catch (error) { } catch (error) {
logger.error("Error calling LLM:", error); logger.error("Error calling LLM:", error);
safeSend( safeSend(event.sender, "chat:response:error", {
event.sender, chatId: req.chatId,
"chat:response:error", error: `Sorry, there was an error processing your request: ${error}`,
`Sorry, there was an error processing your request: ${error}`, });
);
// Clean up the abort controller // Clean up the abort controller
activeStreams.delete(req.chatId); activeStreams.delete(req.chatId);
// Clean up file uploads state on error // Clean up file uploads state on error
FileUploadsState.getInstance().clear(); FileUploadsState.getInstance().clear(req.chatId);
return "error"; return "error";
} }
}); });
@@ -1222,6 +1234,11 @@ ${problemReport.problems
updatedFiles: false, updatedFiles: false,
} satisfies ChatResponseEnd); } satisfies ChatResponseEnd);
// Clean up uploads state for this chat
try {
FileUploadsState.getInstance().clear(chatId);
} catch {}
return true; return true;
}); });
} }

View File

@@ -189,15 +189,27 @@ export class IpcClient {
} }
}); });
this.ipcRenderer.on("chat:response:error", (error) => { this.ipcRenderer.on("chat:response:error", (payload) => {
console.debug("chat:response:error"); console.debug("chat:response:error");
if (typeof error === "string") { if (
for (const [chatId, callbacks] of this.chatStreams.entries()) { payload &&
typeof payload === "object" &&
"chatId" in payload &&
"error" in payload
) {
const { chatId, error } = payload as { chatId: number; error: string };
const callbacks = this.chatStreams.get(chatId);
if (callbacks) {
callbacks.onError(error); callbacks.onError(error);
this.chatStreams.delete(chatId); this.chatStreams.delete(chatId);
} else {
console.warn(
`[IPC] No callbacks found for chat ${chatId} on error`,
this.chatStreams,
);
} }
} else { } else {
console.error("[IPC] Invalid error data received:", error); console.error("[IPC] Invalid error data received:", payload);
} }
}); });

View File

@@ -68,7 +68,7 @@ export async function processFullResponseActions(
}> { }> {
const fileUploadsState = FileUploadsState.getInstance(); const fileUploadsState = FileUploadsState.getInstance();
const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId); const fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId);
fileUploadsState.clear(); fileUploadsState.clear(chatId);
logger.log("processFullResponseActions for chatId", chatId); logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat // Get the app associated with the chat
const chatWithApp = await db.query.chats.findFirst({ const chatWithApp = await db.query.chats.findFirst({

View File

@@ -9,8 +9,8 @@ export interface FileUploadInfo {
export class FileUploadsState { export class FileUploadsState {
private static instance: FileUploadsState; private static instance: FileUploadsState;
private currentChatId: number | null = null; // Map of chatId -> (fileId -> fileInfo)
private fileUploadsMap = new Map<string, FileUploadInfo>(); private uploadsByChat = new Map<number, Map<string, FileUploadInfo>>();
private constructor() {} private constructor() {}
@@ -22,45 +22,54 @@ export class FileUploadsState {
} }
/** /**
* Initialize file uploads state for a specific chat and message * Ensure a map exists for a chatId
*/ */
public initialize({ chatId }: { chatId: number }): void { private ensureChat(chatId: number): Map<string, FileUploadInfo> {
this.currentChatId = chatId; let map = this.uploadsByChat.get(chatId);
this.fileUploadsMap.clear(); if (!map) {
logger.debug(`Initialized file uploads state for chat ${chatId}`); map = new Map<string, FileUploadInfo>();
this.uploadsByChat.set(chatId, map);
}
return map;
} }
/** /**
* Add a file upload mapping * Add a file upload mapping to a specific chat
*/ */
public addFileUpload(fileId: string, fileInfo: FileUploadInfo): void { public addFileUpload(
this.fileUploadsMap.set(fileId, fileInfo); { chatId, fileId }: { chatId: number; fileId: string },
logger.log(`Added file upload: ${fileId} -> ${fileInfo.originalName}`); fileInfo: FileUploadInfo,
): void {
const map = this.ensureChat(chatId);
map.set(fileId, fileInfo);
logger.log(
`Added file upload for chat ${chatId}: ${fileId} -> ${fileInfo.originalName}`,
);
} }
/** /**
* Get the current file uploads map * Get a copy of the file uploads map for a specific chat
*/ */
public getFileUploadsForChat(chatId: number): Map<string, FileUploadInfo> { public getFileUploadsForChat(chatId: number): Map<string, FileUploadInfo> {
if (this.currentChatId !== chatId) { const map = this.uploadsByChat.get(chatId);
return new Map(); return new Map(map ?? []);
} }
return new Map(this.fileUploadsMap);
// Removed getCurrentChatId(): no longer applicable in per-chat state
/**
* Clear state for a specific chat
*/
public clear(chatId: number): void {
this.uploadsByChat.delete(chatId);
logger.debug(`Cleared file uploads state for chat ${chatId}`);
} }
/** /**
* Get current chat ID * Clear all uploads (primarily for tests or full reset)
*/ */
public getCurrentChatId(): number | null { public clearAll(): void {
return this.currentChatId; this.uploadsByChat.clear();
} logger.debug("Cleared all file uploads state");
/**
* Clear the current state
*/
public clear(): void {
this.currentChatId = null;
this.fileUploadsMap.clear();
logger.debug("Cleared file uploads state");
} }
} }

View File

@@ -6,7 +6,7 @@ import { CANNED_MESSAGE, createStreamChunk } from ".";
let globalCounter = 0; let globalCounter = 0;
export const createChatCompletionHandler = export const createChatCompletionHandler =
(prefix: string) => (req: Request, res: Response) => { (prefix: string) => async (req: Request, res: Response) => {
const { stream = false, messages = [] } = req.body; const { stream = false, messages = [] } = req.body;
console.log("* Received messages", messages); console.log("* Received messages", messages);
@@ -42,6 +42,14 @@ DYAD_ATTACHMENT_0
messageContent += "\n\n" + generateDump(req); messageContent += "\n\n" + generateDump(req);
} }
if (
lastMessage &&
typeof lastMessage.content === "string" &&
lastMessage.content.includes("[sleep=medium]")
) {
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
// TS auto-fix prefixes // TS auto-fix prefixes
if ( if (
lastMessage && lastMessage &&
@@ -145,7 +153,8 @@ export default Index;
typeof lastMessage.content === "string" && typeof lastMessage.content === "string" &&
lastMessage.content.startsWith("tc=") lastMessage.content.startsWith("tc=")
) { ) {
const testCaseName = lastMessage.content.slice(3); // Remove "tc=" prefix const testCaseName = lastMessage.content.slice(3).split("[")[0].trim(); // Remove "tc=" prefix
console.error(`* Loading test case: ${testCaseName}`);
const testFilePath = path.join( const testFilePath = path.join(
__dirname, __dirname,
"..", "..",
@@ -162,7 +171,7 @@ export default Index;
messageContent = fs.readFileSync(testFilePath, "utf-8"); messageContent = fs.readFileSync(testFilePath, "utf-8");
console.log(`* Loaded test case: ${testCaseName}`); console.log(`* Loaded test case: ${testCaseName}`);
} else { } else {
console.log(`* Test case file not found: ${testFilePath}`); console.error(`* Test case file not found: ${testFilePath}`);
messageContent = `Error: Test case file not found: ${testCaseName}.md`; messageContent = `Error: Test case file not found: ${testCaseName}.md`;
} }
} catch (error) { } catch (error) {