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:
25
e2e-tests/concurrent_chat.spec.ts
Normal file
25
e2e-tests/concurrent_chat.spec.ts
Normal 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 });
|
||||||
|
|
||||||
|
//
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- paragraph: tc=chat2
|
||||||
|
- paragraph: chat2
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- img
|
||||||
|
- text: less than a minute ago
|
||||||
|
- button "Retry":
|
||||||
|
- img
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- paragraph: tc=chat1 [sleep=medium]
|
||||||
|
- paragraph: chat1
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- img
|
||||||
|
- text: less than a minute ago
|
||||||
|
- button "Retry":
|
||||||
|
- img
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
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 { 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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
21
src/hooks/useSelectChat.ts
Normal file
21
src/hooks/useSelectChat.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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(
|
||||||
|
{ chatId: req.chatId, fileId },
|
||||||
|
{
|
||||||
filePath,
|
filePath,
|
||||||
originalName: attachment.name,
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user