mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-12 02:15:58 +00:00
fix(frontend): isolate new chat thread messages (#3508)
* fix(frontend): isolate new chat thread messages * fix(frontend): keep live messages visible in new chat * fix(frontend): reset thread-local message refs
This commit is contained in:
@@ -72,20 +72,21 @@ export default function AgentChatPage() {
|
|||||||
loadMoreHistory,
|
loadMoreHistory,
|
||||||
} = useThreadStream({
|
} = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
|
displayThreadId: threadId,
|
||||||
context: { ...settings.context, agent_name: agent_name },
|
context: { ...settings.context, agent_name: agent_name },
|
||||||
isMock,
|
isMock,
|
||||||
onSend: () => {
|
onSend: () => {
|
||||||
setIsWelcomeMode(false);
|
setIsWelcomeMode(false);
|
||||||
},
|
},
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
setThreadId(createdThreadId);
|
|
||||||
setIsNewThread(false);
|
|
||||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
null,
|
||||||
"",
|
"",
|
||||||
`/workspace/agents/${agent_name}/chats/${createdThreadId}`,
|
`/workspace/agents/${agent_name}/chats/${createdThreadId}`,
|
||||||
);
|
);
|
||||||
|
setThreadId(createdThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
},
|
},
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
if (document.hidden || !document.hasFocus()) {
|
if (document.hidden || !document.hasFocus()) {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function ChatPage() {
|
|||||||
loadMoreHistory,
|
loadMoreHistory,
|
||||||
} = useThreadStream({
|
} = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
|
displayThreadId: threadId,
|
||||||
context: settings.context,
|
context: settings.context,
|
||||||
isMock,
|
isMock,
|
||||||
// onSend only animates the UI; do NOT flip `isNewThread` here — the
|
// onSend only animates the UI; do NOT flip `isNewThread` here — the
|
||||||
@@ -84,10 +85,10 @@ export default function ChatPage() {
|
|||||||
setIsWelcomeMode(false);
|
setIsWelcomeMode(false);
|
||||||
},
|
},
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
setThreadId(createdThreadId);
|
|
||||||
setIsNewThread(false);
|
|
||||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||||
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
|
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
|
||||||
|
setThreadId(createdThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
},
|
},
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
if (document.hidden || !document.hasFocus()) {
|
if (document.hidden || !document.hasFocus()) {
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
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();
|
||||||
|
const actualPathname =
|
||||||
|
typeof window === "undefined" ? pathname : window.location.pathname;
|
||||||
|
const isNewPath = actualPathname.endsWith("/new");
|
||||||
|
const newThreadIdRef = useRef<string | null>(
|
||||||
|
threadIdFromPath === "new" ? uuid() : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNewPath && !newThreadIdRef.current) {
|
||||||
|
newThreadIdRef.current = uuid();
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [threadId, setThreadId] = useState(() => {
|
const [threadId, setThreadIdState] = useState(() => {
|
||||||
return threadIdFromPath === "new" ? uuid() : threadIdFromPath;
|
return threadIdFromPath === "new"
|
||||||
|
? (newThreadIdRef.current ?? uuid())
|
||||||
|
: threadIdFromPath;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isNewThread, setIsNewThread] = useState(
|
const [isNewThreadState, setIsNewThreadState] = useState(
|
||||||
() => threadIdFromPath === "new",
|
() => threadIdFromPath === "new",
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname.endsWith("/new")) {
|
if (isNewPath) {
|
||||||
setIsNewThread(true);
|
const nextThreadId = newThreadIdRef.current ?? uuid();
|
||||||
setThreadId(uuid());
|
newThreadIdRef.current = nextThreadId;
|
||||||
|
setIsNewThreadState(true);
|
||||||
|
setThreadIdState(nextThreadId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
newThreadIdRef.current = null;
|
||||||
// Guard: after history.replaceState updates the URL from /chats/new to
|
// Guard: after history.replaceState updates the URL from /chats/new to
|
||||||
// /chats/{UUID}, Next.js useParams may still return the stale "new" value
|
// /chats/{UUID}, Next.js useParams may still return the stale "new" value
|
||||||
// because replaceState does not trigger router updates. Avoid propagating
|
// because replaceState does not trigger router updates. Avoid propagating
|
||||||
@@ -32,9 +47,28 @@ export function useThreadChat() {
|
|||||||
if (threadIdFromPath === "new") {
|
if (threadIdFromPath === "new") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsNewThread(false);
|
setIsNewThreadState(false);
|
||||||
setThreadId(threadIdFromPath);
|
setThreadIdState(threadIdFromPath);
|
||||||
}, [pathname, threadIdFromPath]);
|
}, [isNewPath, threadIdFromPath]);
|
||||||
const isMock = searchParams.get("mock") === "true";
|
|
||||||
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
const setThreadId = useCallback((nextThreadId: string) => {
|
||||||
|
newThreadIdRef.current = null;
|
||||||
|
setThreadIdState(nextThreadId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setIsNewThread = useCallback((nextIsNewThread: boolean) => {
|
||||||
|
if (!nextIsNewThread) {
|
||||||
|
newThreadIdRef.current = null;
|
||||||
|
}
|
||||||
|
setIsNewThreadState(nextIsNewThread);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isMock = searchParams.get("mock") === "true";
|
||||||
|
return {
|
||||||
|
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
|
||||||
|
setThreadId,
|
||||||
|
isNewThread: isNewPath ? true : isNewThreadState,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
@@ -41,6 +41,7 @@ export type ToolEndEvent = {
|
|||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
|
displayThreadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
onSend?: (threadId: string) => void;
|
onSend?: (threadId: string) => void;
|
||||||
@@ -53,6 +54,13 @@ type SendMessageOptions = {
|
|||||||
additionalKwargs?: Record<string, unknown>;
|
additionalKwargs?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_THREAD_VALUES: AgentThreadState = {
|
||||||
|
title: "",
|
||||||
|
messages: [],
|
||||||
|
artifacts: [],
|
||||||
|
todos: [],
|
||||||
|
};
|
||||||
|
|
||||||
function isNonEmptyString(value: string | undefined): value is string {
|
function isNonEmptyString(value: string | undefined): value is string {
|
||||||
return typeof value === "string" && value.length > 0;
|
return typeof value === "string" && value.length > 0;
|
||||||
}
|
}
|
||||||
@@ -388,6 +396,7 @@ function getStreamErrorMessage(error: unknown): string {
|
|||||||
|
|
||||||
export function useThreadStream({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
|
displayThreadId,
|
||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
onSend,
|
onSend,
|
||||||
@@ -396,6 +405,18 @@ export function useThreadStream({
|
|||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const currentViewThreadId = displayThreadId ?? threadId ?? null;
|
||||||
|
const currentViewThreadIdRef = useRef(currentViewThreadId);
|
||||||
|
currentViewThreadIdRef.current = currentViewThreadId;
|
||||||
|
// Optimistic messages shown before the server stream responds.
|
||||||
|
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||||
|
const [optimisticThreadId, setOptimisticThreadId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [liveMessagesThreadId, setLiveMessagesThreadId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||||
@@ -437,6 +458,28 @@ export function useThreadStream({
|
|||||||
|
|
||||||
const handleStreamStart = useCallback((_threadId: string, _runId: string) => {
|
const handleStreamStart = useCallback((_threadId: string, _runId: string) => {
|
||||||
threadIdRef.current = _threadId;
|
threadIdRef.current = _threadId;
|
||||||
|
setOptimisticThreadId((currentOptimisticThreadId) => {
|
||||||
|
const currentView = currentViewThreadIdRef.current;
|
||||||
|
if (
|
||||||
|
currentOptimisticThreadId &&
|
||||||
|
(currentOptimisticThreadId === currentView ||
|
||||||
|
currentOptimisticThreadId === _threadId)
|
||||||
|
) {
|
||||||
|
return _threadId;
|
||||||
|
}
|
||||||
|
return currentOptimisticThreadId;
|
||||||
|
});
|
||||||
|
setLiveMessagesThreadId((currentLiveMessagesThreadId) => {
|
||||||
|
const currentView = currentViewThreadIdRef.current;
|
||||||
|
if (
|
||||||
|
currentLiveMessagesThreadId &&
|
||||||
|
(currentLiveMessagesThreadId === currentView ||
|
||||||
|
currentLiveMessagesThreadId === _threadId)
|
||||||
|
) {
|
||||||
|
return _threadId;
|
||||||
|
}
|
||||||
|
return currentLiveMessagesThreadId;
|
||||||
|
});
|
||||||
if (!startedRef.current) {
|
if (!startedRef.current) {
|
||||||
listeners.current.onStart?.(_threadId, _runId);
|
listeners.current.onStart?.(_threadId, _runId);
|
||||||
startedRef.current = true;
|
startedRef.current = true;
|
||||||
@@ -608,6 +651,8 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
|
setOptimisticThreadId(null);
|
||||||
|
setLiveMessagesThreadId(null);
|
||||||
toast.error(getStreamErrorMessage(error));
|
toast.error(getStreamErrorMessage(error));
|
||||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||||
messagesRef.current
|
messagesRef.current
|
||||||
@@ -639,10 +684,17 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimistic messages shown before the server stream responds
|
const hasVisibleStreamState =
|
||||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
Boolean(threadId) || liveMessagesThreadId === currentViewThreadId;
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const persistedMessages = useMemo(
|
||||||
const humanMessageCount = thread.messages.filter(
|
() => (hasVisibleStreamState ? thread.messages : []),
|
||||||
|
[hasVisibleStreamState, thread.messages],
|
||||||
|
);
|
||||||
|
const visibleHistory = useMemo(
|
||||||
|
() => (threadId ? history : []),
|
||||||
|
[history, threadId],
|
||||||
|
);
|
||||||
|
const humanMessageCount = persistedMessages.filter(
|
||||||
(m) => m.type === "human",
|
(m) => m.type === "human",
|
||||||
).length;
|
).length;
|
||||||
const latestMessageCountsRef = useRef({ humanMessageCount });
|
const latestMessageCountsRef = useRef({ humanMessageCount });
|
||||||
@@ -663,15 +715,23 @@ export function useThreadStream({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startedRef.current = false;
|
startedRef.current = false;
|
||||||
sendInFlightRef.current = false;
|
sendInFlightRef.current = false;
|
||||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
messagesRef.current = [];
|
||||||
messagesRef.current
|
summarizedRef.current = new Set<string>();
|
||||||
.map(messageIdentity)
|
pendingUsageBaselineMessageIdsRef.current = new Set();
|
||||||
.filter((id): id is string => Boolean(id)),
|
|
||||||
);
|
|
||||||
prevHumanMsgCountRef.current =
|
prevHumanMsgCountRef.current =
|
||||||
latestMessageCountsRef.current.humanMessageCount;
|
latestMessageCountsRef.current.humanMessageCount;
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (optimisticThreadId && optimisticThreadId !== currentViewThreadId) {
|
||||||
|
setOptimisticMessages([]);
|
||||||
|
setOptimisticThreadId(null);
|
||||||
|
}
|
||||||
|
if (liveMessagesThreadId && liveMessagesThreadId !== currentViewThreadId) {
|
||||||
|
setLiveMessagesThreadId(null);
|
||||||
|
}
|
||||||
|
}, [currentViewThreadId, liveMessagesThreadId, optimisticThreadId]);
|
||||||
|
|
||||||
// When streaming starts without a baseline (e.g. reconnection, run started
|
// When streaming starts without a baseline (e.g. reconnection, run started
|
||||||
// from another client, or page reload mid-stream), snapshot the current
|
// from another client, or page reload mid-stream), snapshot the current
|
||||||
// messages so only *new* messages are treated as "pending" for token usage.
|
// messages so only *new* messages are treated as "pending" for token usage.
|
||||||
@@ -681,12 +741,12 @@ export function useThreadStream({
|
|||||||
pendingUsageBaselineMessageIdsRef.current.size === 0
|
pendingUsageBaselineMessageIdsRef.current.size === 0
|
||||||
) {
|
) {
|
||||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||||
thread.messages
|
persistedMessages
|
||||||
.map(messageIdentity)
|
.map(messageIdentity)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [thread.isLoading, thread.messages]);
|
}, [persistedMessages, thread.isLoading]);
|
||||||
|
|
||||||
// Clear optimistic when server messages arrive.
|
// Clear optimistic when server messages arrive.
|
||||||
// For messages with a human optimistic message, wait until the server's
|
// For messages with a human optimistic message, wait until the server's
|
||||||
@@ -702,6 +762,7 @@ export function useThreadStream({
|
|||||||
|
|
||||||
if (!hasHumanOptimistic || newHumanMsgArrived) {
|
if (!hasHumanOptimistic || newHumanMsgArrived) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
|
setOptimisticThreadId(null);
|
||||||
}
|
}
|
||||||
}, [hasHumanOptimistic, humanMessageCount, optimisticMessageCount]);
|
}, [hasHumanOptimistic, humanMessageCount, optimisticMessageCount]);
|
||||||
|
|
||||||
@@ -723,7 +784,7 @@ export function useThreadStream({
|
|||||||
// messages so we can wait for the server's copy of the user input.
|
// messages so we can wait for the server's copy of the user input.
|
||||||
prevHumanMsgCountRef.current = humanMessageCount;
|
prevHumanMsgCountRef.current = humanMessageCount;
|
||||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||||
thread.messages
|
persistedMessages
|
||||||
.map(messageIdentity)
|
.map(messageIdentity)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
);
|
);
|
||||||
@@ -762,6 +823,8 @@ export function useThreadStream({
|
|||||||
additional_kwargs: { element: "task" },
|
additional_kwargs: { element: "task" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setOptimisticThreadId(threadId);
|
||||||
|
setLiveMessagesThreadId(threadId);
|
||||||
setOptimisticMessages(newOptimistic);
|
setOptimisticMessages(newOptimistic);
|
||||||
|
|
||||||
listeners.current.onSend?.(threadId);
|
listeners.current.onSend?.(threadId);
|
||||||
@@ -827,6 +890,8 @@ export function useThreadStream({
|
|||||||
: "Failed to upload files.";
|
: "Failed to upload files.";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
|
setOptimisticThreadId(null);
|
||||||
|
setLiveMessagesThreadId(null);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -895,35 +960,44 @@ export function useThreadStream({
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
|
setOptimisticThreadId(null);
|
||||||
|
setLiveMessagesThreadId(null);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
sendInFlightRef.current = false;
|
sendInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[thread, t.uploads.uploadingFiles, context, queryClient, humanMessageCount],
|
[
|
||||||
|
thread,
|
||||||
|
t.uploads.uploadingFiles,
|
||||||
|
context,
|
||||||
|
queryClient,
|
||||||
|
humanMessageCount,
|
||||||
|
persistedMessages,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache the latest thread messages in a ref to compare against incoming history messages for deduplication,
|
// Cache the latest thread messages in a ref to compare against incoming history messages for deduplication,
|
||||||
// and to allow access to the full message list in onUpdateEvent without causing re-renders.
|
// and to allow access to the full message list in onUpdateEvent without causing re-renders.
|
||||||
if (thread.messages.length >= messagesRef.current.length) {
|
if (persistedMessages.length >= messagesRef.current.length) {
|
||||||
messagesRef.current = thread.messages;
|
messagesRef.current = persistedMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleOptimisticMessages = getVisibleOptimisticMessages(
|
const visibleOptimisticMessages = getVisibleOptimisticMessages(
|
||||||
optimisticMessages,
|
optimisticThreadId === currentViewThreadId ? optimisticMessages : [],
|
||||||
prevHumanMsgCountRef.current,
|
prevHumanMsgCountRef.current,
|
||||||
humanMessageCount,
|
humanMessageCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedMessages = mergeMessages(
|
const mergedMessages = mergeMessages(
|
||||||
history,
|
visibleHistory,
|
||||||
thread.messages,
|
persistedMessages,
|
||||||
visibleOptimisticMessages,
|
visibleOptimisticMessages,
|
||||||
);
|
);
|
||||||
const pendingUsageMessages = thread.isLoading
|
const pendingUsageMessages = thread.isLoading
|
||||||
? getMessagesAfterBaseline(
|
? getMessagesAfterBaseline(
|
||||||
thread.messages,
|
persistedMessages,
|
||||||
pendingUsageBaselineMessageIdsRef.current,
|
pendingUsageBaselineMessageIdsRef.current,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
@@ -932,6 +1006,7 @@ export function useThreadStream({
|
|||||||
// History messages may overlap with thread.messages; thread.messages take precedence
|
// History messages may overlap with thread.messages; thread.messages take precedence
|
||||||
const mergedThread = {
|
const mergedThread = {
|
||||||
...thread,
|
...thread,
|
||||||
|
values: hasVisibleStreamState ? thread.values : EMPTY_THREAD_VALUES,
|
||||||
messages: mergedMessages,
|
messages: mergedMessages,
|
||||||
} as typeof thread;
|
} as typeof thread;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Route } from "@playwright/test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mockLangGraphAPI,
|
mockLangGraphAPI,
|
||||||
@@ -19,6 +19,9 @@ const THREADS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||||||
|
const SVG_PROMPT_THREAD_ID = "00000000-0000-0000-0000-000000000777";
|
||||||
|
const SVG_PROMPT_MARKER = "LEAK-STRICT-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||||
|
const OPTIMISTIC_PROMPT_MARKER = "LEAK-OPTIMISTIC-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||||
|
|
||||||
test.describe("Thread history", () => {
|
test.describe("Thread history", () => {
|
||||||
test("sidebar shows existing threads", async ({ page }) => {
|
test("sidebar shows existing threads", async ({ page }) => {
|
||||||
@@ -62,6 +65,109 @@ test.describe("Thread history", () => {
|
|||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("new chat does not show previous thread messages after client-side navigation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: SVG_PROMPT_THREAD_ID,
|
||||||
|
title: "SVG artifact prompt",
|
||||||
|
updated_at: "2025-06-03T12:00:00Z",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "msg-human-svg-prompt",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `请严格执行:\n1. 使用 write_file 创建 /mnt/user-data/outputs/shared.svg,内容包含 ${SVG_PROMPT_MARKER}\n2. 最终回复只输出 Markdown 图片。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "msg-ai-svg-prompt",
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${SVG_PROMPT_THREAD_ID}`);
|
||||||
|
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /new chat/i }).click();
|
||||||
|
await page.waitForURL("**/workspace/chats/new");
|
||||||
|
|
||||||
|
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
||||||
|
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new chat does not show previous optimistic user message after client-side navigation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID_2,
|
||||||
|
title: "Destination conversation",
|
||||||
|
updated_at: "2025-06-04T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataOnlyStream = async (route: Route) => {
|
||||||
|
const body = [
|
||||||
|
{
|
||||||
|
event: "metadata",
|
||||||
|
data: {
|
||||||
|
run_id: "00000000-0000-0000-0000-000000000778",
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ event: "end", data: {} },
|
||||||
|
]
|
||||||
|
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route("**/api/langgraph/runs/stream", metadataOnlyStream);
|
||||||
|
await page.route(
|
||||||
|
"**/api/langgraph/threads/*/runs/stream",
|
||||||
|
metadataOnlyStream,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
`请严格执行:使用 write_file 创建 shared.svg,内容包含 ${OPTIMISTIC_PROMPT_MARKER}。`,
|
||||||
|
);
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText("Destination conversation").click();
|
||||||
|
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||||||
|
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /new chat/i }).click();
|
||||||
|
await page.waitForURL("**/workspace/chats/new");
|
||||||
|
|
||||||
|
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user