fix(desktop): surface channel history load failures (#7013)

## Summary

A failed initial channel-history request no longer appears as an
authoritative empty channel. The timeline now shows an announced error
with a Retry action, while cached messages remain visible when a later
refresh fails; successful empty channels continue to use their normal
intro state.

### Related issue

None found.

### Testing

- Full desktop unit suite (`pnpm test`)
- Desktop TypeScript check (`pnpm exec tsc --noEmit`)
- Biome checks for changed files
- Repository file-size ratchet
- Full pre-push desktop checks and tests
- Desktop app launched successfully against local Postgres and Redis for
manual testing

No screenshot is included because the new UI is only shown after a
terminal relay-history failure; the regression test pins the
error/empty/list precedence directly.

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-28 23:23:25 -04:00
committed by GitHub
parent 8dbc65d9e2
commit 00e61eafa9
12 changed files with 350 additions and 51 deletions

View File

@@ -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,
);
}

View File

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

View File

@@ -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;

View File

@@ -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<string | null>(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}

View File

@@ -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();
}
});

View File

@@ -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);
}

View File

@@ -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();
}
});

View File

@@ -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,
);
}

View File

@@ -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({

View File

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

View File

@@ -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 ? (
<TimelineSkeleton rows={timelineSkeletonRows} />
) : null}
{showTimelineError ? (
<MessageTimelineErrorCard onRetry={onRetry} />
) : null}
{activeDirectMessageIntro ? (
<div
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"

View File

@@ -0,0 +1,36 @@
import { Button } from "@/shared/ui/button";
/** Retryable terminal failure for a channel history load with no cached rows. */
export function MessageTimelineErrorCard({
onRetry,
}: {
onRetry?: () => void;
}) {
return (
<div
className="mt-auto rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-6 py-10 text-center shadow-xs"
data-testid="message-timeline-error"
role="alert"
>
<p className="text-base font-semibold tracking-tight">
Couldn&apos;t load messages
</p>
<p className="mt-2 text-sm text-muted-foreground">
The channel history didn&apos;t load. Check your connection and try
again.
</p>
{onRetry ? (
<Button
className="mt-4"
data-testid="message-timeline-retry"
onClick={onRetry}
size="sm"
type="button"
variant="outline"
>
Retry
</Button>
) : null}
</div>
);
}