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}>