diff --git a/packages/app/src/agent-stream/chat-find/types.ts b/packages/app/src/agent-stream/chat-find/types.ts index cce58be96e..4fdf543a5e 100644 --- a/packages/app/src/agent-stream/chat-find/types.ts +++ b/packages/app/src/agent-stream/chat-find/types.ts @@ -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; revealLoadedMessage(messageId: string): boolean; - visibleMessageIds: ReadonlySet; + visibleMessageIds: MountedMessageIds; children: ReactNode; } export interface ChatFindExpansionProps { diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 4b2139fef6..3c3bde3836 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -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() } }, + { initialProps: { mountedHistoryMessageIds: new Set() } }, ); 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(), + mountedHistoryMessageIds: new Set(), }, }, ); @@ -352,11 +352,11 @@ describe("useChatOutline", () => { act(() => result.current.jumpToPrompt(1)); await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce()); - rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set() }); + rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set() }); 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(), 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(); + 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); + }); }); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index 015b883d21..cfdb26b2a5 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -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; onJumpError: () => void; - visibleMessageIds?: ReadonlySet; + // Message ids of the history the viewport has mounted. Live head messages are always mounted. + mountedHistoryMessageIds?: ReadonlySet; 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(null); @@ -62,18 +81,19 @@ export function useChatOutline({ const readingSeqRef = useRef(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 }; } diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 42168f052d..9874158dcc 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -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) { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 1e60240be9..6526e7352b 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -607,16 +607,21 @@ const AgentStreamViewComponent = forwardRef - 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 diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts index 26febae04f..67301bd5ff 100644 --- a/packages/app/src/composer/submission/model.ts +++ b/packages/app/src/composer/submission/model.ts @@ -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[] { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index a741f99ddb..9b70d90486 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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)), ); }