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({
|
||||
replaceDumpPath = false,
|
||||
}: { replaceDumpPath?: boolean } = {}) {
|
||||
timeout,
|
||||
}: { replaceDumpPath?: boolean; timeout?: number } = {}) {
|
||||
if (replaceDumpPath) {
|
||||
// Update page so that "[[dyad-dump-path=*]]" is replaced with a placeholder path
|
||||
// which is stable across runs.
|
||||
@@ -417,7 +418,9 @@ export class PageObject {
|
||||
);
|
||||
});
|
||||
}
|
||||
await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot();
|
||||
await expect(this.page.getByTestId("messages-list")).toMatchAriaSnapshot({
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async approveProposal() {
|
||||
@@ -461,6 +464,16 @@ export class PageObject {
|
||||
await this.page.getByTestId(`${mode}-mode-button`).click();
|
||||
}
|
||||
|
||||
async clickChatActivityButton() {
|
||||
await this.page.getByTestId("chat-activity-button").click();
|
||||
}
|
||||
|
||||
async snapshotChatActivityList() {
|
||||
await expect(
|
||||
this.page.getByTestId("chat-activity-list"),
|
||||
).toMatchAriaSnapshot();
|
||||
}
|
||||
|
||||
async clickRecheckProblems() {
|
||||
await this.page.getByTestId("recheck-button").click();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PreviewHeader } from "@/components/preview_panel/PreviewHeader";
|
||||
import { ActionHeader } from "@/components/preview_panel/ActionHeader";
|
||||
|
||||
export const TitleBar = () => {
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
@@ -97,7 +97,7 @@ export const TitleBar = () => {
|
||||
{/* Preview Header */}
|
||||
{location.pathname === "/chat" && (
|
||||
<div className="flex-1 flex justify-end">
|
||||
<PreviewHeader />
|
||||
<ActionHeader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { Message } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
|
||||
// Atom to hold the chat history
|
||||
export const chatMessagesAtom = atom<Message[]>([]);
|
||||
export const chatErrorAtom = atom<string | null>(null);
|
||||
// Per-chat atoms implemented with maps keyed by chatId
|
||||
export const chatMessagesByIdAtom = atom<Map<number, Message[]>>(new Map());
|
||||
export const chatErrorByIdAtom = atom<Map<number, string | null>>(new Map());
|
||||
|
||||
// Atom to hold the currently selected chat ID
|
||||
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 homeChatInputValueAtom = atom<string>("");
|
||||
|
||||
@@ -17,5 +17,6 @@ export const homeChatInputValueAtom = atom<string>("");
|
||||
export const chatsAtom = atom<ChatSummary[]>([]);
|
||||
export const chatsLoadingAtom = atom<boolean>(false);
|
||||
|
||||
// Used for scrolling to the bottom of the chat messages
|
||||
export const chatStreamCountAtom = atom<number>(0);
|
||||
// Used for scrolling to the bottom of the chat messages (per chat)
|
||||
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 { ChatSearchDialog } from "./ChatSearchDialog";
|
||||
import { useSelectChat } from "@/hooks/useSelectChat";
|
||||
|
||||
export function ChatList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
|
||||
|
||||
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
||||
@@ -51,6 +52,7 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
|
||||
// search dialog state
|
||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||
const { selectChat } = useSelectChat();
|
||||
|
||||
// Update selectedChatId when route changes
|
||||
useEffect(() => {
|
||||
@@ -74,13 +76,8 @@ export function ChatList({ show }: { show?: boolean }) {
|
||||
chatId: number;
|
||||
appId: number;
|
||||
}) => {
|
||||
setSelectedChatId(chatId);
|
||||
setSelectedAppId(appId);
|
||||
selectChat({ chatId, appId });
|
||||
setIsSearchDialogOpen(false);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, chatStreamCountAtom } from "../atoms/chatAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
chatMessagesByIdAtom,
|
||||
chatStreamCountByIdAtom,
|
||||
} from "../atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
import { ChatHeader } from "./chat/ChatHeader";
|
||||
@@ -20,10 +23,11 @@ export function ChatPanel({
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
}: ChatPanelProps) {
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const messagesById = useAtomValue(chatMessagesByIdAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamCount = useAtomValue(chatStreamCountAtom);
|
||||
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
|
||||
// Reference to store the processed prompt so we don't submit it twice
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -60,9 +64,10 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
|
||||
console.log("streamCount", streamCount);
|
||||
scrollToBottom();
|
||||
}, [streamCount]);
|
||||
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
@@ -82,17 +87,22 @@ export function ChatPanel({
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
// no-op when no chat
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}, [chatId, setMessagesById]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatMessages();
|
||||
}, [fetchChatMessages]);
|
||||
|
||||
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
145
src/components/chat/ChatActivity.tsx
Normal file
145
src/components/chat/ChatActivity.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Bell, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { getAllChats } from "@/lib/chat";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
isStreamingByIdAtom,
|
||||
recentStreamChatIdsAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useSelectChat } from "@/hooks/useSelectChat";
|
||||
|
||||
export function ChatActivityButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||
const isAnyStreaming = useMemo(() => {
|
||||
for (const v of isStreamingById.values()) {
|
||||
if (v) return true;
|
||||
}
|
||||
return false;
|
||||
}, [isStreamingById]);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="no-app-region-drag relative flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
data-testid="chat-activity-button"
|
||||
>
|
||||
{isAnyStreaming && (
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="block size-7 rounded-full border-3 border-blue-500/60 border-t-transparent animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Recent chat activity</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-80 p-0 max-h-[50vh] overflow-y-auto"
|
||||
>
|
||||
<ChatActivityList onSelect={() => setOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatActivityList({ onSelect }: { onSelect?: () => void }) {
|
||||
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||
const recentStreamChatIds = useAtomValue(recentStreamChatIdsAtom);
|
||||
const apps = useLoadApps();
|
||||
const { selectChat } = useSelectChat();
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const all = await getAllChats();
|
||||
if (!mounted) return;
|
||||
const recent = Array.from(recentStreamChatIds)
|
||||
.map((id) => all.find((c) => c.id === id))
|
||||
.filter((c) => c !== undefined);
|
||||
// Sort recent first
|
||||
setChats([...recent].reverse());
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [recentStreamChatIds]);
|
||||
|
||||
const rows = useMemo(() => chats.slice(0, 30), [chats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Loading activity…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">No recent chats</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1" data-testid="chat-activity-list">
|
||||
{rows.map((c) => {
|
||||
const inProgress = isStreamingById.get(c.id) === true;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className="w-full text-left px-3 py-2 flex items-center justify-between gap-2 rounded-md hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lighter)] transition-colors"
|
||||
onClick={() => {
|
||||
onSelect?.();
|
||||
selectChat({ chatId: c.id, appId: c.appId });
|
||||
}}
|
||||
data-testid={`chat-activity-list-item-${c.id}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{c.title ?? `Chat #${c.id}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{apps.apps.find((a) => a.id === c.appId)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{inProgress ? (
|
||||
<div className="flex items-center text-purple-600">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-emerald-600">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import {
|
||||
chatInputValueAtom,
|
||||
chatMessagesAtom,
|
||||
chatMessagesByIdAtom,
|
||||
selectedChatIdAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
FileChange,
|
||||
SqlQuery,
|
||||
} from "@/lib/schemas";
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { AutoApproveSwitch } from "../AutoApproveSwitch";
|
||||
@@ -80,7 +80,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const [showError, setShowError] = useState(true);
|
||||
const [isApproving, setIsApproving] = useState(false); // State for approving
|
||||
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
|
||||
const [messages, setMessages] = useAtom<Message[]>(chatMessagesAtom);
|
||||
const messagesById = useAtomValue(chatMessagesByIdAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
|
||||
const [selectedComponent, setSelectedComponent] = useAtom(
|
||||
@@ -110,7 +111,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
const { proposal, messageId } = proposalResult ?? {};
|
||||
useChatModeToggle();
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
|
||||
const disableSendButton =
|
||||
lastMessage?.role === "assistant" &&
|
||||
!lastMessage.approvalState &&
|
||||
@@ -126,12 +127,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}, [chatId, setMessagesById]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DyadCodebaseContext } from "./DyadCodebaseContext";
|
||||
import { DyadThink } from "./DyadThink";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { isStreamingByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
import { DyadOutput } from "./DyadOutput";
|
||||
import { DyadProblemSummary } from "./DyadProblemSummary";
|
||||
@@ -79,7 +79,8 @@ export const VanillaMarkdownParser = ({ content }: { content: string }) => {
|
||||
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const isStreaming = useAtomValue(isStreamingAtom);
|
||||
const chatId = useAtomValue(selectedChatIdAtom);
|
||||
const isStreaming = useAtomValue(isStreamingByIdAtom).get(chatId!) ?? false;
|
||||
// Extract content pieces (markdown and custom tags)
|
||||
const contentPieces = useMemo(() => {
|
||||
return parseCustomTags(content);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useVersions } from "@/hooks/useVersions";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { showError, showWarning } from "@/lib/toast";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatMessagesAtom } from "@/atoms/chatAtoms";
|
||||
import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
@@ -31,7 +31,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders();
|
||||
const { settings } = useSettings();
|
||||
const setMessages = useSetAtom(chatMessagesAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const [isUndoLoading, setIsUndoLoading] = useState(false);
|
||||
const [isRetryLoading, setIsRetryLoading] = useState(false);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
@@ -107,7 +107,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
await IpcClient.getInstance().getChat(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages(chat.messages);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedChatId, chat.messages);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const chat =
|
||||
@@ -120,7 +124,11 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
await IpcClient.getInstance().deleteMessages(
|
||||
selectedChatId,
|
||||
);
|
||||
setMessages([]);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedChatId, []);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { ChatActivityButton } from "@/components/chat/ChatActivity";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
@@ -44,7 +45,7 @@ const BUTTON_CLASS_NAME =
|
||||
"no-app-region-drag cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-[13px] font-medium z-10 hover:bg-[var(--background)]";
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
export const PreviewHeader = () => {
|
||||
export const ActionHeader = () => {
|
||||
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
@@ -58,7 +59,7 @@ export const PreviewHeader = () => {
|
||||
const { problemReport } = useCheckProblems(selectedAppId);
|
||||
const { restartApp, refreshAppIframe } = useRunApp();
|
||||
|
||||
const isCompact = windowWidth < 860;
|
||||
const isCompact = windowWidth < 888;
|
||||
|
||||
// Track window width
|
||||
useEffect(() => {
|
||||
@@ -257,7 +258,9 @@ export const PreviewHeader = () => {
|
||||
"publish-mode-button",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Chat activity bell */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatActivityButton />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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,
|
||||
FileAttachment,
|
||||
} from "@/ipc/ipc_types";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
chatErrorAtom,
|
||||
chatMessagesAtom,
|
||||
chatStreamCountAtom,
|
||||
isStreamingAtom,
|
||||
chatErrorByIdAtom,
|
||||
chatMessagesByIdAtom,
|
||||
chatStreamCountByIdAtom,
|
||||
isStreamingByIdAtom,
|
||||
recentStreamChatIdsAtom,
|
||||
} from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
|
||||
@@ -35,20 +36,24 @@ export function getRandomNumberId() {
|
||||
export function useStreamChat({
|
||||
hasChatId = true,
|
||||
}: { hasChatId?: boolean } = {}) {
|
||||
const [, setMessages] = useAtom(chatMessagesAtom);
|
||||
const [isStreaming, setIsStreaming] = useAtom(isStreamingAtom);
|
||||
const [error, setError] = useAtom(chatErrorAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const isStreamingById = useAtomValue(isStreamingByIdAtom);
|
||||
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
|
||||
const errorById = useAtomValue(chatErrorByIdAtom);
|
||||
const setErrorById = useSetAtom(chatErrorByIdAtom);
|
||||
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const { refreshChats } = useChats(selectedAppId);
|
||||
const { refreshApp } = useLoadApp(selectedAppId);
|
||||
const setStreamCount = useSetAtom(chatStreamCountAtom);
|
||||
|
||||
const setStreamCountById = useSetAtom(chatStreamCountByIdAtom);
|
||||
const { refreshVersions } = useVersions(selectedAppId);
|
||||
const { refreshAppIframe } = useRunApp();
|
||||
const { countTokens } = useCountTokens();
|
||||
const { refetchUserBudget } = useUserBudgetInfo();
|
||||
const { checkProblems } = useCheckProblems(selectedAppId);
|
||||
const { settings } = useSettings();
|
||||
const setRecentStreamChatIds = useSetAtom(recentStreamChatIdsAtom);
|
||||
const posthog = usePostHog();
|
||||
let chatId: number | undefined;
|
||||
|
||||
@@ -79,8 +84,22 @@ export function useStreamChat({
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsStreaming(true);
|
||||
setRecentStreamChatIds((prev) => {
|
||||
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;
|
||||
try {
|
||||
@@ -91,11 +110,19 @@ export function useStreamChat({
|
||||
attachments,
|
||||
onUpdate: (updatedMessages: Message[]) => {
|
||||
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;
|
||||
}
|
||||
|
||||
setMessages(updatedMessages);
|
||||
setMessagesById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, updatedMessages);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onEnd: (response: ChatResponseEnd) => {
|
||||
if (response.updatedFiles) {
|
||||
@@ -117,7 +144,11 @@ export function useStreamChat({
|
||||
refetchUserBudget();
|
||||
|
||||
// Keep the same as below
|
||||
setIsStreaming(false);
|
||||
setIsStreamingById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, false);
|
||||
return next;
|
||||
});
|
||||
refreshChats();
|
||||
refreshApp();
|
||||
refreshVersions();
|
||||
@@ -125,10 +156,18 @@ export function useStreamChat({
|
||||
},
|
||||
onError: (errorMessage: string) => {
|
||||
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
|
||||
setIsStreaming(false);
|
||||
setIsStreamingById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chatId, false);
|
||||
return next;
|
||||
});
|
||||
refreshChats();
|
||||
refreshApp();
|
||||
refreshVersions();
|
||||
@@ -137,13 +176,25 @@ export function useStreamChat({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CHAT] Exception during streaming setup:", error);
|
||||
setIsStreaming(false);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setIsStreamingById((prev) => {
|
||||
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,
|
||||
setIsStreaming,
|
||||
setMessagesById,
|
||||
setIsStreamingById,
|
||||
setIsPreviewOpen,
|
||||
checkProblems,
|
||||
selectedAppId,
|
||||
@@ -154,9 +205,25 @@ export function useStreamChat({
|
||||
|
||||
return {
|
||||
streamMessage,
|
||||
isStreaming,
|
||||
error,
|
||||
setError,
|
||||
setIsStreaming,
|
||||
isStreaming:
|
||||
hasChatId && chatId !== undefined
|
||||
? (isStreamingById.get(chatId) ?? false)
|
||||
: 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 { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { versionsListAtom } from "@/atoms/appAtoms";
|
||||
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 type { RevertVersionResponse, Version } from "@/ipc/ipc_types";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
||||
export function useVersions(appId: number | null) {
|
||||
const [, setVersionsAtom] = useAtom(versionsListAtom);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const [, setMessages] = useAtom(chatMessagesAtom);
|
||||
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
@@ -67,7 +67,11 @@ export function useVersions(appId: number | null) {
|
||||
});
|
||||
if (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({
|
||||
queryKey: ["problems", appId],
|
||||
|
||||
@@ -206,7 +206,6 @@ export function registerChatStreamHandlers() {
|
||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||
try {
|
||||
const fileUploadsState = FileUploadsState.getInstance();
|
||||
fileUploadsState.initialize({ chatId: req.chatId });
|
||||
|
||||
// Create an AbortController for this stream
|
||||
const abortController = new AbortController();
|
||||
@@ -288,10 +287,13 @@ export function registerChatStreamHandlers() {
|
||||
// For upload-to-codebase, create a unique file ID and store the mapping
|
||||
const fileId = `DYAD_ATTACHMENT_${index}`;
|
||||
|
||||
fileUploadsState.addFileUpload(fileId, {
|
||||
filePath,
|
||||
originalName: attachment.name,
|
||||
});
|
||||
fileUploadsState.addFileUpload(
|
||||
{ chatId: req.chatId, fileId },
|
||||
{
|
||||
filePath,
|
||||
originalName: attachment.name,
|
||||
},
|
||||
);
|
||||
|
||||
// Add instruction for AI to use dyad-write tag
|
||||
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
|
||||
? `[Request ID: ${dyadRequestId}] `
|
||||
: "";
|
||||
event.sender.send(
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error from the AI: ${requestIdPrefix}${message}`,
|
||||
);
|
||||
event.sender.send("chat:response:error", {
|
||||
chatId: req.chatId,
|
||||
error: `Sorry, there was an error from the AI: ${requestIdPrefix}${message}`,
|
||||
});
|
||||
// Clean up the abort controller
|
||||
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 ({
|
||||
fullResponse,
|
||||
}: {
|
||||
@@ -823,6 +827,16 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
}
|
||||
// Store the current partial response
|
||||
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
|
||||
const currentMessages = [...updatedChat.messages];
|
||||
@@ -1143,11 +1157,10 @@ ${problemReport.problems
|
||||
});
|
||||
|
||||
if (status.error) {
|
||||
safeSend(
|
||||
event.sender,
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error applying the AI's changes: ${status.error}`,
|
||||
);
|
||||
safeSend(event.sender, "chat:response:error", {
|
||||
chatId: req.chatId,
|
||||
error: `Sorry, there was an error applying the AI's changes: ${status.error}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Signal that the stream has completed
|
||||
@@ -1190,15 +1203,14 @@ ${problemReport.problems
|
||||
return req.chatId;
|
||||
} catch (error) {
|
||||
logger.error("Error calling LLM:", error);
|
||||
safeSend(
|
||||
event.sender,
|
||||
"chat:response:error",
|
||||
`Sorry, there was an error processing your request: ${error}`,
|
||||
);
|
||||
safeSend(event.sender, "chat:response:error", {
|
||||
chatId: req.chatId,
|
||||
error: `Sorry, there was an error processing your request: ${error}`,
|
||||
});
|
||||
// Clean up the abort controller
|
||||
activeStreams.delete(req.chatId);
|
||||
// Clean up file uploads state on error
|
||||
FileUploadsState.getInstance().clear();
|
||||
FileUploadsState.getInstance().clear(req.chatId);
|
||||
return "error";
|
||||
}
|
||||
});
|
||||
@@ -1222,6 +1234,11 @@ ${problemReport.problems
|
||||
updatedFiles: false,
|
||||
} satisfies ChatResponseEnd);
|
||||
|
||||
// Clean up uploads state for this chat
|
||||
try {
|
||||
FileUploadsState.getInstance().clear(chatId);
|
||||
} catch {}
|
||||
|
||||
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");
|
||||
if (typeof error === "string") {
|
||||
for (const [chatId, callbacks] of this.chatStreams.entries()) {
|
||||
if (
|
||||
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);
|
||||
this.chatStreams.delete(chatId);
|
||||
} else {
|
||||
console.warn(
|
||||
`[IPC] No callbacks found for chat ${chatId} on error`,
|
||||
this.chatStreams,
|
||||
);
|
||||
}
|
||||
} 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 fileUploadsMap = fileUploadsState.getFileUploadsForChat(chatId);
|
||||
fileUploadsState.clear();
|
||||
fileUploadsState.clear(chatId);
|
||||
logger.log("processFullResponseActions for chatId", chatId);
|
||||
// Get the app associated with the chat
|
||||
const chatWithApp = await db.query.chats.findFirst({
|
||||
|
||||
@@ -9,8 +9,8 @@ export interface FileUploadInfo {
|
||||
|
||||
export class FileUploadsState {
|
||||
private static instance: FileUploadsState;
|
||||
private currentChatId: number | null = null;
|
||||
private fileUploadsMap = new Map<string, FileUploadInfo>();
|
||||
// Map of chatId -> (fileId -> fileInfo)
|
||||
private uploadsByChat = new Map<number, Map<string, FileUploadInfo>>();
|
||||
|
||||
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 {
|
||||
this.currentChatId = chatId;
|
||||
this.fileUploadsMap.clear();
|
||||
logger.debug(`Initialized file uploads state for chat ${chatId}`);
|
||||
private ensureChat(chatId: number): Map<string, FileUploadInfo> {
|
||||
let map = this.uploadsByChat.get(chatId);
|
||||
if (!map) {
|
||||
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 {
|
||||
this.fileUploadsMap.set(fileId, fileInfo);
|
||||
logger.log(`Added file upload: ${fileId} -> ${fileInfo.originalName}`);
|
||||
public addFileUpload(
|
||||
{ chatId, fileId }: { chatId: number; fileId: string },
|
||||
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> {
|
||||
if (this.currentChatId !== chatId) {
|
||||
return new Map();
|
||||
}
|
||||
return new Map(this.fileUploadsMap);
|
||||
const map = this.uploadsByChat.get(chatId);
|
||||
return new Map(map ?? []);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return this.currentChatId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current state
|
||||
*/
|
||||
public clear(): void {
|
||||
this.currentChatId = null;
|
||||
this.fileUploadsMap.clear();
|
||||
logger.debug("Cleared file uploads state");
|
||||
public clearAll(): void {
|
||||
this.uploadsByChat.clear();
|
||||
logger.debug("Cleared all file uploads state");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CANNED_MESSAGE, createStreamChunk } from ".";
|
||||
let globalCounter = 0;
|
||||
|
||||
export const createChatCompletionHandler =
|
||||
(prefix: string) => (req: Request, res: Response) => {
|
||||
(prefix: string) => async (req: Request, res: Response) => {
|
||||
const { stream = false, messages = [] } = req.body;
|
||||
console.log("* Received messages", messages);
|
||||
|
||||
@@ -42,6 +42,14 @@ DYAD_ATTACHMENT_0
|
||||
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
|
||||
if (
|
||||
lastMessage &&
|
||||
@@ -145,7 +153,8 @@ export default Index;
|
||||
typeof lastMessage.content === "string" &&
|
||||
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(
|
||||
__dirname,
|
||||
"..",
|
||||
@@ -162,7 +171,7 @@ export default Index;
|
||||
messageContent = fs.readFileSync(testFilePath, "utf-8");
|
||||
console.log(`* Loaded test case: ${testCaseName}`);
|
||||
} 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`;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user