Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
);
}
22 changes: 10 additions & 12 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner";
import {
mentionsKnownAgent,
selectThreadComposerBotTypingPubkeys,
shouldPrioritizeIdleAuxiliary,
shouldUseFocusIdleDrawer,
} from "@/features/channels/ui/ChannelPane.helpers";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 12 additions & 21 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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}
Expand Down
71 changes: 71 additions & 0 deletions desktop/src/features/messages/lib/projectChannelWindow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
107 changes: 104 additions & 3 deletions desktop/src/features/messages/lib/timelineLoadingState.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 }),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
});
Loading