perf(desktop): virtualize message timeline and memoize Markdown for instant channel switching (#146)

This commit is contained in:
Wes
2026-03-21 09:43:25 -07:00
committed by GitHub
parent 266d9c6b64
commit 537e54ed6e
8 changed files with 247 additions and 101 deletions
+1
View File
@@ -31,6 +31,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-virtual": "^3.13.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
+20
View File
@@ -41,6 +41,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@tanstack/react-virtual':
specifier: ^3.13.0
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
@@ -1036,6 +1039,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.23':
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -2780,6 +2792,14 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.13.23
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/virtual-core@3.13.23': {}
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
+1 -1
View File
@@ -31,7 +31,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests)
["src/app/AppShell.tsx", 750],
["src/app/AppShell.tsx", 775],
["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring
["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
+54 -43
View File
@@ -238,14 +238,6 @@ export function AppShell() {
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
selectedView === "home"
? "home"
: selectedView === "agents"
? "agents"
: selectedView === "settings"
? "settings"
: `channel:${activeChannel?.id ?? "none"}`;
const shouldLoadTimeline =
activeChannel !== null && activeChannel.channelType !== "forum";
const isTimelineLoading =
@@ -610,10 +602,7 @@ export function AppShell() {
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
@@ -655,7 +644,13 @@ export function AppShell() {
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{selectedView === "home" ? (
<div
className={
selectedView === "home"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
@@ -671,37 +666,53 @@ export function AppShell() {
void homeFeedQuery.refetch();
}}
/>
) : selectedView === "agents" ? (
</div>
<div
className={
selectedView === "agents"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<AgentsView />
) : activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={handleCancelReply}
onReply={handleReply}
onSend={handleSend}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel &&
searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
typingPubkeys={typingPubkeys}
/>
)}
</div>
<div
className={
selectedView !== "home" && selectedView !== "agents"
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "hidden"
}
>
{activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={handleCancelReply}
onReply={handleReply}
onSend={handleSend}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel &&
searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
typingPubkeys={typingPubkeys}
/>
)}
</div>
</div>
</SidebarInset>
</React.Fragment>
+7 -7
View File
@@ -146,8 +146,8 @@ export function useRelayAgentsQuery() {
return useQuery({
queryKey: relayAgentsQueryKey,
queryFn: listRelayAgents,
staleTime: 15_000,
refetchInterval: 15_000,
staleTime: 30_000,
refetchInterval: 30_000,
});
}
@@ -155,15 +155,15 @@ export function useManagedAgentsQuery() {
return useQuery({
queryKey: managedAgentsQueryKey,
queryFn: listManagedAgents,
staleTime: 1_000,
staleTime: 5_000,
refetchInterval: (query) => {
const agents = query.state.data as ManagedAgent[] | undefined;
// Only local "running" agents need fast polling (process state can
// change). "deployed" is static control-plane state — presence polling
// handles the live signal for remote agents separately.
return agents?.some((agent) => agent.status === "running")
? 2_000
: 10_000;
? 5_000
: 30_000;
},
});
}
@@ -440,8 +440,8 @@ export function useManagedAgentLogQuery(
queryFn: () => getManagedAgentLog(pubkey!, lineCount),
enabled: pubkey !== null,
retry: false,
staleTime: 1_000,
refetchInterval: pubkey ? 2_000 : false,
staleTime: 3_000,
refetchInterval: pubkey ? 5_000 : false,
});
}
@@ -1,5 +1,6 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -11,6 +12,9 @@ import { SystemMessageRow } from "./SystemMessageRow";
import { TimelineSkeleton } from "./TimelineSkeleton";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
const ESTIMATED_ROW_HEIGHT = 60;
const OVERSCAN_COUNT = 10;
type MessageTimelineProps = {
channelId?: string | null;
messages: TimelineMessage[];
@@ -44,6 +48,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
overscan: OVERSCAN_COUNT,
});
const {
bottomAnchorRef,
contentRef,
@@ -52,22 +65,26 @@ export const MessageTimeline = React.memo(function MessageTimeline({
newMessageCount,
scrollToBottom,
syncScrollState,
timelineRef,
} = useTimelineScrollManager({
channelId,
isLoading,
messages,
onTargetReached,
scrollContainerRef,
targetMessageId,
virtualizer,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div className="relative min-h-0 flex-1">
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={timelineRef}
ref={scrollContainerRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
@@ -100,29 +117,46 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
{!isLoading
? messages.map((message) =>
message.kind === KIND_SYSTEM_MESSAGE ? (
<SystemMessageRow
body={message.body}
currentPubkey={currentPubkey}
{!isLoading && messages.length > 0 ? (
<div
className="relative w-full"
style={{ height: `${totalSize}px` }}
>
{virtualItems.map((virtualRow) => {
const message = messages[virtualRow.index];
return (
<div
key={message.id}
profiles={profiles}
time={message.time}
/>
) : (
<MessageRow
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
key={message.id}
message={message}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>
),
)
: null}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
{message.kind === KIND_SYSTEM_MESSAGE ? (
<SystemMessageRow
body={message.body}
currentPubkey={currentPubkey}
profiles={profiles}
time={message.time}
/>
) : (
<MessageRow
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
message={message}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>
)}
</div>
);
})}
</div>
) : null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
@@ -1,4 +1,5 @@
import * as React from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { TimelineMessage } from "@/features/messages/types";
import { isNearBottom } from "./messageTimelineUtils";
@@ -8,15 +9,19 @@ export function useTimelineScrollManager({
isLoading,
messages,
onTargetReached,
scrollContainerRef,
targetMessageId,
virtualizer,
}: {
channelId?: string | null;
isLoading: boolean;
messages: TimelineMessage[];
onTargetReached?: (messageId: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
targetMessageId?: string | null;
virtualizer?: Virtualizer<HTMLDivElement, Element>;
}) {
const timelineRef = React.useRef<HTMLDivElement>(null);
const timelineRef = scrollContainerRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
const hasInitializedRef = React.useRef(false);
@@ -35,6 +40,10 @@ export function useTimelineScrollManager({
>(null);
const [newMessageCount, setNewMessageCount] = React.useState(0);
// Keep a ref to the virtualizer so callbacks don't need it as a dependency
const virtualizerRef = React.useRef(virtualizer);
virtualizerRef.current = virtualizer;
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
React.useLayoutEffect(() => {
hasInitializedRef.current = false;
@@ -55,6 +64,7 @@ export function useTimelineScrollManager({
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
const syncScrollState = React.useCallback(() => {
const timeline = timelineRef.current;
if (!timeline) {
@@ -104,6 +114,7 @@ export function useTimelineScrollManager({
}
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const restoreScrollPosition = React.useCallback(
(scrollTop: number) => {
const timeline = timelineRef.current;
@@ -135,6 +146,7 @@ export function useTimelineScrollManager({
[syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
const timeline = timelineRef.current;
@@ -145,6 +157,11 @@ export function useTimelineScrollManager({
isProgrammaticBottomScrollRef.current = true;
const virt = virtualizerRef.current;
if (virt && virt.options.count > 0) {
virt.scrollToIndex(virt.options.count - 1, { align: "end" });
}
const alignToBottom = (nextBehavior: ScrollBehavior) => {
bottomAnchorRef.current?.scrollIntoView({
block: "end",
@@ -191,6 +208,7 @@ export function useTimelineScrollManager({
[syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
React.useEffect(() => {
const timeline = timelineRef.current;
@@ -295,6 +313,7 @@ export function useTimelineScrollManager({
previousMessageCountRef.current = messages.length;
}, [isLoading, latestMessage, messages.length, scrollToBottom]);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
React.useEffect(() => {
if (!targetMessageId) {
handledTargetMessageIdRef.current = null;
@@ -311,26 +330,56 @@ export function useTimelineScrollManager({
return;
}
const targetElement = timeline.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (!targetElement) {
return;
}
const settleOnTarget = () => {
handledTargetMessageIdRef.current = targetMessageId;
shouldStickToBottomRef.current = false;
isAtBottomRef.current = false;
isProgrammaticBottomScrollRef.current = false;
previousScrollTopRef.current = timeline.scrollTop;
setIsAtBottom(false);
setHighlightedMessageId(targetMessageId);
setNewMessageCount(0);
onTargetReached?.(targetMessageId);
};
handledTargetMessageIdRef.current = targetMessageId;
shouldStickToBottomRef.current = false;
isAtBottomRef.current = false;
isProgrammaticBottomScrollRef.current = false;
targetElement.scrollIntoView({
block: "center",
behavior: "smooth",
});
previousScrollTopRef.current = timeline.scrollTop;
setIsAtBottom(false);
setHighlightedMessageId(targetMessageId);
setNewMessageCount(0);
onTargetReached?.(targetMessageId);
// With virtualization the target row may not be in the DOM yet.
// Use scrollToIndex to bring it into view first, then highlight.
const virt = virtualizerRef.current;
const targetIndex = messages.findIndex((m) => m.id === targetMessageId);
if (virt && targetIndex >= 0) {
virt.scrollToIndex(targetIndex, { align: "center" });
// Give the virtualizer a frame to render the row before querying the DOM.
requestAnimationFrame(() => {
const targetElement = timeline.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (targetElement) {
targetElement.scrollIntoView({
block: "center",
behavior: "smooth",
});
}
settleOnTarget();
});
} else {
// Fallback for non-virtualized usage or unknown target
const targetElement = timeline.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (!targetElement) {
return;
}
targetElement.scrollIntoView({
block: "center",
behavior: "smooth",
});
settleOnTarget();
}
const timeout = window.setTimeout(() => {
setHighlightedMessageId((current) =>
@@ -341,7 +390,7 @@ export function useTimelineScrollManager({
return () => {
window.clearTimeout(timeout);
};
}, [isLoading, onTargetReached, targetMessageId]);
}, [isLoading, messages, onTargetReached, targetMessageId]);
return {
bottomAnchorRef,
@@ -351,6 +400,5 @@ export function useTimelineScrollManager({
newMessageCount,
scrollToBottom,
syncScrollState,
timelineRef,
};
}
+36 -4
View File
@@ -1,4 +1,4 @@
import type * as React from "react";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
@@ -181,15 +181,35 @@ function createMarkdownComponents(
} as Components;
}
export function Markdown({
function shallowArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function MarkdownInner({
className,
compact = false,
content,
mentionNames,
tight = false,
}: MarkdownProps) {
const variant = tight ? "tight" : compact ? "compact" : "default";
const variant: MarkdownVariant = tight
? "tight"
: compact
? "compact"
: "default";
const { channels, onOpenChannel } = useChannelNavigation();
const components = React.useMemo(
() => createMarkdownComponents(variant, channels, onOpenChannel),
[variant, channels, onOpenChannel],
);
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -212,7 +232,7 @@ export function Markdown({
)}
>
<ReactMarkdown
components={createMarkdownComponents(variant, channels, onOpenChannel)}
components={components}
remarkPlugins={[
remarkGfm,
remarkBreaks,
@@ -225,3 +245,15 @@ export function Markdown({
</div>
);
}
export const Markdown = React.memo(
MarkdownInner,
(prev, next) =>
prev.content === next.content &&
prev.className === next.className &&
prev.compact === next.compact &&
prev.tight === next.tight &&
shallowArrayEqual(prev.mentionNames, next.mentionNames),
);
Markdown.displayName = "Markdown";