mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-15 11:56:01 +00:00
fix(frontend): reset active chat after deletion (#3519)
This commit is contained in:
@@ -25,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||||
import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks";
|
import {
|
||||||
|
useThreadMetadata,
|
||||||
|
useThreadStream,
|
||||||
|
useThreadTokenUsage,
|
||||||
|
} from "@/core/threads/hooks";
|
||||||
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -54,6 +58,10 @@ export default function AgentChatPage() {
|
|||||||
isNewThread || isMock ? undefined : threadId,
|
isNewThread || isMock ? undefined : threadId,
|
||||||
{ enabled: tokenUsageEnabled && !isMock },
|
{ enabled: tokenUsageEnabled && !isMock },
|
||||||
);
|
);
|
||||||
|
const threadMetadata = useThreadMetadata(threadId, {
|
||||||
|
enabled: !isNewThread && !isMock,
|
||||||
|
isMock,
|
||||||
|
});
|
||||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
@@ -106,6 +114,34 @@ export default function AgentChatPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasThreadMessages = thread.messages.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isNewThread &&
|
||||||
|
!isMock &&
|
||||||
|
threadMetadata.data === null &&
|
||||||
|
!threadMetadata.isLoading &&
|
||||||
|
!threadMetadata.isFetching &&
|
||||||
|
!isHistoryLoading &&
|
||||||
|
!hasMoreHistory &&
|
||||||
|
!hasThreadMessages
|
||||||
|
) {
|
||||||
|
router.replace(`/workspace/agents/${agent_name}/chats/new`);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agent_name,
|
||||||
|
hasMoreHistory,
|
||||||
|
hasThreadMessages,
|
||||||
|
isHistoryLoading,
|
||||||
|
isMock,
|
||||||
|
isNewThread,
|
||||||
|
router,
|
||||||
|
threadMetadata.data,
|
||||||
|
threadMetadata.isFetching,
|
||||||
|
threadMetadata.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
const sendPromise = sendMessage(threadId, message, { agent_name });
|
const sendPromise = sendMessage(threadId, message, { agent_name });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
@@ -24,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||||
import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks";
|
import {
|
||||||
|
useThreadMetadata,
|
||||||
|
useThreadStream,
|
||||||
|
useThreadTokenUsage,
|
||||||
|
} from "@/core/threads/hooks";
|
||||||
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
// `isNewThread` tracks whether the backend has the thread yet — gates the
|
// `isNewThread` tracks whether the backend has the thread yet — gates the
|
||||||
@@ -47,6 +53,10 @@ export default function ChatPage() {
|
|||||||
isNewThread || isMock ? undefined : threadId,
|
isNewThread || isMock ? undefined : threadId,
|
||||||
{ enabled: tokenUsageEnabled && !isMock },
|
{ enabled: tokenUsageEnabled && !isMock },
|
||||||
);
|
);
|
||||||
|
const threadMetadata = useThreadMetadata(threadId, {
|
||||||
|
enabled: !isNewThread && !isMock,
|
||||||
|
isMock,
|
||||||
|
});
|
||||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
@@ -108,6 +118,33 @@ export default function ChatPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasThreadMessages = thread.messages.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isNewThread &&
|
||||||
|
!isMock &&
|
||||||
|
threadMetadata.data === null &&
|
||||||
|
!threadMetadata.isLoading &&
|
||||||
|
!threadMetadata.isFetching &&
|
||||||
|
!isHistoryLoading &&
|
||||||
|
!hasMoreHistory &&
|
||||||
|
!hasThreadMessages
|
||||||
|
) {
|
||||||
|
router.replace("/workspace/chats/new");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasMoreHistory,
|
||||||
|
hasThreadMessages,
|
||||||
|
isHistoryLoading,
|
||||||
|
isMock,
|
||||||
|
isNewThread,
|
||||||
|
router,
|
||||||
|
threadMetadata.data,
|
||||||
|
threadMetadata.isFetching,
|
||||||
|
threadMetadata.isLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
const sendPromise = sendMessage(threadId, message);
|
const sendPromise = sendMessage(threadId, message);
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
|
export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset";
|
||||||
|
|
||||||
|
type ThreadChatResetDetail = {
|
||||||
|
deletedThreadId: string;
|
||||||
|
nextPath: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resetThreadChatAfterDelete(detail: ThreadChatResetDetail) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<ThreadChatResetDetail>(THREAD_CHAT_RESET_EVENT, {
|
||||||
|
detail,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useThreadChat() {
|
export function useThreadChat() {
|
||||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -30,6 +49,13 @@ export function useThreadChat() {
|
|||||||
() => threadIdFromPath === "new",
|
() => threadIdFromPath === "new",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetToNewThread = useCallback(() => {
|
||||||
|
const nextThreadId = uuid();
|
||||||
|
newThreadIdRef.current = nextThreadId;
|
||||||
|
setIsNewThreadState(true);
|
||||||
|
setThreadIdState(nextThreadId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNewPath) {
|
if (isNewPath) {
|
||||||
const nextThreadId = newThreadIdRef.current ?? uuid();
|
const nextThreadId = newThreadIdRef.current ?? uuid();
|
||||||
@@ -51,6 +77,35 @@ export function useThreadChat() {
|
|||||||
setThreadIdState(threadIdFromPath);
|
setThreadIdState(threadIdFromPath);
|
||||||
}, [isNewPath, threadIdFromPath]);
|
}, [isNewPath, threadIdFromPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReset = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<ThreadChatResetDetail>).detail;
|
||||||
|
if (!detail?.nextPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPathname = window.location.pathname;
|
||||||
|
const isDeletingCurrentThread =
|
||||||
|
detail.force === true ||
|
||||||
|
detail.deletedThreadId === threadId ||
|
||||||
|
detail.deletedThreadId === threadIdFromPath ||
|
||||||
|
currentPathname.endsWith(`/${detail.deletedThreadId}`);
|
||||||
|
|
||||||
|
if (!isDeletingCurrentThread) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL replacement is owned by the caller's Next router action; this hook
|
||||||
|
// only resets local chat state so the router state and browser URL stay
|
||||||
|
// in sync.
|
||||||
|
resetToNewThread();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(THREAD_CHAT_RESET_EVENT, handleReset);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(THREAD_CHAT_RESET_EVENT, handleReset);
|
||||||
|
}, [resetToNewThread, threadId, threadIdFromPath]);
|
||||||
|
|
||||||
const setThreadId = useCallback((nextThreadId: string) => {
|
const setThreadId = useCallback((nextThreadId: string) => {
|
||||||
newThreadIdRef.current = null;
|
newThreadIdRef.current = null;
|
||||||
setThreadIdState(nextThreadId);
|
setThreadIdState(nextThreadId);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { writeTextToClipboard } from "@/core/clipboard";
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -112,24 +113,41 @@ export function RecentChatList() {
|
|||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(threadId: string) => {
|
(thread: AgentThread) => {
|
||||||
deleteThread({ threadId });
|
const currentPathname =
|
||||||
if (threadId === threadIdFromPath) {
|
typeof window === "undefined" ? pathname : window.location.pathname;
|
||||||
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
const threadPath = pathOfThread(thread);
|
||||||
let nextThreadPath = pathOfThread("new", {
|
const nextThreadPath = pathOfThread("new", {
|
||||||
agent_name: agentNameFromPath,
|
agent_name: agentNameFromPath,
|
||||||
});
|
});
|
||||||
if (threadIndex > -1) {
|
const isNewThreadPath = currentPathname === nextThreadPath;
|
||||||
if (threads[threadIndex + 1]) {
|
const isCurrentThread =
|
||||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
thread.thread_id === threadIdFromPath ||
|
||||||
} else if (threads[threadIndex - 1]) {
|
threadPath === currentPathname ||
|
||||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
(isNewThreadPath && threads[0]?.thread_id === thread.thread_id);
|
||||||
}
|
|
||||||
}
|
deleteThread({
|
||||||
void router.push(nextThreadPath);
|
threadId: thread.thread_id,
|
||||||
|
onRemoteDeleted: isCurrentThread
|
||||||
|
? () => {
|
||||||
|
resetThreadChatAfterDelete({
|
||||||
|
deletedThreadId: thread.thread_id,
|
||||||
|
nextPath: nextThreadPath,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
void router.replace(nextThreadPath);
|
||||||
}
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[agentNameFromPath, deleteThread, router, threadIdFromPath, threads],
|
[
|
||||||
|
agentNameFromPath,
|
||||||
|
deleteThread,
|
||||||
|
pathname,
|
||||||
|
router,
|
||||||
|
threadIdFromPath,
|
||||||
|
threads,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRenameClick = useCallback(
|
const handleRenameClick = useCallback(
|
||||||
@@ -302,7 +320,7 @@ export function RecentChatList() {
|
|||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleDelete(thread.thread_id)}
|
onSelect={() => handleDelete(thread)}
|
||||||
>
|
>
|
||||||
<Trash2 className="text-muted-foreground" />
|
<Trash2 className="text-muted-foreground" />
|
||||||
<span>{t.common.delete}</span>
|
<span>{t.common.delete}</span>
|
||||||
|
|||||||
@@ -399,6 +399,34 @@ function getStreamErrorMessage(error: unknown): string {
|
|||||||
return "Request failed.";
|
return "Request failed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHttpStatus(error: unknown): number | undefined {
|
||||||
|
if (typeof error !== "object" || error === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = Reflect.get(error, "status");
|
||||||
|
if (typeof status === "number") {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = Reflect.get(error, "response");
|
||||||
|
if (typeof response === "object" && response !== null) {
|
||||||
|
const responseStatus = Reflect.get(response, "status");
|
||||||
|
if (typeof responseStatus === "number") {
|
||||||
|
return responseStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThreadMissingError(error: unknown): boolean {
|
||||||
|
const status = getHttpStatus(error);
|
||||||
|
// Treat 403 like 404 here to avoid disclosing whether an inaccessible thread
|
||||||
|
// exists; callers redirect stale/inaccessible URLs back to a blank chat.
|
||||||
|
return status === 403 || status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
export function useThreadStream({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
displayThreadId,
|
displayThreadId,
|
||||||
@@ -1193,12 +1221,22 @@ export function useThreadHistory(
|
|||||||
return dedupeMessagesByIdentity([...prev, ..._messages]);
|
return dedupeMessagesByIdentity([...prev, ..._messages]);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
const hasThreadId = Boolean(threadId);
|
||||||
|
const hasUnloadedRuns = Boolean(
|
||||||
|
runs.data?.some((run) => !loadedRunIdsRef.current.has(run.run_id)),
|
||||||
|
);
|
||||||
|
const isRunsLoading =
|
||||||
|
enabled &&
|
||||||
|
hasThreadId &&
|
||||||
|
(runs.isLoading || (runs.isFetching && !runs.data));
|
||||||
|
const isRunsUnresolved =
|
||||||
|
enabled && hasThreadId && !runs.data && !runs.isError;
|
||||||
const hasMore =
|
const hasMore =
|
||||||
enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data);
|
enabled && hasThreadId && (indexRef.current >= 0 || hasUnloadedRuns);
|
||||||
return {
|
return {
|
||||||
runs: runs.data,
|
runs: runs.data,
|
||||||
messages,
|
messages,
|
||||||
loading,
|
loading: loading || isRunsLoading || isRunsUnresolved,
|
||||||
appendMessages,
|
appendMessages,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore: loadMessages,
|
loadMore: loadMessages,
|
||||||
@@ -1313,6 +1351,36 @@ export function useThreadRuns(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useThreadMetadata(
|
||||||
|
threadId?: string | null,
|
||||||
|
{
|
||||||
|
enabled = true,
|
||||||
|
isMock = false,
|
||||||
|
}: { enabled?: boolean; isMock?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const apiClient = getAPIClient(isMock);
|
||||||
|
return useQuery<AgentThread | null>({
|
||||||
|
queryKey: ["thread", "metadata", threadId, isMock],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!threadId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.threads.get(threadId);
|
||||||
|
return response as AgentThread;
|
||||||
|
} catch (error) {
|
||||||
|
if (isThreadMissingError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(threadId),
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useThreadTokenUsage(
|
export function useThreadTokenUsage(
|
||||||
threadId?: string | null,
|
threadId?: string | null,
|
||||||
{ enabled = true }: { enabled?: boolean } = {},
|
{ enabled = true }: { enabled?: boolean } = {},
|
||||||
@@ -1347,8 +1415,15 @@ export function useDeleteThread() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ threadId }: { threadId: string }) => {
|
mutationFn: async ({
|
||||||
|
threadId,
|
||||||
|
onRemoteDeleted,
|
||||||
|
}: {
|
||||||
|
threadId: string;
|
||||||
|
onRemoteDeleted?: () => void;
|
||||||
|
}) => {
|
||||||
await apiClient.threads.delete(threadId);
|
await apiClient.threads.delete(threadId);
|
||||||
|
onRemoteDeleted?.();
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||||
|
|||||||
@@ -65,6 +65,36 @@ test.describe("Thread history", () => {
|
|||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deleting an inactive chat keeps the current chat open", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, { threads: THREADS });
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Response in thread First conversation"),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||||
|
const inactiveThreadItem = sidebar
|
||||||
|
.locator("[data-sidebar='menu-item']")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("button", { name: /more/i }),
|
||||||
|
hasText: "Second conversation",
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await expect(inactiveThreadItem).toBeVisible();
|
||||||
|
await inactiveThreadItem.hover();
|
||||||
|
await inactiveThreadItem.getByRole("button", { name: /more/i }).click();
|
||||||
|
await page.getByRole("menuitem", { name: /delete/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
|
||||||
|
await expect(
|
||||||
|
page.getByText("Response in thread First conversation"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(sidebar.getByText("Second conversation")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("new chat does not show previous thread messages after client-side navigation", async ({
|
test("new chat does not show previous thread messages after client-side navigation", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -100,7 +130,9 @@ test.describe("Thread history", () => {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole("link", { name: /new chat/i }).click();
|
await page
|
||||||
|
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
|
||||||
|
.click();
|
||||||
await page.waitForURL("**/workspace/chats/new");
|
await page.waitForURL("**/workspace/chats/new");
|
||||||
|
|
||||||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
||||||
@@ -161,13 +193,64 @@ test.describe("Thread history", () => {
|
|||||||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole("link", { name: /new chat/i }).click();
|
await page
|
||||||
|
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
|
||||||
|
.click();
|
||||||
await page.waitForURL("**/workspace/chats/new");
|
await page.waitForURL("**/workspace/chats/new");
|
||||||
|
|
||||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deleting the active newly created chat returns to the new chat screen", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
await page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "Local cleanup failed" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
await textarea.fill("What should disappear after deletion?");
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||||
|
const recentThreadItem = sidebar
|
||||||
|
.locator("[data-sidebar='menu-item']")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("button", { name: /more/i }),
|
||||||
|
hasText: "New Chat",
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await expect(recentThreadItem).toBeVisible();
|
||||||
|
await recentThreadItem.hover();
|
||||||
|
await recentThreadItem.getByRole("button", { name: /more/i }).click();
|
||||||
|
await page.getByRole("menuitem", { name: /delete/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
|
||||||
|
await expect(page.getByText("Previous question")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
|
||||||
|
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await page.waitForURL("**/workspace/chats/new");
|
||||||
|
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
|
||||||
|
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("mock thread does not load real backend run history", async ({
|
test("mock thread does not load real backend run history", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
|||||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
||||||
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||||
|
|
||||||
|
const MOCK_AUTH_USER = {
|
||||||
|
id: "default",
|
||||||
|
email: "default@test.local",
|
||||||
|
system_role: "admin",
|
||||||
|
needs_setup: false,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -71,6 +78,21 @@ const DEFAULT_SKILLS: MockSkill[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function mockStreamMessages() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "msg-human-1",
|
||||||
|
content: [{ type: "text", text: "Hello" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "msg-ai-1",
|
||||||
|
content: "Hello from DeerFlow!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// mockLangGraphAPI
|
// mockLangGraphAPI
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -81,23 +103,84 @@ const DEFAULT_SKILLS: MockSkill[] = [
|
|||||||
* for a real backend.
|
* for a real backend.
|
||||||
*/
|
*/
|
||||||
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||||
const threads = options?.threads ?? [];
|
let threads = [...(options?.threads ?? [])];
|
||||||
const agents = options?.agents ?? [];
|
const agents = options?.agents ?? [];
|
||||||
const skills = options?.skills ?? DEFAULT_SKILLS;
|
const skills = options?.skills ?? DEFAULT_SKILLS;
|
||||||
|
|
||||||
// Thread search — sidebar thread list & chats list page
|
const upsertThread = (thread: MockThread) => {
|
||||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
threads = [
|
||||||
const body = threads.map((t) => ({
|
thread,
|
||||||
thread_id: t.thread_id,
|
...threads.filter((existing) => existing.thread_id !== thread.thread_id),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const threadSearchResult = (thread: MockThread) => ({
|
||||||
|
thread_id: thread.thread_id,
|
||||||
created_at: "2025-01-01T00:00:00Z",
|
created_at: "2025-01-01T00:00:00Z",
|
||||||
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
updated_at: thread.updated_at ?? "2025-01-01T00:00:00Z",
|
||||||
metadata: {
|
metadata: {
|
||||||
...(t.metadata ?? {}),
|
...(thread.metadata ?? {}),
|
||||||
...(t.agent_name ? { agent_name: t.agent_name } : {}),
|
...(thread.agent_name ? { agent_name: thread.agent_name } : {}),
|
||||||
},
|
},
|
||||||
status: "idle",
|
status: "idle",
|
||||||
values: { title: t.title ?? "Untitled" },
|
values: { title: thread.title ?? "Untitled" },
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Auth — keep workspace tests independent from a real gateway session.
|
||||||
|
void page.route("**/api/v1/auth/me", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(MOCK_AUTH_USER),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.route("**/api/v1/auth/setup-status", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ needs_setup: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.route("**/api/v1/auth/logout", (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204 });
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.route("**/api/channels/providers", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ enabled: false, providers: [] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.route("**/api/channels/connections", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ connections: [] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thread search — sidebar thread list & chats list page
|
||||||
|
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||||
|
const body = threads.map(threadSearchResult);
|
||||||
|
|
||||||
let limit: number | undefined;
|
let limit: number | undefined;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -131,6 +214,12 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
// Thread create — called when user sends first message in a new chat
|
// Thread create — called when user sends first message in a new chat
|
||||||
void page.route("**/api/langgraph/threads", (route) => {
|
void page.route("**/api/langgraph/threads", (route) => {
|
||||||
if (route.request().method() === "POST") {
|
if (route.request().method() === "POST") {
|
||||||
|
upsertThread({
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "New Chat",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
messages: mockStreamMessages(),
|
||||||
|
});
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@@ -149,6 +238,26 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
|
|
||||||
// Thread update (PATCH) — metadata update after creation
|
// Thread update (PATCH) — metadata update after creation
|
||||||
void page.route("**/api/langgraph/threads/*", (route) => {
|
void page.route("**/api/langgraph/threads/*", (route) => {
|
||||||
|
const threadId = decodeURIComponent(
|
||||||
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||||
|
);
|
||||||
|
const matchingThread = threads.find(
|
||||||
|
(thread) => thread.thread_id === threadId,
|
||||||
|
);
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
if (!matchingThread) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "Thread not found" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(threadSearchResult(matchingThread)),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (route.request().method() === "PATCH") {
|
if (route.request().method() === "PATCH") {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -156,6 +265,21 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
threads = threads.filter((thread) => thread.thread_id !== threadId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
}
|
||||||
return route.fallback();
|
return route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,8 +423,21 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Run stream — returns a minimal SSE response with an AI message
|
// Run stream — returns a minimal SSE response with an AI message
|
||||||
void page.route("**/api/langgraph/runs/stream", handleRunStream);
|
const handleMockRunStream = (route: Route) => {
|
||||||
void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream);
|
upsertThread({
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "New Chat",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
messages: mockStreamMessages(),
|
||||||
|
});
|
||||||
|
return handleRunStream(route);
|
||||||
|
};
|
||||||
|
|
||||||
|
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
|
||||||
|
void page.route(
|
||||||
|
"**/api/langgraph/threads/*/runs/stream",
|
||||||
|
handleMockRunStream,
|
||||||
|
);
|
||||||
|
|
||||||
// Models list — model picker dropdown
|
// Models list — model picker dropdown
|
||||||
void page.route("**/api/models", (route) => {
|
void page.route("**/api/models", (route) => {
|
||||||
@@ -391,18 +528,7 @@ export function handleRunStream(route: Route) {
|
|||||||
{
|
{
|
||||||
event: "values",
|
event: "values",
|
||||||
data: {
|
data: {
|
||||||
messages: [
|
messages: mockStreamMessages(),
|
||||||
{
|
|
||||||
type: "human",
|
|
||||||
id: "msg-human-1",
|
|
||||||
content: [{ type: "text", text: "Hello" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "ai",
|
|
||||||
id: "msg-ai-1",
|
|
||||||
content: "Hello from DeerFlow!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ event: "end", data: {} },
|
{ event: "end", data: {} },
|
||||||
|
|||||||
Reference in New Issue
Block a user