Fix scrollback pagination and IntersectionObserver lifecycle (#212)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-02 15:16:40 -07:00
committed by GitHub
parent 579da797e0
commit 1f21085765
12 changed files with 327 additions and 58 deletions

View File

@@ -32,14 +32,15 @@ const rules = [
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 810], // message edit state + handlers + ChannelPane edit prop threading
["src/app/AppShell.tsx", 820], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 740], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests

View File

@@ -27,6 +27,8 @@ import {
useChannelSubscription,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import {
collectMessageAuthorPubkeys,
formatTimelineMessages,
@@ -117,6 +119,8 @@ export function AppShell() {
const activeChannelId = activeChannel?.id ?? null;
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const { fetchOlder, isFetchingOlder, hasOlderMessages } =
useFetchOlderMessages(activeChannel);
const latestActiveMessage =
messagesQuery.data?.[messagesQuery.data.length - 1];
const activeReadAt = latestActiveMessage
@@ -486,7 +490,7 @@ export function AppShell() {
}
queryClient.setQueryData<RelayEvent[]>(
["channel-messages", activeChannel.id],
channelMessagesKey(activeChannel.id),
(current = []) => mergeMessages(current, event),
);
} catch (error) {
@@ -732,6 +736,9 @@ export function AppShell() {
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {

View File

@@ -15,6 +15,9 @@ type ChannelPaneProps = {
body: string;
id: string;
} | null;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
@@ -45,6 +48,9 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
currentPubkey,
editTarget = null,
fetchOlder,
hasOlderMessages,
isFetchingOlder,
isSending,
isTimelineLoading,
messages,
@@ -68,6 +74,9 @@ export const ChannelPane = React.memo(function ChannelPane({
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"

View File

@@ -5,6 +5,7 @@ import {
channelsQueryKey,
updateChannelLastMessageAt,
} from "@/features/channels/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { relayClient } from "@/shared/api/relayClient";
@@ -196,7 +197,7 @@ export function useUnreadChannels(
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
queryClient.setQueryData<RelayEvent[]>(
["channel-messages", channelId],
channelMessagesKey(channelId),
(current) => {
if (!current) {
return current;

View File

@@ -2,6 +2,12 @@ import { useEffect, useEffectEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
import {
channelMessagesKey,
dedupeMessagesById,
normalizeTimelineMessages,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import {
buildReplyTags,
normalizeMentionPubkeys,
@@ -20,47 +26,10 @@ import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
type MessageQueryContext = {
optimisticId: string;
previousMessages: RelayEvent[];
queryKey: readonly ["channel-messages", string];
queryKey: ReturnType<typeof channelMessagesKey>;
};
const CHANNEL_HISTORY_LIMIT = 200;
const MAX_TIMELINE_MESSAGES = CHANNEL_HISTORY_LIMIT * 2;
function dedupeMessagesById(messages: RelayEvent[]) {
const seenIds = new Set<string>();
const deduped: RelayEvent[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (seenIds.has(message.id)) {
continue;
}
seenIds.add(message.id);
deduped.push(message);
}
return deduped.reverse();
}
function sortMessages(messages: RelayEvent[]) {
return dedupeMessagesById(messages).sort(
(left, right) => left.created_at - right.created_at,
);
}
function normalizeTimelineMessages(messages: RelayEvent[]) {
const normalized = sortMessages(messages);
if (normalized.length <= MAX_TIMELINE_MESSAGES) {
return normalized;
}
// Keep the live timeline bounded so de-virtualized rendering does not grow
// into an unbounded DOM during long-lived channel sessions.
return normalized.slice(-MAX_TIMELINE_MESSAGES);
}
function mergeMessagesWithNormalizer(
current: RelayEvent[],
@@ -145,7 +114,7 @@ function createOptimisticMessage(
export function useChannelMessagesQuery(channel: Channel | null) {
const queryClient = useQueryClient();
const queryKey = ["channel-messages", channel?.id ?? "none"] as const;
const queryKey = channelMessagesKey(channel?.id ?? "none");
return useQuery({
enabled: channel !== null && channel.channelType !== "forum",
@@ -189,7 +158,7 @@ export function useChannelSubscription(channel: Channel | null) {
);
queryClient.setQueryData<RelayEvent[]>(
["channel-messages", channelId],
channelMessagesKey(channelId),
(current = []) => {
const mergedHistory = normalizeTimelineMessages([
...current,
@@ -212,7 +181,7 @@ export function useChannelSubscription(channel: Channel | null) {
new Date(event.created_at * 1_000).toISOString(),
);
queryClient.setQueryData<RelayEvent[]>(
["channel-messages", channelId],
channelMessagesKey(channelId),
(current = []) => mergeTimelineCacheMessages(current, event),
);
});
@@ -309,10 +278,9 @@ export function useSendMessageMutation(
// validation runs. The WebSocket path does not validate imeta tags.
if (parentEventId || (mediaTags && mediaTags.length > 0)) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>([
"channel-messages",
channel.id,
]) ?? [];
queryClient.getQueryData<RelayEvent[]>(
channelMessagesKey(channel.id),
) ?? [];
const result = await sendChannelMessage(
channel.id,
content,
@@ -373,7 +341,7 @@ export function useSendMessageMutation(
return undefined;
}
const queryKey = ["channel-messages", channel.id] as const;
const queryKey = channelMessagesKey(channel.id);
await queryClient.cancelQueries({ queryKey });
const previousMessages =
@@ -477,7 +445,7 @@ export function useEditMessageMutation(channel: Channel | null) {
}
queryClient.setQueryData<RelayEvent[]>(
["channel-messages", channel.id],
channelMessagesKey(channel.id),
(current = []) =>
current.map((message) =>
message.id === eventId ? { ...message, content } : message,

View File

@@ -0,0 +1,46 @@
import type { RelayEvent } from "@/shared/api/types";
const MAX_TIMELINE_MESSAGES = 2_000;
export function channelMessagesKey(channelId: string) {
return ["channel-messages", channelId] as const;
}
export function dedupeMessagesById(messages: RelayEvent[]) {
const seenIds = new Set<string>();
const deduped: RelayEvent[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (seenIds.has(message.id)) {
continue;
}
seenIds.add(message.id);
deduped.push(message);
}
return deduped.reverse();
}
export function sortMessages(messages: RelayEvent[]) {
return dedupeMessagesById(messages).sort(
(left, right) => left.created_at - right.created_at,
);
}
/**
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} so
* de-virtualized rendering does not grow into an unbounded DOM during
* long-lived channel sessions.
*/
export function normalizeTimelineMessages(messages: RelayEvent[]) {
const normalized = sortMessages(messages);
if (normalized.length <= MAX_TIMELINE_MESSAGES) {
return normalized;
}
return normalized.slice(-MAX_TIMELINE_MESSAGES);
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { ArrowDown, Loader2 } from "lucide-react";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -7,6 +7,7 @@ import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { TimelineSkeleton } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
type MessageTimelineProps = {
@@ -17,6 +18,9 @@ type MessageTimelineProps = {
emptyDescription?: string;
activeReplyTargetId?: string | null;
currentPubkey?: string;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
profiles?: UserProfileLookup;
onEdit?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
@@ -37,6 +41,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
emptyDescription = "Send the first message to start the thread.",
activeReplyTargetId = null,
currentPubkey,
fetchOlder,
hasOlderMessages = true,
isFetchingOlder = false,
profiles,
onEdit,
onReply,
@@ -45,6 +52,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onTargetReached,
}: MessageTimelineProps) {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
const {
bottomAnchorRef,
@@ -52,6 +60,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
@@ -63,6 +72,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
targetMessageId,
});
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
return (
<div className="relative min-h-0 flex-1">
<div
@@ -75,6 +93,27 @@ export const MessageTimeline = React.memo(function MessageTimeline({
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{isFetchingOlder ? (
<div className="flex justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : null}
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
<div
className="flex items-center gap-3 py-2"
data-testid="message-timeline-beginning"
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Beginning of conversation
</p>
<Separator className="flex-1" />
</div>
) : null}
<div
className="flex items-center gap-3"
data-testid="message-timeline-day-divider"

View File

@@ -0,0 +1,92 @@
import * as React from "react";
type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
};
/**
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
* container enters the viewport, then restores the scroll position so the
* visible content doesn't jump.
*/
export function useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
React.useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
if (
!sentinel ||
!container ||
!fetchOlder ||
isLoading ||
!hasOlderMessages
) {
return;
}
let disposed = false;
let currentObserver: IntersectionObserver | null = null;
const observe = () => {
if (disposed) {
return;
}
currentObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || disposed) {
return;
}
currentObserver?.disconnect();
const previousHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
void fetchOlder().then(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newHeight = container.scrollHeight;
const delta = newHeight - previousHeight;
if (delta > 0) {
restoreScrollPositionRef.current(previousScrollTop + delta);
}
observe();
});
});
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
currentObserver.observe(sentinel);
};
observe();
return () => {
disposed = true;
currentObserver?.disconnect();
};
}, [
fetchOlder,
hasOlderMessages,
isLoading,
scrollContainerRef,
sentinelRef,
]);
}

View File

@@ -380,6 +380,7 @@ export function useTimelineScrollManager({
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
syncScrollState,
};

View File

@@ -0,0 +1,89 @@
import { useCallback, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
channelMessagesKey,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import type { Channel, RelayEvent } from "@/shared/api/types";
const OLDER_MESSAGES_BATCH_SIZE = 100;
export function useFetchOlderMessages(channel: Channel | null) {
const queryClient = useQueryClient();
const channelId = channel?.id ?? null;
const [isFetchingOlder, setIsFetchingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(true);
const isFetchingOlderRef = useRef(false);
const hasOlderMessagesRef = useRef(true);
const previousChannelIdRef = useRef(channelId);
if (previousChannelIdRef.current !== channelId) {
previousChannelIdRef.current = channelId;
hasOlderMessagesRef.current = true;
setHasOlderMessages(true);
}
const fetchOlder = useCallback(async () => {
if (
!channelId ||
isFetchingOlderRef.current ||
!hasOlderMessagesRef.current
) {
return;
}
const queryKey = channelMessagesKey(channelId);
const currentMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (currentMessages.length === 0) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
return;
}
// Use the oldest timestamp directly — `until` is inclusive so the relay will
// return the boundary message again, but `sortMessages` deduplicates by id.
// Subtracting 1 risks skipping messages that share the same second.
const oldestTimestamp = currentMessages[0].created_at;
isFetchingOlderRef.current = true;
setIsFetchingOlder(true);
try {
const olderMessages = await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
OLDER_MESSAGES_BATCH_SIZE,
);
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
sortMessages([...current, ...olderMessages]),
);
const updatedMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (
updatedMessages.length > 0 &&
updatedMessages[0].created_at === oldestTimestamp
) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
}
} catch (error) {
console.error("Failed to fetch older messages", channelId, error);
} finally {
isFetchingOlderRef.current = false;
setIsFetchingOlder(false);
}
}, [channelId, queryClient]);
return { fetchOlder, isFetchingOlder, hasOlderMessages };
}

View File

@@ -46,6 +46,18 @@ export class RelayClient {
private notifyReconnectListeners = false;
async fetchChannelHistory(channelId: string, limit = 50) {
return this.fetchHistory(this.buildChannelFilter(channelId, limit));
}
async fetchChannelHistoryBefore(
channelId: string,
before: number,
limit = 50,
) {
return this.fetchHistory(this.buildChannelFilter(channelId, limit, before));
}
private async fetchHistory(filter: RelaySubscriptionFilter) {
await this.ensureConnected();
return new Promise<RelayEvent[]>((resolve, reject) => {
@@ -64,11 +76,7 @@ export class RelayClient {
timeout,
});
void this.sendRaw([
"REQ",
subId,
this.buildChannelFilter(channelId, limit),
]).catch((error) => {
void this.sendRaw(["REQ", subId, filter]).catch((error) => {
window.clearTimeout(timeout);
this.subscriptions.delete(subId);
reject(
@@ -244,12 +252,19 @@ export class RelayClient {
private buildChannelFilter(
channelId: string,
limit: number,
until?: number,
): RelaySubscriptionFilter {
return {
const filter: RelaySubscriptionFilter = {
kinds: [...CHANNEL_EVENT_KINDS],
"#h": [channelId],
limit,
};
if (until !== undefined) {
filter.until = until;
}
return filter;
}
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {

View File

@@ -5,6 +5,7 @@ export type RelaySubscriptionFilter = {
"#h"?: string[];
limit: number;
since?: number;
until?: number;
};
type HistorySubscription = {