Files
paseo/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
T
Mohamed BoudraandGitHub 553ac53643 feat(app): add chat outline navigation (#2792)
* feat(app): add chat outline navigation

Long agent sessions need prompt navigation without coupling viewport movement to disposable server windows. Keep fetched timeline pages in client state so repeated jumps stay local, and settle prompt jumps against virtualized row measurement changes.

* fix(app): preserve chat outline timeline coverage

* fix(app): reconcile outline window submissions

* fix(app): harden outline timeline navigation
2026-08-02 20:48:23 +08:00

179 lines
5.9 KiB
TypeScript

import { useCallback, 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";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
import type { StreamItem } from "@/types/stream";
import type { StreamViewportHandle } from "../strategy";
import {
createActivePromptPublisher,
resolveActivePromptSeq,
shouldAcceptPromptIndexEpoch,
type ActivePromptSource,
type ChatOutlinePrompt,
} from "./model";
const NO_PROMPTS: ChatOutlinePrompt[] = [];
const NO_STREAM_ITEMS: StreamItem[] = [];
interface PendingPromptJump {
requestId: number;
seq: number;
fetchSettled: boolean;
hasScrolled: boolean;
}
export interface UseChatOutlineInput {
agentId: string;
serverId: string;
timelineEpoch: string | null;
tail: StreamItem[];
head: StreamItem[] | undefined;
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
}
export interface ChatOutline {
prompts: ChatOutlinePrompt[];
activePrompt: ActivePromptSource;
jumpToPrompt: (seq: number) => void;
reportReadingPosition: (rowId: string | null) => void;
}
export function useChatOutline({
agentId,
serverId,
timelineEpoch,
tail,
head,
enabled,
viewportRef,
onJumpError,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
const [activePrompt] = useState(createActivePromptPublisher);
const readingRowIdRef = useRef<string | null>(null);
const nextJumpRequestIdRef = useRef(0);
const nextIndexRequestIdRef = useRef(0);
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
useEffect(() => {
if (!isWeb || !enabled) {
setIndex(null);
return;
}
setIndex(null);
const client = getHostRuntimeStore().getClient(serverId);
if (!client) return;
let active = true;
const refresh = () => {
const requestId = ++nextIndexRequestIdRef.current;
void client
.listAgentTimelinePrompts(agentId)
.then((payload) => {
if (
active &&
requestId === nextIndexRequestIdRef.current &&
shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch)
) {
setIndex(payload);
}
return undefined;
})
.catch(() => undefined);
};
refresh();
const unsubscribe = client.on("agent_stream", (message) => {
if (
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "user_message"
) {
refresh();
}
});
return () => {
active = false;
unsubscribe();
};
}, [agentId, enabled, serverId, timelineEpoch]);
// The transcript names the row it is showing; the outline turns that into a prompt using the
// complete index, so unloaded rows never have to exist in the DOM to be marked.
const publishActivePrompt = useStableEvent(() => {
const rowId = readingRowIdRef.current;
const anchorSeq =
rowId === null
? null
: (loadedItems.find((item) => item.id === rowId)?.timelineCursor?.seq ?? null);
activePrompt.publish(resolveActivePromptSeq(prompts, anchorSeq));
});
const reportReadingPosition = useStableEvent((rowId: string | null) => {
readingRowIdRef.current = rowId;
publishActivePrompt();
});
useEffect(() => {
nextJumpRequestIdRef.current += 1;
setPendingJump(null);
readingRowIdRef.current = null;
activePrompt.publish(null);
}, [activePrompt, agentId, timelineEpoch]);
// The transcript reports its reading position long before the index arrives, and a reader
// who never scrolls would otherwise sit on an unmarked rail.
useEffect(() => {
publishActivePrompt();
}, [loadedItems, prompts, publishActivePrompt]);
useEffect(() => {
if (pendingJump === null) return;
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
if (target && !pendingJump.hasScrolled) {
viewportRef.current?.scrollToMessage?.(target.id);
setPendingJump((current) => {
if (current?.requestId !== pendingJump.requestId) return current;
return { ...current, hasScrolled: true };
});
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, viewportRef]);
const jumpToPrompt = useCallback(
(seq: number) => {
nextJumpRequestIdRef.current += 1;
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
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, serverId, viewportRef],
);
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
}