Files
paseo/packages/app/src/agent-stream/web-virtualization.ts
T
Mohamed Boudra c6f0c24bf6 Make a folded turn's summary line its own chat row (#9009)
* fix(app): make a folded turn's summary line its own chat row

The summary line of a collapsed turn was drawn inside its prompt's row, so the
row gap landed below the summary and the prompt touched it. Turn collapse now
emits a `turn_summary` row between the host and the reply, and one spacing
rule covers every prompt: 16 above whatever answers it (reply, summary row or
tool row). The summary row spaces like the work rows it stands for, 4 above the
reply and flush under a tool host.

This removes the per-row summary lookup, the host revision tracking and the
fragments in the view. A fold reuses a turn's previous summary row object while
its work and open state are unchanged, so mounted rows do not re-render.

* fix(app): keep the turn summary row at its host's timeline position

The chat outline resolves the active prompt from the timeline position of
the row under the reading line. The summary row had none, so the outline lost
its mark while the row sat there. The row now carries its host's position and
is replaced when that position lands.

* fix(app): keep the reader on a folding turn's host, not its summary row

When an older page completes the oldest loaded turn and it folds, the reading
position moves from the hidden rows to the turn's host row, as on main, so
the prompt and its summary row both land in view. The pagination helper now
finds the summary line in the row under the prompt.
2026-11-07 23:00:13 +07:00

96 lines
2.7 KiB
TypeScript

import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
DEFAULT_MOUNTED_RECENT_STREAM_ITEMS,
findMountedWindowStart,
getMountedRecentStreamItems,
} from "./history-window";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
return getMountedRecentStreamItems();
}
export interface IndexedStreamItem {
item: StreamItem;
index: number;
}
export interface WebVirtualizedHistoryWindow {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
}
interface StreamItemHeightEstimateInput {
item: StreamItem;
contentMaxWidth: number;
}
export function estimateStreamItemHeight({
item,
contentMaxWidth,
}: StreamItemHeightEstimateInput): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return (
estimateAssistantMessageHeightFromCache({
markdown: item.text,
contentMaxWidth,
}) ?? 220
);
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
case "turn_summary":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
case "notification":
return 88;
case "compaction":
return 72;
default:
return 120;
}
}
export { findMountedWindowStart };
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
minMountedCount: number;
}): WebVirtualizedHistoryWindow {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
mountedEntries: input.entries.slice(startIndex),
};
}