Refine tool call summaries and scrolling (#2069)

* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
This commit is contained in:
Mohamed Boudra
2026-07-14 21:16:29 +02:00
committed by GitHub
parent d781b68711
commit 1b8af2e58e
55 changed files with 1484 additions and 2488 deletions
@@ -33,6 +33,24 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
});
const HISTORY_START_THRESHOLD_PX = 96;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
}
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
let variants = historyRowDisplayVariants.get(item);
if (!variants) {
variants = {};
historyRowDisplayVariants.set(item, variants);
}
const key = compact ? "compact" : "regular";
variants[key] ??= { ...item };
return variants[key];
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
@@ -41,6 +59,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const {
agentId,
segments,
historyRowRevision,
liveHeadRowRevision,
boundary,
renderers,
listEmptyComponent,
@@ -73,12 +93,33 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyRows = useMemo(() => {
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
return segments.historyMounted;
}
return [...segments.historyVirtualized, ...segments.historyMounted];
}, [segments.historyMounted, segments.historyVirtualized]);
// Keep unchanged item identities intact so live updates only rerender rows
// whose projected content or local display state actually changed. A rare
// breakpoint change intentionally refreshes the whole history window.
const globallyRevisedHistoryRows = useMemo(() => {
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
}, [historyItems, historyRowRevision?.globalDisplayState]);
const displayStateHistoryRows = useMemo(
() =>
globallyRevisedHistoryRows.map((item) =>
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
),
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
);
const historyRows = useMemo(
() =>
displayStateHistoryRows.map((item) =>
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
@@ -307,12 +348,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyRows);
const rendered = renderHistoryMountedRow(item, index, historyItems);
return (rendered ?? null) as ReactElement | null;
},
);
const liveHeaderContent = useMemo(() => {
// Stable render events read the latest expansion state; this revision makes
// the memo invoke them again when that state changes.
void liveHeadRowRevision;
const liveHeadRows = segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
@@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
{liveAuxiliary}
</Fragment>
);
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
}, [
boundary,
listEmptyComponent,
liveHeadRowRevision,
renderLiveAuxiliary,
renderLiveHeadRow,
segments.liveHead,
]);
const historyFooterContent = useMemo(() => {
if (!isLoadingOlderHistory) {
@@ -344,12 +395,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
}, [isLoadingOlderHistory]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.
return (
<FlatList
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
strictMode
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
@@ -6,7 +6,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
vi.hoisted(() => {
@@ -25,8 +25,6 @@ vi.hoisted(() => {
});
});
vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
@@ -148,6 +146,59 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("rerenders a stable live-head row when its revision changes", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
const liveHead = [userMessage(1)];
let label = "collapsed";
const renderLiveHeadRow = vi.fn(() => <div>{label}</div>);
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [],
liveHead,
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: true,
},
renderers: {
...createRenderers(vi.fn()),
renderLiveHeadRow,
},
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 }));
});
expect(container.textContent).toContain("collapsed");
label = "expanded";
act(() => {
root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 }));
});
expect(container.textContent).toContain("expanded");
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
+36 -47
View File
@@ -25,7 +25,6 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
@@ -95,6 +94,7 @@ function isScrollContainerOverscrolledPastBottom(
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments,
liveHeadRowRevision,
boundary,
renderers,
listEmptyComponent,
@@ -131,11 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
contentRef,
});
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -540,10 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
}, [renderLiveHeadRow, segments.liveHead]);
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
@@ -564,47 +560,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary;
return (
<>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized,
)}
</div>
);
})}
</div>
) : null}
{mountedHistoryRows}
{liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)}
</div>
);
})}
</div>
) : null}
{mountedHistoryRows}
{liveHeadRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
{scrollbarOverlay}
</>
</div>
);
}
@@ -51,9 +51,17 @@ export interface StreamSegmentRenderers {
renderLiveAuxiliary: () => ReactNode;
}
export interface StreamHistoryRowRevision {
contentById: { has(id: string): boolean };
displayStateById: { has(id: string): boolean };
globalDisplayState: boolean;
}
export interface StreamRenderInput {
agentId: string;
segments: StreamRenderSegments;
historyRowRevision?: StreamHistoryRowRevision;
liveHeadRowRevision?: unknown;
boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode;
+66 -22
View File
@@ -57,8 +57,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { ToolCallGroup } from "@/components/tool-call-group";
import { compactToolCallRuns } from "@/tool-calls/grouping";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
@@ -259,6 +262,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function buildChatHistoryAttachment(input: {
draftId: string;
@@ -544,25 +548,38 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const compactedToolCalls = useMemo(
// Keep retained history outside the 48ms live-head flush path.
const preparedToolCallHistory = useMemo(
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
[effectiveStreamItems, toolCallDetailLevel],
);
const projectedToolCalls = useMemo(
() =>
compactToolCallRuns({
projectToolCallDetailLevel({
level: toolCallDetailLevel,
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
enabled: toolCallDetailLevel !== "detailed",
preparedHistory: preparedToolCallHistory,
isTurnActive: context.status === "running",
}),
[effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel],
[
context.status,
effectiveStreamHead,
effectiveStreamItems,
preparedToolCallHistory,
toolCallDetailLevel,
],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
agentStatus: context.status,
tail: compactedToolCalls.tail,
head: compactedToolCalls.head,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]);
}, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]);
const streamLayout = useMemo(
() =>
layoutStream({
@@ -691,7 +708,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderSingleToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, isLastInSequence: boolean) => {
(
item: Extract<StreamItem, { kind: "tool_call" }>,
isLastInSequence: boolean,
maxDetailHeight?: number,
) => {
const { payload } = item;
if (payload.source === "agent") {
@@ -720,6 +741,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
/>
);
}
@@ -735,6 +757,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
/>
);
},
@@ -743,32 +766,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const group = compactedToolCalls.groupsByHostId.get(item.id);
const group = projectedToolCalls.groupsByHostId.get(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.id);
const expanded = expandedToolCallGroupIds.has(group.run.id);
return (
<ToolCallGroup
<OverviewToolCallGroupView
group={group}
presentation={toolCallDetailLevel === "concise" ? "concise" : "overview"}
expanded={expanded}
isCompact={isMobile}
isLastInSequence={layoutItem.isLastInToolSequence}
onExpandedChange={setToolCallGroupExpanded}
cwd={context.cwd}
onOpenFilePath={handleToolCallOpenFile}
>
{group.calls.map((call, index) => (
<React.Fragment key={call.id}>
{renderSingleToolCallItem(call, index === group.calls.length - 1)}
</React.Fragment>
))}
</ToolCallGroup>
{expanded
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)}
</React.Fragment>
))
: null}
</OverviewToolCallGroupView>
);
},
[
compactedToolCalls.groupsByHostId,
projectedToolCalls.groupsByHostId,
context.cwd,
expandedToolCallGroupIds,
handleToolCallOpenFile,
isMobile,
renderSingleToolCallItem,
setToolCallGroupExpanded,
toolCallDetailLevel,
],
);
@@ -960,6 +994,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const historyRowRevision = useMemo(
() => ({
contentById: projectedToolCalls.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds,
globalDisplayState: isMobile,
}),
[expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId],
);
return (
<ToolCallSheetProvider>
@@ -968,6 +1010,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroupIds,
boundary,
renderers,
listEmptyComponent,