From 7246f89c124fe7c515350777ac24c4aad393f7b3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:29:18 +0530 Subject: [PATCH] refactor(chat): enhance message loading state management and update skeleton loading components for better user experience --- .../new-chat/[[...chat_id]]/page.tsx | 104 +++++------------- .../components/assistant-ui/thread.tsx | 89 ++++++++++++--- 2 files changed, 98 insertions(+), 95 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx index f5b857ce1..22298e574 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx @@ -39,7 +39,6 @@ import { TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { useSyncChatArtifacts } from "@/features/chat-artifacts"; import { type HitlDecision, @@ -104,6 +103,7 @@ const MobileArtifactsPanel = dynamic( /** Stable empty reference so idle threads don't re-render the interrupt provider. */ const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = []; +const EMPTY_MESSAGES: ThreadMessageLike[] = []; function parseUrlChatId(id: string | string[] | undefined): number { let parsed = 0; @@ -115,61 +115,6 @@ function parseUrlChatId(id: string | string[] | undefined): number { return Number.isNaN(parsed) ? 0 : parsed; } -function ThreadMessagesSkeleton() { - return ( -
-
-
-
-
- -
- -
- - - -
- -
- -
- -
- - - -
- -
- -
-
- -
-
- -
-
-
-
- ); -} - export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); @@ -226,6 +171,16 @@ export default function NewChatPage() { const threadDetailQuery = useThreadDetail(activeThreadId); const threadMessagesQuery = useThreadMessages(activeThreadId); + const hydratedMessagesRef = useRef<{ + threadId: number | null; + data: typeof threadMessagesQuery.data; + }>({ threadId: null, data: undefined }); + const hasLiveStream = !!streamState && streamState.messages.length > 0; + const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId; + const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated; + const isThreadMessagesLoading = + shouldHideStaleMessages && !threadMessagesQuery.error; + const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages; // Live collaboration: sync session state and messages via Zero. Kept on the // page because "AI responding" reflects the currently-viewed thread. @@ -296,8 +251,8 @@ export default function NewChatPage() { // Latest displayed messages, read by the engine wrappers at call time so // history/slice seeds stay fresh without re-creating the callbacks. - const messagesRef = useRef(displayMessages); - messagesRef.current = displayMessages; + const messagesRef = useRef(runtimeMessages); + messagesRef.current = runtimeMessages; const buildCtx = useCallback( (): EngineContext => ({ @@ -309,11 +264,6 @@ export default function NewChatPage() { [workspaceId, activeThreadId] ); - const hydratedMessagesRef = useRef<{ - threadId: number | null; - data: typeof threadMessagesQuery.data; - }>({ threadId: null, data: undefined }); - // Reset thread-local runtime state on route/workspace changes. The durable // streaming overlay is preserved for any still-running thread (and the newly // viewed thread) via ``clearInactive`` so an in-flight turn survives nav. @@ -517,8 +467,11 @@ export default function NewChatPage() { // Handle new message from user const onNew = useCallback( - (message: AppendMessage) => startNewChat(buildCtx(), message), - [buildCtx] + (message: AppendMessage) => { + if (isThreadMessagesLoading) return Promise.resolve(); + return startNewChat(buildCtx(), message); + }, + [buildCtx, isThreadMessagesLoading] ); // Cancel the in-flight turn (targets the active stream's owner thread). @@ -747,11 +700,11 @@ export default function NewChatPage() { }, [buildCtx, pendingInterrupts, activeThreadId]); // Surface the thread's deliverables to the layout-level artifacts sidebar. - useSyncChatArtifacts(displayMessages); + useSyncChatArtifacts(runtimeMessages); // Create external store runtime const runtime = useExternalStoreRuntime({ - messages: displayMessages, + messages: runtimeMessages, isRunning, onNew, onEdit, @@ -764,12 +717,7 @@ export default function NewChatPage() { ? (threadDetailQuery.error ?? threadMessagesQuery.error) : null; const shouldShowThreadLoadError = - !!threadLoadError && !!activeThreadId && !currentThread && displayMessages.length === 0; - const isThreadMessagesLoading = - !!activeThreadId && - threadMessagesQuery.isPending && - displayMessages.length === 0 && - !threadMessagesQuery.error; + !!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0; if (shouldShowThreadLoadError) { return ( @@ -798,12 +746,10 @@ export default function NewChatPage() { >
- - {isThreadMessagesLoading ? ( -
- -
- ) : null} +
diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index c51bea165..634d9bd8b 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -103,6 +103,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; +import { canSubmitChat } from "@/lib/chat/can-submit-chat"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { findPlatform, type PlaygroundPlatform } from "@/lib/playground/catalog"; @@ -147,13 +148,19 @@ function getComposerSuggestionAnchorPoint( interface ThreadProps { hasActiveThread?: boolean; + isLoadingMessages?: boolean; } -export const Thread: FC = ({ hasActiveThread = false }) => { - return ; +export const Thread: FC = ({ hasActiveThread = false, isLoadingMessages = false }) => { + return ( + + ); }; -const ThreadContent: FC = ({ hasActiveThread = false }) => { +const ThreadContent: FC = ({ + hasActiveThread = false, + isLoadingMessages = false, +}) => { return ( = ({ hasActiveThread = false }) => { footer={ hasActiveThread || !thread.isEmpty}> - + } > @@ -173,6 +180,8 @@ const ThreadContent: FC = ({ hasActiveThread = false }) => { + {isLoadingMessages ? : null} + = ({ hasActiveThread = false }) => { ); }; +const ThreadMessagesSkeletonBody: FC = () => { + return ( +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ ); +}; + const PremiumQuotaPinnedAlert: FC = () => { const currentThreadState = useAtomValue(currentThreadAtom); const alertsByThread = useAtomValue(premiumAlertByThreadAtom); @@ -490,7 +529,11 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> ); }; -const Composer: FC = () => { +interface ComposerProps { + isLoadingMessages?: boolean; +} + +const Composer: FC = ({ isLoadingMessages = false }) => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); @@ -823,7 +866,7 @@ const Composer: FC = () => { ); const handleSubmit = useCallback(() => { - if (isThreadRunning || isBlockedByOtherUser) return; + if (isLoadingMessages || isThreadRunning || isBlockedByOtherUser) return; if (showDocumentPopover || showPromptPicker) return; if (clipboardInitialText) { @@ -844,6 +887,7 @@ const Composer: FC = () => { }, [ showDocumentPopover, showPromptPicker, + isLoadingMessages, isThreadRunning, isBlockedByOtherUser, clipboardInitialText, @@ -1016,15 +1060,17 @@ const Composer: FC = () => {
- {isThreadEmpty && isComposerInputEmpty ? ( + {!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
@@ -1087,12 +1133,16 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => interface ComposerActionProps { isBlockedByOtherUser?: boolean; + isLoadingMessages?: boolean; + isThreadRunning?: boolean; workspaceId: number; onChatModelSelected?: () => void; } const ComposerAction: FC = ({ isBlockedByOtherUser = false, + isLoadingMessages = false, + isThreadRunning = false, workspaceId, onChatModelSelected, }) => { @@ -1210,7 +1260,20 @@ const ComposerAction: FC = ({ // send that lacks a resolvable model, making this defense-in-depth. const isWorkspaceChatReady = setupStatus?.status === "ready"; - const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser; + const isSendDisabled = !canSubmitChat({ + isLoadingMessages, + isThreadRunning, + isBlockedByOtherUser, + isComposerEmpty, + isWorkspaceChatReady, + }); + const sendTooltip = isLoadingMessages + ? "Loading conversation..." + : isBlockedByOtherUser + ? "Wait for AI to finish responding" + : isComposerEmpty + ? "Enter a message or add a screenshot to send" + : "Send message"; return (
@@ -1661,13 +1724,7 @@ const ComposerAction: FC = ({ !thread.isRunning}>