Stabilize web timeline height estimates
This commit is contained in:
@@ -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[] {
|
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||||
return items.map((item, index) => ({ item, index }));
|
return items.map((item, index) => ({ item, index }));
|
||||||
}
|
}
|
||||||
@@ -111,6 +121,11 @@ describe("splitWebVirtualizedHistory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("estimateStreamItemHeight", () => {
|
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", () => {
|
it("uses a larger estimate for user messages with image attachments", () => {
|
||||||
const item: StreamItem = {
|
const item: StreamItem = {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
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_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||||
|
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||||
|
|
||||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||||
@@ -48,9 +49,9 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
|||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return 136;
|
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||||
case "thought":
|
case "thought":
|
||||||
return 112;
|
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||||
case "todo_list":
|
case "todo_list":
|
||||||
return 144;
|
return 144;
|
||||||
case "activity_log":
|
case "activity_log":
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import Markdown, {
|
|||||||
type ASTNode,
|
type ASTNode,
|
||||||
type RenderRules,
|
type RenderRules,
|
||||||
} from "react-native-markdown-display";
|
} from "react-native-markdown-display";
|
||||||
|
|
||||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import MaskedView from "@react-native-masked-view/masked-view";
|
import MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
@@ -83,13 +81,13 @@ import {
|
|||||||
getAssistantImageMetadata,
|
getAssistantImageMetadata,
|
||||||
setAssistantImageMetadata,
|
setAssistantImageMetadata,
|
||||||
} from "@/utils/assistant-image-metadata";
|
} from "@/utils/assistant-image-metadata";
|
||||||
|
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
||||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||||
import {
|
import {
|
||||||
createPreviewAttachmentId,
|
createPreviewAttachmentId,
|
||||||
getFileNameFromPath,
|
getFileNameFromPath,
|
||||||
parseImageDataUrl,
|
parseImageDataUrl,
|
||||||
} from "@/attachments/utils";
|
} from "@/attachments/utils";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
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 { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
|
|
||||||
|
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1186,16 +1187,29 @@ function NativeShimmerPeakSvg({ gradientId }: { gradientId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantMessageBlockContainerProps {
|
interface AssistantMessageBlockContainerProps {
|
||||||
|
block: string;
|
||||||
marginBottom: number;
|
marginBottom: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessageBlockContainer({
|
function AssistantMessageBlockContainer({
|
||||||
|
block,
|
||||||
marginBottom,
|
marginBottom,
|
||||||
children,
|
children,
|
||||||
}: AssistantMessageBlockContainerProps) {
|
}: AssistantMessageBlockContainerProps) {
|
||||||
const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]);
|
const style = useMemo(() => (marginBottom > 0 ? { marginBottom } : undefined), [marginBottom]);
|
||||||
return <View style={style}>{children}</View>;
|
const handleLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = event.nativeEvent.layout;
|
||||||
|
setAssistantMarkdownBlockHeight({ block, width, height });
|
||||||
|
},
|
||||||
|
[block],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style={style} onLayout={isWeb ? handleLayout : undefined}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoizedMarkdownBlockProps {
|
interface MemoizedMarkdownBlockProps {
|
||||||
@@ -1572,6 +1586,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
{keyedBlocks.map(({ key, block }, index) => (
|
{keyedBlocks.map(({ key, block }, index) => (
|
||||||
<AssistantMessageBlockContainer
|
<AssistantMessageBlockContainer
|
||||||
key={key}
|
key={key}
|
||||||
|
block={block}
|
||||||
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0}
|
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0}
|
||||||
>
|
>
|
||||||
<MemoizedMarkdownBlock
|
<MemoizedMarkdownBlock
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
clearAssistantMessageHeightEstimateCache,
|
||||||
|
estimateAssistantMessageHeightFromCache,
|
||||||
|
setAssistantMarkdownBlockHeight,
|
||||||
|
} from "./assistant-message-height-estimate";
|
||||||
|
import {
|
||||||
|
clearAssistantImageMetadataCache,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "./assistant-image-metadata";
|
||||||
|
|
||||||
|
describe("assistant message height estimate", () => {
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
).toBeGreaterThan(220);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number>();
|
||||||
|
|
||||||
|
function touchCacheEntry<K, V>(cache: Map<K, V>, 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user