diff --git a/packages/app/src/components/agent-stream-web-virtualization.test.ts b/packages/app/src/components/agent-stream-web-virtualization.test.ts index ced53df4c..736fef315 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.test.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.test.ts @@ -54,6 +54,16 @@ function toolCall(id: string, seed: number): StreamItem { }; } +function thought(id: string, seed: number): StreamItem { + return { + kind: "thought", + id, + text: id, + status: "ready", + timestamp: createTimestamp(seed), + }; +} + function indexEntries(items: StreamItem[]): IndexedStreamItem[] { return items.map((item, index) => ({ item, index })); } @@ -111,6 +121,11 @@ describe("splitWebVirtualizedHistory", () => { }); describe("estimateStreamItemHeight", () => { + it("uses compact estimates for collapsed tool sequence rows", () => { + expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40); + expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40); + }); + it("uses a larger estimate for user messages with image attachments", () => { const item: StreamItem = { kind: "user_message", diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/components/agent-stream-web-virtualization.ts index b922fbd4d..92da8c346 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.ts @@ -1,8 +1,9 @@ import type { StreamItem } from "@/types/stream"; -import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata"; +import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate"; export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50; +const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40; type BottomAnchorE2ETestGlobals = typeof globalThis & { __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; @@ -48,9 +49,9 @@ export function estimateStreamItemHeight(item: StreamItem): number { case "assistant_message": return estimateAssistantMessageHeightFromCache(item.text) ?? 220; case "tool_call": - return 136; + return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "thought": - return 112; + return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "todo_list": return 144; case "activity_log": diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index def5e8562..9159de97b 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -29,8 +29,6 @@ import Markdown, { type ASTNode, type RenderRules, } from "react-native-markdown-display"; - -type MarkdownStyles = Record; import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { @@ -83,13 +81,13 @@ import { getAssistantImageMetadata, setAssistantImageMetadata, } from "@/utils/assistant-image-metadata"; +import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { createPreviewAttachmentId, getFileNameFromPath, parseImageDataUrl, } from "@/attachments/utils"; -export type { InlinePathTarget } from "@/utils/inline-path"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -97,6 +95,9 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service"; import type { DaemonClient } from "@server/client/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; +export type { InlinePathTarget } from "@/utils/inline-path"; + +type MarkdownStyles = Record; interface UserMessageProps { message: string; @@ -1186,16 +1187,29 @@ function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) { } interface AssistantMessageBlockContainerProps { + block: string; marginBottom: number; children: ReactNode; } function AssistantMessageBlockContainer({ + block, marginBottom, children, }: AssistantMessageBlockContainerProps) { const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]); - return {children}; + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setAssistantMarkdownBlockHeight({ block, width, height }); + }, + [block], + ); + return ( + + {children} + + ); } interface MemoizedMarkdownBlockProps { @@ -1572,6 +1586,7 @@ export const AssistantMessage = memo(function AssistantMessage({ {keyedBlocks.map(({ key, block }, index) => ( { + beforeEach(() => { + clearAssistantMessageHeightEstimateCache(); + clearAssistantImageMetadataCache(); + }); + + it("estimates assistant message height from measured markdown block heights", () => { + setAssistantMarkdownBlockHeight({ + block: "First paragraph", + width: 804, + height: 18.2, + }); + setAssistantMarkdownBlockHeight({ + block: "Second paragraph", + width: 804, + height: 41.1, + }); + + expect(estimateAssistantMessageHeightFromCache("First paragraph\n\nSecond paragraph")).toBe(97); + }); + + it("falls back to image metadata when markdown blocks are not measured", () => { + setAssistantImageMetadata( + { + source: "https://example.com/landscape.png", + }, + { width: 1200, height: 800 }, + ); + + expect( + estimateAssistantMessageHeightFromCache( + "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + ), + ).toBeGreaterThan(220); + }); +}); diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts new file mode 100644 index 000000000..05d702fb4 --- /dev/null +++ b/packages/app/src/utils/assistant-message-height-estimate.ts @@ -0,0 +1,116 @@ +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata"; +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; + +const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000; +const ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 16; +const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24; +const ASSISTANT_MARKDOWN_BLOCK_GAP = 12; + +interface MarkdownBlockHeightInput { + block: string; + width: number; +} + +const assistantMarkdownBlockHeightCache = new Map(); + +function touchCacheEntry(cache: Map, key: K, value: V, limit: number): void { + cache.delete(key); + cache.set(key, value); + if (cache.size <= limit) { + return; + } + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } +} + +function hashMarkdownBlock(block: string): string { + let hash = 2166136261; + for (let index = 0; index < block.length; index += 1) { + hash ^= block.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${block.length}:${(hash >>> 0).toString(36)}`; +} + +function normalizeMarkdownBlockWidth(width: number): number | null { + if (!Number.isFinite(width) || width <= 0) { + return null; + } + return Math.round(width); +} + +function createMarkdownBlockHeightKey(input: MarkdownBlockHeightInput): string | null { + const normalizedWidth = normalizeMarkdownBlockWidth(input.width); + if (normalizedWidth === null) { + return null; + } + if (input.block.length === 0) { + return null; + } + return `${normalizedWidth}:${hashMarkdownBlock(input.block)}`; +} + +export function setAssistantMarkdownBlockHeight(input: { + block: string; + width: number; + height: number; +}): number | null { + if (!Number.isFinite(input.height) || input.height <= 0) { + return null; + } + const key = createMarkdownBlockHeightKey({ + block: input.block, + width: input.width, + }); + if (!key) { + return null; + } + const height = Math.ceil(input.height); + touchCacheEntry( + assistantMarkdownBlockHeightCache, + key, + height, + ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT, + ); + return height; +} + +function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number | null { + const blocks = splitMarkdownBlocks(markdown); + if (blocks.length === 0) { + return null; + } + + let blockHeight = 0; + for (const block of blocks) { + const key = createMarkdownBlockHeightKey({ + block, + width: ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH, + }); + const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined; + if (cachedHeight === undefined) { + return null; + } + blockHeight += cachedHeight; + } + + return ( + ASSISTANT_MESSAGE_VERTICAL_PADDING + + blockHeight + + ASSISTANT_MARKDOWN_BLOCK_GAP * Math.max(0, blocks.length - 1) + ); +} + +export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { + return ( + estimateAssistantMarkdownBlockHeightFromCache(markdown) ?? + estimateAssistantImageMessageHeightFromCache(markdown) + ); +} + +export function clearAssistantMessageHeightEstimateCache(): void { + assistantMarkdownBlockHeightCache.clear(); +}