From 537e54ed6e7e1bebc572e2e2fb072b1418587d59 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 21 Mar 2026 09:43:25 -0700 Subject: [PATCH] perf(desktop): virtualize message timeline and memoize Markdown for instant channel switching (#146) --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 20 ++++ desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/app/AppShell.tsx | 97 +++++++++++-------- desktop/src/features/agents/hooks.ts | 14 +-- .../features/messages/ui/MessageTimeline.tsx | 82 +++++++++++----- .../messages/ui/useTimelineScrollManager.ts | 92 +++++++++++++----- desktop/src/shared/ui/markdown.tsx | 40 +++++++- 8 files changed, 247 insertions(+), 101 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 4751fc761..35de06da7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 34e41666c..0c9d1c2e3 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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': diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8b53baa96..17ba8177f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6345ef506..7d718ef3d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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} /> - + {selectedView === "home" ? ( - {selectedView === "home" ? ( +
- ) : selectedView === "agents" ? ( +
+
- ) : activeChannel?.channelType === "forum" ? ( - - ) : ( - - )} +
+
+ {activeChannel?.channelType === "forum" ? ( + + ) : ( + + )} +
diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 075ac24a1..856731b7b 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -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, }); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 560bfb78c..be2fa113b 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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(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 (
) : null} - {!isLoading - ? messages.map((message) => - message.kind === KIND_SYSTEM_MESSAGE ? ( - 0 ? ( +
+ {virtualItems.map((virtualRow) => { + const message = messages[virtualRow.index]; + return ( +
- ) : ( - - ), - ) - : 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 ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : null} +
diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index ff6ca211b..f73382852 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -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; targetMessageId?: string | null; + virtualizer?: Virtualizer; }) { - const timelineRef = React.useRef(null); + const timelineRef = scrollContainerRef; const contentRef = React.useRef(null); const bottomAnchorRef = React.useRef(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( - `[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( + `[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( + `[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, }; } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 92f2581ad..df9bbd16d 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -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({ )} > ); } + +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";