mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
refactor(chat): enhance message loading state management and update skeleton loading components for better user experience
This commit is contained in:
parent
c7819f90d8
commit
7246f89c12
2 changed files with 98 additions and 95 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue