mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
perf(app): keep the stream view's per-delta work bounded by the head
The chat outline concatenated tail and head, reduced over the result in the render body, and rebuilt jumpToPrompt on every delta even when the outline was disabled, which is every native viewport. Memoize the tail's contribution, scan only the head per delta, do nothing while disabled, make jumpToPrompt a stable event so the rail's memo holds, and republish the active prompt only when the index or reading position changes. The view built a Set of every mounted id plus the live head per delta for the outline's pending-jump check; key it on mounted history only and treat live head rows as always mounted. Split plugin projection so a head change does not re-project the tail, memoize the history-window boundary lookup, and read the turn presentation's submission flag with some() instead of allocating a filtered array. Refs #4816 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f315ccd256
commit
b198932e49
@@ -2,6 +2,9 @@ import type { ReactNode, RefObject } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
|
||||
export interface MountedMessageIds {
|
||||
has(messageId: string): boolean;
|
||||
}
|
||||
export interface ChatFindProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
@@ -9,7 +12,7 @@ export interface ChatFindProps {
|
||||
items: StreamItem[];
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
revealLoadedMessage(messageId: string): boolean;
|
||||
visibleMessageIds: ReadonlySet<string>;
|
||||
visibleMessageIds: MountedMessageIds;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface ChatFindExpansionProps {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("useChatOutline", () => {
|
||||
timelineEpoch,
|
||||
enabled,
|
||||
viewportRef,
|
||||
visibleMessageIds: new Set(),
|
||||
mountedHistoryMessageIds: new Set(),
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{
|
||||
@@ -278,7 +278,7 @@ describe("useChatOutline", () => {
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleMessageIds }) =>
|
||||
({ mountedHistoryMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -288,10 +288,10 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{ initialProps: { visibleMessageIds: new Set<string>() } },
|
||||
{ initialProps: { mountedHistoryMessageIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
@@ -299,7 +299,7 @@ describe("useChatOutline", () => {
|
||||
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
|
||||
rerender({ mountedHistoryMessageIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
@@ -327,7 +327,7 @@ describe("useChatOutline", () => {
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleMessageIds }) =>
|
||||
({ tail, mountedHistoryMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -337,13 +337,13 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleMessageIds: new Set<string>(),
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
@@ -392,7 +392,7 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds: new Set([livePrompt.id]),
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
);
|
||||
@@ -401,4 +401,38 @@ describe("useChatOutline", () => {
|
||||
await act(async () => result.current.jumpToPrompt(2));
|
||||
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
|
||||
});
|
||||
|
||||
it("keeps jumpToPrompt stable while assistant chunks replace the live head", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const chunk = (text: string): import("@/types/stream").StreamItem => ({
|
||||
id: "assistant",
|
||||
kind: "assistant_message",
|
||||
text,
|
||||
timestamp: new Date(2),
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ head }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail: [],
|
||||
head,
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { head: [chunk("one")] } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
const jumpToPrompt = result.current.jumpToPrompt;
|
||||
|
||||
rerender({ head: [chunk("one two")] });
|
||||
|
||||
expect(result.current.jumpToPrompt).toBe(jumpToPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -33,7 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleMessageIds?: ReadonlySet<string>;
|
||||
// Message ids of the history the viewport has mounted. Live head messages are always mounted.
|
||||
mountedHistoryMessageIds?: ReadonlySet<string>;
|
||||
revealLoadedMessage?: (messageId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,24 @@ export interface ChatOutline {
|
||||
reportReadingPosition: (seq: number | null) => void;
|
||||
}
|
||||
|
||||
function latestPromptSeqIn(items: StreamItem[], timelineEpoch: string | null): number {
|
||||
let latest = -1;
|
||||
for (const item of items) {
|
||||
if (item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch) {
|
||||
latest = Math.max(latest, item.timelineCursor.seq);
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function findLoadedItem(
|
||||
tail: StreamItem[],
|
||||
head: StreamItem[],
|
||||
predicate: (item: StreamItem) => boolean,
|
||||
): StreamItem | undefined {
|
||||
return tail.find(predicate) ?? head.find(predicate);
|
||||
}
|
||||
|
||||
export function useChatOutline({
|
||||
agentId,
|
||||
serverId,
|
||||
@@ -53,7 +72,7 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
@@ -62,18 +81,19 @@ export function useChatOutline({
|
||||
const readingSeqRef = useRef<number | null>(null);
|
||||
const nextJumpRequestIdRef = useRef(0);
|
||||
const nextIndexRequestIdRef = useRef(0);
|
||||
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
|
||||
// A disabled outline (every native viewport) must not pay for the timeline per delta.
|
||||
const loadedTail = enabled ? tail : NO_STREAM_ITEMS;
|
||||
const loadedHead = enabled ? (head ?? NO_STREAM_ITEMS) : NO_STREAM_ITEMS;
|
||||
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
|
||||
|
||||
// The viewed timeline already owns live delivery and reconnect catch-up. Its complete
|
||||
// loaded items (including rows outside the mounted window) invalidate the prompt index.
|
||||
const latestPromptSeq = loadedItems.reduce(
|
||||
(latest, item) =>
|
||||
item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch
|
||||
? Math.max(latest, item.timelineCursor.seq)
|
||||
: latest,
|
||||
-1,
|
||||
// Only the head changes per streamed delta, so the tail's contribution is memoized.
|
||||
const tailPromptSeq = useMemo(
|
||||
() => latestPromptSeqIn(loadedTail, timelineEpoch),
|
||||
[loadedTail, timelineEpoch],
|
||||
);
|
||||
const latestPromptSeq = Math.max(tailPromptSeq, latestPromptSeqIn(loadedHead, timelineEpoch));
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
@@ -135,10 +155,18 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
const target = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === pendingJump.seq,
|
||||
);
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleMessageIds?.has(target.id) === false) {
|
||||
const isMounted =
|
||||
mountedHistoryMessageIds === undefined ||
|
||||
mountedHistoryMessageIds.has(target.id) ||
|
||||
loadedHead.includes(target);
|
||||
if (!isMounted) {
|
||||
revealLoadedMessage?.(target.id);
|
||||
return;
|
||||
}
|
||||
@@ -150,40 +178,48 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
|
||||
}, [
|
||||
loadedHead,
|
||||
loadedTail,
|
||||
mountedHistoryMessageIds,
|
||||
pendingJump,
|
||||
revealLoadedMessage,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
const jumpToPrompt = useStableEvent((seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === seq,
|
||||
);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
});
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
}
|
||||
|
||||
@@ -70,10 +70,11 @@ export function useStreamHistoryWindow(input: {
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryIndex =
|
||||
window.agentId === agentId && window.boundaryItemId !== null
|
||||
? items.findIndex((item) => item.id === window.boundaryItemId)
|
||||
: -1;
|
||||
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
|
||||
const boundaryIndex = useMemo(
|
||||
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
|
||||
[boundaryItemId, items],
|
||||
);
|
||||
let start = initialStart;
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
|
||||
@@ -607,16 +607,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toast?.error(t("agentStream.historyLoadFailed"));
|
||||
}, [t, toast]);
|
||||
// Chat find and the chat outline address messages, and an assistant message is a
|
||||
// group of block rows, so this is a set of message ids and never of row ids.
|
||||
const visibleMessageIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
|
||||
getStreamItemMessageId,
|
||||
),
|
||||
),
|
||||
[baseRenderModel.history, baseRenderModel.segments.liveHead],
|
||||
// group of block rows, so these hold message ids and never row ids. Only the head
|
||||
// changes per streamed delta, so the history set is keyed on mounted history alone.
|
||||
const mountedHistoryMessageIds = useMemo(
|
||||
() => new Set(baseRenderModel.history.map(getStreamItemMessageId)),
|
||||
[baseRenderModel.history],
|
||||
);
|
||||
const mountedMessageIds = useMemo(() => {
|
||||
const liveHeadMessageIds = new Set(
|
||||
baseRenderModel.segments.liveHead.map(getStreamItemMessageId),
|
||||
);
|
||||
return {
|
||||
has: (messageId: string) =>
|
||||
mountedHistoryMessageIds.has(messageId) || liveHeadMessageIds.has(messageId),
|
||||
};
|
||||
}, [baseRenderModel.segments.liveHead, mountedHistoryMessageIds]);
|
||||
const chatOutline = useChatOutline({
|
||||
agentId,
|
||||
serverId: resolvedServerId,
|
||||
@@ -626,7 +631,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
enabled: supportsChatOutline && chatOutlineEnabled,
|
||||
viewportRef,
|
||||
onJumpError: handleTimelineHistoryLoadError,
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage: revealLoadedHistory,
|
||||
});
|
||||
|
||||
@@ -1105,7 +1110,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={visibleMessageIds}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
|
||||
@@ -17,6 +17,14 @@ export function getActiveMessageSubmissions(
|
||||
);
|
||||
}
|
||||
|
||||
export function hasActiveMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): boolean {
|
||||
return (submissions ?? EMPTY_MESSAGE_SUBMISSIONS).some(
|
||||
(submission) => !submission.providerAcknowledged,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSendingClientMessageIds(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): string[] {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
hasActiveMessageSubmission,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
@@ -348,7 +348,7 @@ export function selectAgentTurnPresentation(
|
||||
session?.agents.get(agentId)?.turn ??
|
||||
session?.agentDetails.get(agentId)?.turn ??
|
||||
TURN_LIVENESS_IDLE,
|
||||
getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0,
|
||||
hasActiveMessageSubmission(session?.messageSubmissions.get(agentId)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user