Unverified 提交 75867f6e authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

Separate ChatInput Prompts per-chat session (#3129)

Chat input state management to use per-chat values Closes #3128 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3129" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
上级 61c49948
import { test, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("chat input is preserved when switching between chats", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Chat 1: send a message so it becomes a real chat
await po.sendPrompt("[dump] first chat setup");
await po.chatActions.waitForChatCompletion();
// Type some text in Chat 1's input without sending
const chatInput = po.chatActions.getChatInput();
await expect(chatInput).toBeVisible();
await chatInput.fill("unsent text in chat one");
await expect(chatInput).toContainText("unsent text in chat one");
// Create Chat 2
await po.chatActions.clickNewChat();
await expect(chatInput).toBeVisible();
await po.sendPrompt("[dump] second chat setup");
await po.chatActions.waitForChatCompletion();
// Type different text in Chat 2
await chatInput.fill("unsent text in chat two");
await expect(chatInput).toContainText("unsent text in chat two");
// Switch to Chat 1 via the inactive tab
const inactiveTab = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab.locator("button").first().click();
// Chat 1 should still have its unsent text
await expect(chatInput).toContainText("unsent text in chat one", {
timeout: Timeout.MEDIUM,
});
// Switch back to Chat 2 (now the inactive tab)
const inactiveTab2 = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab2.locator("button").first().click();
// Chat 2 should still have its unsent text
await expect(chatInput).toContainText("unsent text in chat two", {
timeout: Timeout.MEDIUM,
});
});
test("new chat starts with empty input", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Chat 1: type something
await po.sendPrompt("[dump] initial message");
await po.chatActions.waitForChatCompletion();
const chatInput = po.chatActions.getChatInput();
await chatInput.fill("some draft text");
await expect(chatInput).toContainText("some draft text");
// Create a new chat
await po.chatActions.clickNewChat();
// New chat input should be empty
await expect(chatInput).toBeVisible({ timeout: Timeout.SHORT });
await expect(async () => {
const text = await chatInput.textContent();
expect(text?.trim()).toBe("");
}).toPass({ timeout: Timeout.SHORT });
});
test("closing a chat tab clears its stored input", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Chat 1
await po.sendPrompt("[dump] chat one message");
await po.chatActions.waitForChatCompletion();
// Chat 2
await po.chatActions.clickNewChat();
const chatInput = po.chatActions.getChatInput();
await expect(chatInput).toBeVisible();
await po.sendPrompt("[dump] chat two message");
await po.chatActions.waitForChatCompletion();
// Type in Chat 2
await chatInput.fill("draft in chat two");
await expect(chatInput).toContainText("draft in chat two");
// Close the active tab (Chat 2) using the close button on the active tab
const activeTabContainer = po.page
.locator("div[draggable]")
.filter({ has: po.page.locator('button[aria-current="page"]') });
await activeTabContainer.getByLabel(/^Close tab:/).click();
// Should now be on Chat 1 — input should not contain Chat 2's text
await expect(chatInput).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(po.page.getByText("chat one message")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(async () => {
const text = await chatInput.textContent();
expect(text?.trim() ?? "").not.toContain("draft in chat two");
}).toPass({ timeout: Timeout.MEDIUM });
});
test("input preserved when switching back and forth multiple times", async ({
po,
}) => {
await po.setUp({ autoApprove: true });
const chatInput = po.chatActions.getChatInput();
// Chat 1
await po.sendPrompt("[dump] chat alpha");
await po.chatActions.waitForChatCompletion();
await expect(chatInput).toBeVisible();
await chatInput.fill("draft-alpha");
await expect(chatInput).toContainText("draft-alpha");
// Chat 2
await po.chatActions.clickNewChat();
await expect(chatInput).toBeVisible();
await po.sendPrompt("[dump] chat beta");
await po.chatActions.waitForChatCompletion();
await chatInput.fill("draft-beta");
await expect(chatInput).toContainText("draft-beta");
// We're on Chat 2. Switch to Chat 1 (inactive tab).
const getInactiveTab = () =>
po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await getInactiveTab().locator("button").first().click();
await expect(chatInput).toContainText("draft-alpha", {
timeout: Timeout.MEDIUM,
});
// Switch back to Chat 2
await getInactiveTab().locator("button").first().click();
await expect(chatInput).toContainText("draft-beta", {
timeout: Timeout.MEDIUM,
});
// Switch to Chat 1 again — still preserved after multiple switches
await getInactiveTab().locator("button").first().click();
await expect(chatInput).toContainText("draft-alpha", {
timeout: Timeout.MEDIUM,
});
});
......@@ -15,7 +15,6 @@ import {
import { useSettings } from "@/hooks/useSettings";
import { DEFAULT_ZOOM_LEVEL } from "@/lib/schemas";
import { selectedComponentsPreviewAtom } from "@/atoms/previewAtoms";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { usePlanEvents } from "@/hooks/usePlanEvents";
import { useZoomShortcuts } from "@/hooks/useZoomShortcuts";
import { useQueueProcessor } from "@/hooks/useQueueProcessor";
......@@ -31,7 +30,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
const setSelectedComponentsPreview = useSetAtom(
selectedComponentsPreviewAtom,
);
const setChatInput = useSetAtom(chatInputValueAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
......@@ -100,7 +98,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
}, [refreshAppIframe, previewMode]);
useEffect(() => {
setChatInput("");
setSelectedComponentsPreview([]);
setConsoleEntries([]);
}, [selectedAppId]);
......
......@@ -16,7 +16,27 @@ export const chatErrorByIdAtom = atom<Map<number, string | null>>(new Map());
export const selectedChatIdAtom = atom<number | null>(null);
export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map());
export const chatInputValueAtom = atom<string>("");
export const chatInputValuesByIdAtom = atom<Map<number, string>>(new Map());
export const chatInputValueAtom = atom(
(get) => {
const chatId = get(selectedChatIdAtom);
if (chatId === null) return "";
return get(chatInputValuesByIdAtom).get(chatId) ?? "";
},
(get, set, newValue: string | ((prev: string) => string)) => {
const chatId = get(selectedChatIdAtom);
// Intentionally a no-op when no chat is selected (e.g. before the URL
// sync effect in chat.tsx has run). Callers on the chat page always have
// a valid chatId by the time they write, so no queuing is needed.
if (chatId === null) return;
const currentMap = get(chatInputValuesByIdAtom);
const prev = currentMap.get(chatId) ?? "";
const next = typeof newValue === "function" ? newValue(prev) : newValue;
const newMap = new Map(currentMap);
newMap.set(chatId, next);
set(chatInputValuesByIdAtom, newMap);
},
);
export const homeChatInputValueAtom = atom<string>("");
export const homeSelectedAppAtom = atom<ListedApp | null>(null);
......@@ -193,6 +213,13 @@ export const removeChatIdFromAllTrackingAtom = atom(
removeFromClosedSet(get, set, chatId);
// Also remove from session tracking
removeFromSessionSet(get, set, [chatId]);
// Clear per-chat input
const inputs = get(chatInputValuesByIdAtom);
if (inputs.has(chatId)) {
const next = new Map(inputs);
next.delete(chatId);
set(chatInputValuesByIdAtom, next);
}
},
);
......
......@@ -60,9 +60,7 @@ export const ConsoleEntryComponent = (props: ConsoleEntryProps) => {
const prefix = sourceName ? `[${sourceName}]` : "";
const formattedLog = `[${time}] ${level.toUpperCase()} ${prefix}: ${message}`;
setChatInput((prev) => {
return `${prev}\n\`\`\`\n${formattedLog}\n\`\`\``;
});
setChatInput((prev) => `${prev}\n\`\`\`\n${formattedLog}\n\`\`\``);
};
// Determine styling based on log level
......
......@@ -14,6 +14,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { isPreviewOpenAtom, isChatPanelHiddenAtom } from "@/atoms/viewAtoms";
import { useChats } from "@/hooks/useChats";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { usePlanImplementation } from "@/hooks/usePlanImplementation";
const DEFAULT_CHAT_PANEL_SIZE = 50;
......@@ -25,6 +26,7 @@ export default function ChatPage() {
const [isChatPanelHidden, setIsChatPanelHidden] = useAtom(
isChatPanelHiddenAtom,
);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const [isResizing, setIsResizing] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
......@@ -32,6 +34,11 @@ export default function ChatPage() {
const previousSizeRef = useRef<number>(DEFAULT_CHAT_PANEL_SIZE);
const isInitialMountRef = useRef(true);
// Sync selectedChatIdAtom with the chatId from the URL
useEffect(() => {
setSelectedChatId(chatId ?? null);
}, [chatId, setSelectedChatId]);
// Handle plan implementation when a plan is accepted
usePlanImplementation();
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论