diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index c4740f15..f5144bae 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -80,20 +80,19 @@ export default function ChatPage() { }, 100); } }, [inputInitialValue]); - const isNewThread = useMemo( - () => { - if (threadIdFromPath !== "new") { - return false; - } + // UI mode depends only on route: /workspace/chats/new is always "new page" mode. + const isNewThread = useMemo(() => threadIdFromPath === "new", [threadIdFromPath]); - const queryThreadId = searchParams.get("thread_id")?.trim(); - const queryIsNew = searchParams.get("isnew")?.trim().toLowerCase(); - const shouldReuseExisting = queryIsNew === "false" && !!queryThreadId; + // Submission strategy is controlled by `isnew` query param only. + // - isnew=false: reuse existing thread + // - otherwise: create/start a new session + const createNewSession = useMemo(() => { + if (threadIdFromPath !== "new") { + return false; + } - return !shouldReuseExisting; - }, - [threadIdFromPath, searchParams], - ); + return searchParams.get("isnew")?.trim().toLowerCase() !== "false"; + }, [threadIdFromPath, searchParams]); const uploadTarget = useMemo(() => { const target = searchParams.get("upload_target")?.trim().toLowerCase(); @@ -110,11 +109,21 @@ export default function ChatPage() { } }, [threadIdFromPath, searchParams]); + // Runtime strategy for /new page: + // - UI remains new-page mode + // - if isnew=false, execute against existing thread_id without creating a new one + const reuseExistingThread = useMemo( + () => threadIdFromPath === "new" && !createNewSession && !!threadId, + [threadIdFromPath, createNewSession, threadId], + ); + const { showNotification } = useNotification(); const [finalState, setFinalState] = useState(null); const thread = useThreadStream({ - isNewThread, + // Keep UI in new-page mode, but runtime may reuse existing thread + isNewThread: reuseExistingThread ? false : isNewThread, threadId, + fetchStateHistory: true, onFinish: (state) => { setFinalState(state); if (document.hidden || !document.hasFocus()) { @@ -150,13 +159,16 @@ export default function ChatPage() { return result; }, [thread, isNewThread]); + const [hasSubmitted, setHasSubmitted] = useState(false); + const suppressExistingThreadPrefetchUi = reuseExistingThread && !hasSubmitted; + useEffect(() => { const pageTitle = isNewThread ? t.pages.newChat : thread.values?.title && thread.values.title !== "Untitled" ? thread.values.title : t.pages.untitled; - if (thread.isThreadLoading) { + if (thread.isThreadLoading && !suppressExistingThreadPrefetchUi) { document.title = `Loading... - ${t.pages.appName}`; } else { document.title = `${pageTitle} - ${t.pages.appName}`; @@ -168,6 +180,7 @@ export default function ChatPage() { t.pages.appName, thread.values.title, thread.isThreadLoading, + suppressExistingThreadPrefetchUi, ]); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); @@ -198,8 +211,9 @@ export default function ChatPage() { const [todoListCollapsed, setTodoListCollapsed] = useState(true); - const handleSubmit = useSubmitThread({ + const submitThread = useSubmitThread({ isNewThread, + createNewSession, threadId, thread, uploadTarget, @@ -214,6 +228,13 @@ export default function ChatPage() { router.push(pathOfThread(threadId!)); }, }); + const handleSubmit = useCallback( + (message: Parameters[0]) => { + setHasSubmitted(true); + void submitThread(message); + }, + [submitThread], + ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); @@ -268,8 +289,11 @@ export default function ChatPage() { className={cn("size-full", !isNewThread && "pt-10")} threadId={threadId} thread={thread} + suppressThreadLoading={suppressExistingThreadPrefetchUi} messagesOverride={ - !thread.isLoading && finalState?.messages + suppressExistingThreadPrefetchUi + ? [] + : !thread.isLoading && finalState?.messages ? (finalState.messages as Message[]) : undefined } @@ -306,7 +330,13 @@ export default function ChatPage() { className={cn("bg-background/5 w-full -translate-y-4")} isNewThread={isNewThread} autoFocus={isNewThread} - status={thread.isLoading ? "streaming" : "ready"} + status={ + suppressExistingThreadPrefetchUi + ? "ready" + : thread.isLoading + ? "streaming" + : "ready" + } context={settings.context} extraHeader={ isNewThread && diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 8f577fd5..db37d275 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -35,6 +35,7 @@ export function MessageList({ threadId, thread, messagesOverride, + suppressThreadLoading = false, paddingBottom = 160, }: { className?: string; @@ -42,13 +43,14 @@ export function MessageList({ thread: UseStream; /** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */ messagesOverride?: Message[]; + suppressThreadLoading?: boolean; paddingBottom?: number; }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); const messages = messagesOverride ?? thread.messages; - if (thread.isThreadLoading) { + if (thread.isThreadLoading && !suppressThreadLoading) { return ; } return ( diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index fab29800..b05a1341 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -21,10 +21,12 @@ import type { export function useThreadStream({ threadId, isNewThread, + fetchStateHistory = true, onFinish, }: { isNewThread: boolean; threadId: string | null | undefined; + fetchStateHistory?: boolean; onFinish?: (state: AgentThreadState) => void; }) { const queryClient = useQueryClient(); @@ -34,7 +36,7 @@ export function useThreadStream({ assistantId: "lead_agent", threadId: isNewThread ? undefined : threadId, reconnectOnMount: true, - fetchStateHistory: true, + fetchStateHistory, onCustomEvent(event: unknown) { console.info(event); if ( @@ -84,10 +86,12 @@ export function useSubmitThread({ thread, threadContext, isNewThread, + createNewSession, uploadTarget, afterSubmit, }: { isNewThread: boolean; + createNewSession: boolean; threadId: string | null | undefined; thread: UseStream; threadContext: Omit; @@ -95,10 +99,21 @@ export function useSubmitThread({ afterSubmit?: () => void; }) { const queryClient = useQueryClient(); + const apiClient = getAPIClient(); const callback = useCallback( async (message: PromptInputMessage) => { const text = message.text.trim(); + // For "new session" semantics, ensure the target thread id starts fresh. + // If the same id already exists, delete it first and let submit recreate it. + if (createNewSession && threadId) { + try { + await apiClient.threads.delete(threadId); + } catch { + // Ignore delete errors (e.g. thread does not exist yet) + } + } + // Upload files first if any if (message.files && message.files.length > 0) { try { @@ -154,7 +169,7 @@ export function useSubmitThread({ ] as HumanMessage[], }, { - threadId: isNewThread ? threadId! : undefined, + threadId: createNewSession ? threadId! : undefined, streamSubgraphs: true, streamResumable: true, streamMode: ["values", "messages-tuple", "custom"], @@ -170,7 +185,17 @@ export function useSubmitThread({ void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); afterSubmit?.(); }, - [thread, isNewThread, threadId, threadContext, uploadTarget, queryClient, afterSubmit], + [ + thread, + isNewThread, + createNewSession, + threadId, + threadContext, + uploadTarget, + queryClient, + apiClient, + afterSubmit, + ], ); return callback; }