From 4c53191de902a8f2e415d0a722633f45b4466d2e Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:34:31 +0100 Subject: [PATCH] Studio: Smoother thread switching in chat (#5126) * fix: keep single chat runtime mounted across thread selection * fix: stabilize thread switching and remove transition flicker * chore: keep hidden-welcome thread footer chrome mounted * fix: guard composer during thread attach * fix: recover from stale routed chat threads * fix: notify when routed chat is missing * fix: ensure stale chat recovery opens new chat --- .../src/components/assistant-ui/thread.tsx | 59 ++++++++++++++----- .../frontend/src/features/chat/chat-page.tsx | 33 ++++++++++- .../src/features/chat/runtime-provider.tsx | 11 +++- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 54f64220e8..4c6569c2ee 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -60,15 +60,31 @@ import { Trash2Icon, XIcon, } from "lucide-react"; -import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { + type FC, + type FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { toast } from "sonner"; import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; -export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ +export const Thread: FC<{ + hideComposer?: boolean; + hideWelcome?: boolean; + targetThreadId?: string; +}> = ({ hideComposer, hideWelcome, + targetThreadId, }) => { + const isComposerAttachPending = useAuiState(({ threads }) => + targetThreadId ? threads.mainThreadId !== targetThreadId : false, + ); + return ( = ({ )} > {!hideWelcome && ( - thread.isEmpty}> + thread.isEmpty && !thread.isLoading}> )} @@ -102,14 +118,14 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ sticky scroll-to-bottom button (and the floating composer in single mode). Without this, content would butt against the sticky footer and feel cramped. */} - !thread.isEmpty}> + hideWelcome || !thread.isEmpty}>
- !thread.isEmpty}> + hideWelcome || !thread.isEmpty}> = ({ {!hideComposer && ( - !thread.isEmpty}> + hideWelcome || !thread.isEmpty}>
= ({ />
- +

LLMs can make mistakes. Double-check all responses. @@ -268,7 +284,7 @@ const GeneratingSpinner: FC = () => { ); }; -const ComposerAnimated: FC = () => { +const ComposerAnimated: FC<{ disabled?: boolean }> = ({ disabled }) => { return (

{ transition={{ type: "spring", bounce: 0.15, duration: 0.5 }} className="relative z-10 w-full" > - +
); @@ -305,9 +321,22 @@ const PendingAudioChip: FC = () => { ); }; -const Composer: FC = () => { +const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { + const handleSubmit = useCallback( + (event: FormEvent) => { + if (disabled) { + event.preventDefault(); + } + }, + [disabled], + ); + return ( - + @@ -317,10 +346,11 @@ const Composer: FC = () => { className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0" minRows={1} maxRows={6} - autoFocus={true} + autoFocus={!disabled} + disabled={disabled} aria-label="Message input" /> - + ); @@ -537,7 +567,7 @@ const ToolStatusDisplay: FC = () => { ); }; -const ComposerAction: FC = () => { +const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => { return (
@@ -578,6 +608,7 @@ const ComposerAction: FC = () => { type="submit" variant="default" size="icon" + disabled={disabled} className="aui-composer-send size-8 rounded-full" aria-label="Send message" > diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 8510aece50..32e34b3c72 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -122,7 +122,7 @@ const SingleContent = memo(function SingleContent({ newThreadNonce={newThreadNonce} >
- +
); @@ -492,6 +492,37 @@ export function ChatPage(): ReactElement { useEffect(() => { return () => setSettingsOpen(false); }, [setSettingsOpen]); + + useEffect(() => { + const threadId = search.thread; + if (!threadId) return; + + let canceled = false; + void db.threads + .get(threadId) + .then((thread) => { + if (canceled || thread) return; + useChatRuntimeStore.getState().setActiveThreadId(null); + toast.info("Chat not found", { + description: "That thread no longer exists, so we opened a new chat.", + }); + navigate({ + to: "/chat", + search: { new: crypto.randomUUID() }, + replace: true, + }); + }) + .catch(() => { + if (useChatRuntimeStore.getState().activeThreadId === threadId) { + useChatRuntimeStore.getState().setActiveThreadId(null); + } + }); + + return () => { + canceled = true; + }; + }, [navigate, search.thread]); + const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); const viewBeforeCompareRef = useRef(null); diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 5747e17970..105f2fb4b1 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -681,9 +681,16 @@ function ThreadAutoSwitch({ useEffect(() => { if (!isLoading && mainThreadId !== threadId) { - aui.threads().switchToThread(threadId); + const switchResult = aui.threads().switchToThread(threadId) as unknown; + if (switchResult && typeof (switchResult as Promise).catch === "function") { + void (switchResult as Promise).catch(() => { + if (syncActiveThreadId) { + useChatRuntimeStore.getState().setActiveThreadId(null); + } + }); + } } - }, [aui, isLoading, mainThreadId, threadId]); + }, [aui, isLoading, mainThreadId, syncActiveThreadId, threadId]); useEffect(() => { if (!syncActiveThreadId || isLoading || mainThreadId !== threadId) {