From 2d03c0170d231aaa335e90a93f87a32aa50955ac Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 28 May 2026 22:42:45 +0100 Subject: [PATCH 1/3] UI thread cleanup + tanstack virtualized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - virtua → @tanstack/react-virtual for thread + raw logs - scroll-to-bottom now settles after row remeasure (no more double-click) - streaming safety-net: re-pin to end on in-place row growth - session footer: muted-foreground, opacity-50 → 100 on thread hover, right-aligned always - thread scroll container: scroll-mask edge fade - p line-height bumped to 1.9 in user + agent messages Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/code/package.json | 1 + .../components/ContextUsageIndicator.tsx | 2 +- .../sessions/components/ConversationView.tsx | 28 +- .../sessions/components/SessionFooter.tsx | 19 +- .../sessions/components/SessionView.tsx | 8 +- .../sessions/components/VirtualizedList.tsx | 261 ++++++++++++------ .../session-update/AgentMessage.tsx | 2 +- .../components/session-update/UserMessage.tsx | 2 +- pnpm-lock.yaml | 20 ++ pnpm-workspace.yaml | 2 + 10 files changed, 241 insertions(+), 104 deletions(-) diff --git a/apps/code/package.json b/apps/code/package.json index 4fb9ab883c..dee944027f 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -143,6 +143,7 @@ "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-virtual": "^3.13.26", "@tiptap/core": "^3.13.0", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", diff --git a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx index f1ac3c11ba..87f1df8760 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx +++ b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx @@ -58,7 +58,7 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { strokeLinecap="round" /> - + {formatTokensCompact(used)}/{formatTokensCompact(size)} ·{" "} {percentage}% diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd67..234cf71a40 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -14,7 +14,13 @@ import { SkillButtonActionMessage } from "@features/skill-buttons/components/Ski import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; -import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import type { AcpMessage } from "@shared/types/session-events"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -250,7 +256,7 @@ export function ConversationView({ poolOptions={DIFFS_POOL_OPTIONS} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > -
+
{showScrollButton && ( - - + + + + + + Scroll to bottom + )}
diff --git a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx index 6b988222b4..7910864837 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx @@ -33,7 +33,7 @@ export function SessionFooter({ usage, }: SessionFooterProps) { const rightSide = ( - + {task && } @@ -41,16 +41,16 @@ export function SessionFooter({ if (isPromptPending && !isCompacting) { if (hasPendingPermission) { return ( - + - + Awaiting permission... @@ -61,7 +61,7 @@ export function SessionFooter({ } return ( - + {queuedCount > 0 && ( - + ({queuedCount} queued) )} @@ -89,19 +89,18 @@ export function SessionFooter({ !wasCancelled; return ( - + {showDuration && ( Generated in {formatDuration(lastGenerationDuration)} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 49b9cdfa95..39d376854a 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -476,7 +476,7 @@ export function SessionView({ /> ) : ( - + { items: T[]; @@ -28,6 +29,9 @@ export interface VirtualizedListHandle { } const AT_BOTTOM_THRESHOLD = 50; +const ESTIMATED_ROW_SIZE = 80; +const OVERSCAN = 6; +const FOOTER_KEY = "__virtualized_footer__"; function VirtualizedListInner( { @@ -43,106 +47,203 @@ function VirtualizedListInner( }: VirtualizedListProps, ref: React.ForwardedRef, ) { - const listRef = useRef(null); - const isAtBottomRef = useRef(true); + const parentRef = useRef(null); const initializedRef = useRef(false); + const isAtBottomRef = useRef(true); + const settlingRef = useRef(false); + const settleRafRef = useRef(null); const onScrollStateChangeRef = useRef(onScrollStateChange); onScrollStateChangeRef.current = onScrollStateChange; - const itemCountRef = useRef(items.length); - itemCountRef.current = items.length; + + const hasFooter = footer != null; + const totalCount = items.length + (hasFooter ? 1 : 0); + + const virtualizer = useVirtualizer({ + count: totalCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ESTIMATED_ROW_SIZE, + overscan: OVERSCAN, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: AT_BOTTOM_THRESHOLD, + getItemKey: (index) => { + if (hasFooter && index === items.length) return FOOTER_KEY; + const item = items[index]; + return getItemKey ? getItemKey(item, index) : index; + }, + }); + + const settleAtEnd = useCallback(() => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + } + settlingRef.current = true; + isAtBottomRef.current = true; + let attempts = 0; + const step = () => { + virtualizer.scrollToEnd(); + if (virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD)) { + settlingRef.current = false; + settleRafRef.current = null; + if (initializedRef.current) { + onScrollStateChangeRef.current?.(true); + } + return; + } + if (++attempts > 12) { + settlingRef.current = false; + settleRafRef.current = null; + return; + } + settleRafRef.current = requestAnimationFrame(step); + }; + step(); + }, [virtualizer]); useImperativeHandle( ref, () => ({ - scrollToBottom: () => { - const handle = listRef.current; - if (handle) { - handle.scrollTo(handle.scrollSize); - isAtBottomRef.current = true; - } - }, + scrollToBottom: settleAtEnd, scrollToIndex: (index: number) => { - const handle = listRef.current; - if (handle) { - isAtBottomRef.current = false; - handle.scrollToIndex(index, { align: "center" }); + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + settlingRef.current = false; } + isAtBottomRef.current = false; + virtualizer.scrollToIndex(index, { align: "center" }); }, }), - [], + [virtualizer, settleAtEnd], ); + useEffect(() => { + return () => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + } + }; + }, []); + useLayoutEffect(() => { - const handle = listRef.current; - if (!handle) return; + if (initializedRef.current || totalCount === 0) return; + virtualizer.scrollToEnd(); + requestAnimationFrame(() => { + initializedRef.current = true; + }); + }, [totalCount, virtualizer]); - if (items.length > 0 && !initializedRef.current) { - handle.scrollToIndex(items.length - 1, { align: "end" }); + // Safety net: streaming tokens grow an existing row in place; neither + // followOnAppend (count-based) nor anchorTo='end' (above-viewport-resize) + // covers in-place growth of the last row. Re-pin to end when at-bottom. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on items mutation, including streaming text updates + useEffect(() => { + if (!initializedRef.current) return; + if (!isAtBottomRef.current) return; + virtualizer.scrollToEnd(); + }, [items, virtualizer]); - requestAnimationFrame(() => { - initializedRef.current = true; - }); - } - }, [items.length]); + const handleScroll = useCallback(() => { + const atBottom = virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD); + isAtBottomRef.current = atBottom; + if (!initializedRef.current) return; + // Suppress intermediate "not at bottom" pings while a programmatic + // scrollToEnd is still settling after row remeasure. + if (settlingRef.current && !atBottom) return; + onScrollStateChangeRef.current?.(atBottom); + }, [virtualizer]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items change for streaming scroll - useEffect(() => { - if (isAtBottomRef.current) { - const handle = listRef.current; - if (handle) { - // Use scrollToIndex for reliable positioning after measurements settle - const totalChildren = itemCountRef.current + (footer ? 1 : 0); - if (totalChildren > 0) { - handle.scrollToIndex(totalChildren - 1, { align: "end" }); - } - } - } - }, [items, footer]); - - const handleScroll = useCallback((offset: number) => { - const handle = listRef.current; - if (!handle) return; - const distanceFromBottom = handle.scrollSize - offset - handle.viewportSize; - const atBottom = distanceFromBottom < AT_BOTTOM_THRESHOLD; - if (isAtBottomRef.current !== atBottom) { - isAtBottomRef.current = atBottom; - } - // Skip reporting during initialization to avoid flashing the - // scroll-to-bottom button before measurements settle. - if (initializedRef.current) { - onScrollStateChangeRef.current?.(atBottom); - } - }, []); + const virtualItems = virtualizer.getVirtualItems(); + + const renderedIndices = useMemo(() => { + const set = new Set(); + for (const v of virtualItems) set.add(v.index); + return set; + }, [virtualItems]); + + const orphanKeepIndices = useMemo(() => { + if (!keepMounted || keepMounted.length === 0) return []; + return keepMounted.filter( + (i) => i >= 0 && i < items.length && !renderedIndices.has(i), + ); + }, [keepMounted, renderedIndices, items.length]); return ( -
- +
- {items.map((item, index) => { - const key = getItemKey ? getItemKey(item, index) : index; - return ( -
- {renderItem(item, index)} -
- ); - })} - {footer && ( -
- {footer} -
- )} - +
+ {virtualItems.map((virtualItem) => { + const isFooter = hasFooter && virtualItem.index === items.length; + const item = isFooter ? null : items[virtualItem.index]; + const itemKey = isFooter + ? FOOTER_KEY + : getItemKey + ? getItemKey(item as T, virtualItem.index) + : virtualItem.index; + return ( +
+
+ {isFooter ? footer : renderItem(item as T, virtualItem.index)} +
+
+ ); + })} + {orphanKeepIndices.map((index) => { + const item = items[index]; + const k = getItemKey ? getItemKey(item, index) : index; + return ( +
+
+ {renderItem(item, index)} +
+
+ ); + })} +
+
); } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx index 0cf7d00083..ff11486e3e 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx @@ -149,7 +149,7 @@ export const AgentMessage = memo(function AgentMessage({ }, [content]); return ( - + =18'} @@ -17225,6 +17237,14 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.1.0 + '@tanstack/react-virtual@3.13.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.16.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.16.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4309763b4..7ad66fb59b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,8 @@ minimumReleaseAgeExclude: - '@pierre/diffs' - '@posthog/quill' - '@posthog/quill-tokens' + - '@tanstack/react-virtual' + - '@tanstack/virtual-core' onlyBuiltDependencies: - '@parcel/watcher' From a161293047e0d9b4206f7473f9458c46c7d60167 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 28 May 2026 23:04:25 +0100 Subject: [PATCH 2/3] remove max-height + top border on pending permission box Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/renderer/features/sessions/components/SessionView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 39d376854a..a40c65af26 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -598,7 +598,7 @@ export function SessionView({
) : hideInput ? null : firstPendingPermission ? ( - + Date: Fri, 29 May 2026 05:38:16 +0100 Subject: [PATCH 3/3] input group addon padding + focus ring + bottom padding bumps Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/message-editor/components/PromptInput.tsx | 4 ++-- .../renderer/features/sessions/components/SessionView.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 4e54828720..9ad3d72633 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -285,7 +285,7 @@ export const PromptInput = forwardRef( ( >
- +