refactor(chat): enhance message loading state management and update skeleton loading components for better user experience

This commit is contained in:
Anish Sarkar 2026-07-16 01:29:18 +05:30
parent c7819f90d8
commit 7246f89c12
2 changed files with 98 additions and 95 deletions

View file

@ -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 (
<div
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-panel"
style={{
["--thread-max-width" as string]: "42rem",
}}
>
<div
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
style={{ scrollbarGutter: "stable" }}
>
<div
aria-hidden
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-panel from-20% to-transparent"
/>
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
<div className="flex justify-end">
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
</div>
</div>
<div
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-panel from-60% to-transparent px-4 pt-6"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
>
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<Skeleton className="h-28 w-full rounded-3xl" />
</div>
</div>
</div>
</div>
);
}
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<ThreadMessageLike[]>(displayMessages);
messagesRef.current = displayMessages;
const messagesRef = useRef<ThreadMessageLike[]>(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() {
>
<div key={workspaceId} className="flex h-full overflow-hidden">
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread hasActiveThread={!!activeThreadId} />
{isThreadMessagesLoading ? (
<div className="absolute inset-0 z-10 bg-panel">
<ThreadMessagesSkeleton />
</div>
) : null}
<Thread
hasActiveThread={!!activeThreadId}
isLoadingMessages={isThreadMessagesLoading}
/>
</div>
<MobileReportPanel />
<MobileEditorPanel />

View file

@ -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<ThreadProps> = ({ hasActiveThread = false }) => {
return <ThreadContent hasActiveThread={hasActiveThread} />;
export const Thread: FC<ThreadProps> = ({ hasActiveThread = false, isLoadingMessages = false }) => {
return (
<ThreadContent hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
);
};
const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
const ThreadContent: FC<ThreadProps> = ({
hasActiveThread = false,
isLoadingMessages = false,
}) => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
@ -165,7 +172,7 @@ const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
footer={
<AuiIf condition={({ thread }) => hasActiveThread || !thread.isEmpty}>
<PremiumQuotaPinnedAlert />
<Composer />
<Composer isLoadingMessages={isLoadingMessages} />
</AuiIf>
}
>
@ -173,6 +180,8 @@ const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
<ThreadWelcome />
</AuiIf>
{isLoadingMessages ? <ThreadMessagesSkeletonBody /> : null}
<ThreadPrimitive.Messages
components={{
UserMessage,
@ -185,6 +194,36 @@ const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
);
};
const ThreadMessagesSkeletonBody: FC = () => {
return (
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col gap-6 py-8">
<div className="flex justify-end">
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
<div className="flex justify-end gap-2">
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
<div className="flex justify-end gap-2">
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
</div>
</div>
);
};
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<ComposerProps> = ({ 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 = () => {
</div>
<ComposerAction
isBlockedByOtherUser={isBlockedByOtherUser}
isLoadingMessages={isLoadingMessages}
isThreadRunning={isThreadRunning}
workspaceId={workspaceId ?? 0}
onChatModelSelected={handleChatModelSelected}
/>
</div>
<ConnectToolsBanner
isThreadEmpty={isThreadEmpty}
isThreadEmpty={isThreadEmpty && !isLoadingMessages}
onVisibleChange={setConnectToolsTrayVisible}
/>
{isThreadEmpty && isComposerInputEmpty ? (
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
<div className="absolute top-full left-0 right-0 z-20">
<ChatExamplePrompts onSelect={handleExampleSelect} />
</div>
@ -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<ComposerActionProps> = ({
isBlockedByOtherUser = false,
isLoadingMessages = false,
isThreadRunning = false,
workspaceId,
onChatModelSelected,
}) => {
@ -1210,7 +1260,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
// 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 (
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
@ -1661,13 +1724,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: isComposerEmpty
? "Enter a message or add a screenshot to send"
: "Send message"
}
tooltip={sendTooltip}
side="bottom"
type="submit"
variant="default"