Files
paseo/packages/app/src/agent-stream/prompt-jump-settle.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

112 lines
3.2 KiB
TypeScript

interface PromptJumpScrollMetrics {
clientHeight: number;
scrollHeight: number;
scrollTop: number;
}
export interface PromptJumpSettleViewport {
findTargetTop(itemId: string): number | null;
getContainerTop(): number;
getScrollMetrics(): PromptJumpScrollMetrics;
setScrollTop(scrollTop: number): void;
subscribeToUserIntent(listener: () => void): () => void;
}
export interface PromptJumpSettleController {
start(itemId: string): void;
cancel(): void;
isActive(): boolean;
}
const POSITION_TOLERANCE_PX = 1;
const REQUIRED_STABLE_FRAMES = 2;
const FRAME_BUDGET = 24;
export const PROMPT_JUMP_TOP_INSET_PX = 8;
function distanceFromLandingPosition(targetTop: number, containerTop: number): number {
return targetTop - containerTop - PROMPT_JUMP_TOP_INSET_PX;
}
export function createPromptJumpSettleController(input: {
viewport: PromptJumpSettleViewport;
requestFrame: (callback: () => void) => number;
cancelFrame: (frameId: number) => void;
}): PromptJumpSettleController {
let activeItemId: string | null = null;
let frameId: number | null = null;
let sampledFrames = 0;
let stableFrames = 0;
let unsubscribeFromUserIntent: (() => void) | null = null;
function finish(): void {
if (frameId !== null) input.cancelFrame(frameId);
unsubscribeFromUserIntent?.();
activeItemId = null;
frameId = null;
sampledFrames = 0;
stableFrames = 0;
unsubscribeFromUserIntent = null;
}
function scheduleNextFrame(): void {
frameId = input.requestFrame(tick);
}
function tick(): void {
frameId = null;
const itemId = activeItemId;
if (itemId === null) return;
sampledFrames += 1;
const targetTop = input.viewport.findTargetTop(itemId);
if (targetTop !== null) {
const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop());
if (Math.abs(delta) <= POSITION_TOLERANCE_PX) {
stableFrames += 1;
if (stableFrames >= REQUIRED_STABLE_FRAMES) {
finish();
return;
}
} else {
stableFrames = 0;
const before = input.viewport.getScrollMetrics();
input.viewport.setScrollTop(before.scrollTop + delta);
const after = input.viewport.getScrollMetrics();
const adjustedTargetTop = input.viewport.findTargetTop(itemId);
let remainingDelta = 0;
if (adjustedTargetTop !== null) {
remainingDelta = distanceFromLandingPosition(
adjustedTargetTop,
input.viewport.getContainerTop(),
);
}
const maxScrollTop = Math.max(0, after.scrollHeight - after.clientHeight);
const isClampedAtBottom = after.scrollTop >= maxScrollTop - POSITION_TOLERANCE_PX;
if (remainingDelta > POSITION_TOLERANCE_PX && isClampedAtBottom) {
finish();
return;
}
}
}
if (sampledFrames >= FRAME_BUDGET) {
finish();
return;
}
scheduleNextFrame();
}
return {
start(itemId) {
finish();
activeItemId = itemId;
unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish);
scheduleNextFrame();
},
cancel: finish,
isActive() {
return activeItemId !== null;
},
};
}