diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 4dc0f0f247b..a93eed6837a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,6 +1,7 @@ import { getChannelDetail } from "@/features/channels/lib/channelDescription"; import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -106,3 +107,19 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +export function selectThreadComposerBotTypingPubkeys( + entries: TypingIndicatorEntry[], + threadHeadId: string | null, +) { + if (!threadHeadId) return []; + return entries + .filter((entry) => entry.threadHeadId === threadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a4..d53e8dbbdaa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -52,6 +52,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + selectThreadComposerBotTypingPubkeys, shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; @@ -103,7 +104,9 @@ export const ChannelPane = React.memo(function ChannelPane({ isJoining = false, isSinglePanelView = false, isSending, + isTimelineError = false, isTimelineLoading, + onRetryTimeline, entranceMessageId = null, onEntranceMessageComplete, welcomeKickoffStage = null, @@ -334,18 +337,11 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; - const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) return []; - return botTypingEntries - .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + const threadComposerBotTypingPubkeys = React.useMemo( + () => + selectThreadComposerBotTypingPubkeys(botTypingEntries, openThreadHeadId), + [botTypingEntries, openThreadHeadId], + ); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( @@ -662,7 +658,9 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No messages yet" : "No channel selected" } + isError={isTimelineError} isLoading={isHuddleTranscript ? false : isTimelineLoading} + onRetry={onRetryTimeline} entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5bce6a71ff7..1fe5bf751b8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -59,7 +59,10 @@ export type ChannelPaneProps = { isJoining?: boolean; isSinglePanelView?: boolean; isSending: boolean; + /** Terminal channel-history failure. Cached messages remain visible when present. */ + isTimelineError?: boolean; isTimelineLoading: boolean; + onRetryTimeline?: () => void; /** Newly-created message that should receive the one-shot conversation arrival motion. */ entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7cf..f7a5b480122 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -50,10 +50,7 @@ import { isThreadReply, } from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; +import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -616,27 +613,21 @@ export function ChannelScreen({ setThreadScrollTargetId, }); const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineQueryLoadingState( + settledChannelIdRef.current, + activeChannelId, { + isEnabled: + activeChannel !== null && activeChannel.channelType !== "forum", isPending: messagesQuery.isPending, isFetching: messagesQuery.isFetching, isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, + isError: messagesQuery.isError, }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, + activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId), ); settledChannelIdRef.current = settledChannelId; const { welcomeKickoffStage, welcomeKickoffSettingUp } = @@ -890,8 +881,8 @@ export function ChannelScreen({ isFollowingThread={isNotifiedForEffectiveThread} isSending={sendMessageMutation.isPending} isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} + isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading} + onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages} threadSummaries={threadSummaries} huddleThreadRepliesError={huddleThreadRepliesError} onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc9912..9594b937879 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -416,3 +416,74 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("test_subscription_refresh_preserves_cold_history_error", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const channelId = "cold-failure"; + const queryKey = channelMessagesKey(channelId); + const observer = new QueryObserver(client, { + queryKey, + queryFn: async () => { + throw new Error("history unavailable"); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await observer.refetch(); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + + await assert.rejects( + refreshChannelWindowMessages(client, channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + } finally { + unsubscribe(); + } +}); + +test("test_refresh_failure_retains_cached_rows_and_success_clears_error", async () => { + const harness = createHarness(); + let shouldFail = true; + const refreshed = event("refreshed", 110); + const observer = new QueryObserver(harness.client, { + queryKey: harness.messagesKey, + queryFn: async ({ signal }) => { + if (shouldFail) { + throw new Error("history unavailable"); + } + const previousMessages = + harness.client.getQueryData(harness.messagesKey) ?? []; + return reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + wirePage([refreshed, event("initial", 100)]), + previousMessages, + signal, + ); + }, + staleTime: Number.POSITIVE_INFINITY, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await assert.rejects( + refreshChannelWindowMessages(harness.client, harness.channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(contents(harness), ["initial"]); + + shouldFail = false; + await refreshChannelWindowMessages(harness.client, harness.channelId); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(contents(harness), ["initial", "refreshed"]); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index b16187ce18c..8531c867f2b 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -47,7 +47,7 @@ export async function refreshChannelWindowMessages( } await queryClient.invalidateQueries( { queryKey, exact: true, refetchType: "active" }, - { cancelRefetch: !seeded }, + { cancelRefetch: !seeded, throwOnError: true }, ); projectChannelWindowMessages(queryClient, channelId); } diff --git a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs index fe6960f74c3..ed414365be7 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs +++ b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { selectTimelineLoadingState } from "./timelineLoadingState.ts"; +import { + resolveTimelineLoadingLatch, + resolveTimelineQueryLoadingState, + selectTimelineLoadingState, +} from "./timelineLoadingState.ts"; const settled = { isPending: false, @@ -10,6 +15,27 @@ const settled = { dataLength: null, }; +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function resolveQueryLoading(result, settledChannelId = null) { + return resolveTimelineQueryLoadingState(settledChannelId, "chan-a", { + isEnabled: true, + isPending: result.isPending, + isFetching: result.isFetching, + isPlaceholderData: result.isPlaceholderData, + dataLength: result.data?.length ?? null, + isError: result.isError, + }); +} + test("pending first fetch with no cache is loading", () => { assert.equal( selectTimelineLoadingState({ ...settled, isPending: true }), @@ -120,8 +146,6 @@ test("settled channel with rows mid-refetch is not loading", () => { ); }); -import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts"; - test("latch: loading on first entry to a channel", () => { const r = resolveTimelineLoadingLatch(null, "chan-a", true); assert.equal(r.isLoading, true); @@ -158,3 +182,80 @@ test("latch: no active channel passes loadingNow through untouched", () => { false, ); }); + +test("query wiring: cold error retry stays loading until successful empty result", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + let loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: false }); + + const retryResult = observer.refetch(); + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "pending"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: true }); + + retry.resolve([]); + await retryResult; + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(loading, { + settledChannelId: "chan-a", + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); + +test("query wiring: repeated cold retry failure never settles as empty", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + + const retryResult = observer.refetch(); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: true, + }); + + retry.reject(new Error("still unavailable")); + await retryResult; + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/timelineLoadingState.ts b/desktop/src/features/messages/lib/timelineLoadingState.ts index ea46168d254..bb6f0bc96c9 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.ts +++ b/desktop/src/features/messages/lib/timelineLoadingState.ts @@ -15,6 +15,11 @@ export type TimelineQueryStatus = { dataLength: number | null; }; +export type TimelineQueryLoadingStatus = TimelineQueryStatus & { + isEnabled: boolean; + isError: boolean; +}; + export function selectTimelineLoadingState( status: TimelineQueryStatus, hasSettled = true, @@ -50,6 +55,7 @@ export function resolveTimelineLoadingLatch( settledChannelId: string | null, activeChannelId: string | null, loadingNow: boolean, + canSettle = true, ): { settledChannelId: string | null; isLoading: boolean } { if (activeChannelId === null) { return { settledChannelId, isLoading: loadingNow }; @@ -58,9 +64,37 @@ export function resolveTimelineLoadingLatch( // Already settled for this channel — stay loaded through refetch blips. return { settledChannelId, isLoading: false }; } - if (!loadingNow) { + if (!loadingNow && canSettle) { // First settle for this channel; latch it. return { settledChannelId: activeChannelId, isLoading: false }; } - return { settledChannelId, isLoading: true }; + return { settledChannelId, isLoading: loadingNow }; +} + +/** + * Production coordinator from the messages query state to the channel loading + * latch. Keeping the error guard here prevents a cold terminal failure from + * being recorded as an authoritative empty result before Retry starts. + */ +export function resolveTimelineQueryLoadingState( + settledChannelId: string | null, + activeChannelId: string | null, + status: TimelineQueryLoadingStatus, + hasPersistedHydratedChannel = false, +): { settledChannelId: string | null; isLoading: boolean } { + const hasSettledThisChannel = + activeChannelId !== null && settledChannelId === activeChannelId; + const loadingNow = + status.isEnabled && + selectTimelineLoadingState( + status, + hasSettledThisChannel || hasPersistedHydratedChannel, + ); + + return resolveTimelineLoadingLatch( + settledChannelId, + activeChannelId, + loadingNow, + !status.isError, + ); } diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a8afb823ecb..3787f8281a2 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -590,6 +590,30 @@ test("timeline-body-surface: empty only when live and deferred rows are empty", ); }); +test("timeline-body-surface: terminal history failure never paints false-empty", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 0, + isError: true, + isLoading: false, + liveCount: 0, + }), + "error", + ); +}); + +test("timeline-body-surface: cached rows stay visible after a refetch failure", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 2, + isError: true, + isLoading: false, + liveCount: 2, + }), + "list", + ); +}); + test("deferred-snapshot: stale when channel ids diverge during channel switch", () => { assert.equal( isDeferredTimelineSnapshotStale({ diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 4e23fb22453..656e25d1b63 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -181,16 +181,18 @@ export function selectDeferredListRenderState( return "pending"; } -export type TimelineBodySurface = "skeleton" | "empty" | "list"; +export type TimelineBodySurface = "skeleton" | "error" | "empty" | "list"; export function selectTimelineBodySurface({ deferredCount, preserveSettledEmptyIntro = false, + isError = false, isLoading, liveCount, }: { deferredCount: number; preserveSettledEmptyIntro?: boolean; + isError?: boolean; isLoading: boolean; liveCount: number; }): TimelineBodySurface { @@ -199,6 +201,12 @@ export function selectTimelineBodySurface({ } const renderState = selectDeferredListRenderState(deferredCount, liveCount); + if (renderState === "list") { + return "list"; + } + if (isError && liveCount === 0) { + return "error"; + } if (renderState === "pending") { // Preserve a channel/DM intro across a new append only when this channel // already committed an authoritative empty timeline. On first load, the diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fa8bb4e9f6d..88ec8080aab 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip"; import { useCommittedEmptyTimeline } from "./useCommittedEmptyTimeline"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock"; +import { MessageTimelineErrorCard } from "./MessageTimelineErrorCard"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; import type { TimelineVirtualizerApi } from "./TimelineMessageList"; @@ -52,7 +53,9 @@ type MessageTimelineProps = { displayName: string; participants: DirectMessageIntroParticipant[]; } | null; + isError?: boolean; isLoading?: boolean; + onRetry?: () => void; entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; emptyTitle?: string; @@ -163,7 +166,9 @@ const MessageTimelineBase = React.forwardRef< messages, mainEntries, threadSummaries, + isError = false, isLoading = false, + onRetry, entranceMessageId = null, onEntranceMessageComplete, emptyTitle = "No messages yet", @@ -299,10 +304,12 @@ const MessageTimelineBase = React.forwardRef< const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, preserveSettledEmptyIntro, + isError, isLoading: timelineIsLoading, liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + const showTimelineError = timelineBodySurface === "error"; const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] = React.useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes @@ -425,15 +432,17 @@ const MessageTimelineBase = React.forwardRef< [onVirtualizerAtBottomStateChange, queueSemanticBottom], ); - const timelineIntroSurface = selectTimelineIntroSurface({ - hasChannelIntro: channelIntro !== null && directMessageIntro === null, - hasDirectMessageIntro: directMessageIntro !== null, - hasReachedChannelStart: - !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && - !isHoldingPrepend && - (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), - isSkeletonVisible: showTimelineSkeleton, - }); + const timelineIntroSurface = showTimelineError + ? null + : selectTimelineIntroSurface({ + hasChannelIntro: channelIntro !== null && directMessageIntro === null, + hasDirectMessageIntro: directMessageIntro !== null, + hasReachedChannelStart: + !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && + !isHoldingPrepend && + (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), + isSkeletonVisible: showTimelineSkeleton, + }); const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; @@ -765,7 +774,10 @@ const MessageTimelineBase = React.forwardRef< showChannelIntroOnly ? "pt-[var(--channel-top-chrome-height,4.5rem)]" : channelChrome.contentPadding, - (showIntro || showGenericEmpty || showMessageList) && + (showIntro || + showTimelineError || + showGenericEmpty || + showMessageList) && "min-h-full", )} ref={contentRef} @@ -784,7 +796,8 @@ const MessageTimelineBase = React.forwardRef< className={cn( "flex min-h-[18rem] min-w-0 flex-col gap-2", useTimelineVirtualizer && "min-h-0 flex-1", - (showIntro || showGenericEmpty) && "min-h-full", + (showIntro || showTimelineError || showGenericEmpty) && + "min-h-full", showMessageList && !showIntro && !useTimelineVirtualizer && @@ -794,6 +807,9 @@ const MessageTimelineBase = React.forwardRef< {showTimelineSkeleton ? ( ) : null} + {showTimelineError ? ( + + ) : null} {activeDirectMessageIntro ? (
void; +}) { + return ( +
+

+ Couldn't load messages +

+

+ The channel history didn't load. Check your connection and try + again. +

+ {onRetry ? ( + + ) : null} +
+ ); +}