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
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createChatOutlineHoverIntent } from "./hover-intent";
|
||||
|
||||
function createFakeScheduler() {
|
||||
const callbacks = new Map<number, () => void>();
|
||||
const delays: number[] = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
schedule(callback: () => void, delayMs: number) {
|
||||
const id = nextId++;
|
||||
callbacks.set(id, callback);
|
||||
delays.push(delayMs);
|
||||
return id;
|
||||
},
|
||||
cancel(id: number) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
runPending() {
|
||||
for (const [id, callback] of callbacks) {
|
||||
callbacks.delete(id);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
scheduleCount() {
|
||||
return delays.length;
|
||||
},
|
||||
delays() {
|
||||
return delays;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("chat outline hover intent", () => {
|
||||
it("counts movement across the rail toward one initial activation", () => {
|
||||
const scheduler = createFakeScheduler();
|
||||
const activations: Array<number | null> = [];
|
||||
const intent = createChatOutlineHoverIntent({
|
||||
activate: (index) => activations.push(index),
|
||||
schedule: scheduler.schedule,
|
||||
cancel: scheduler.cancel,
|
||||
});
|
||||
|
||||
intent.enter({ x: 20, y: 10 });
|
||||
intent.pointAt(1);
|
||||
intent.move({ x: 20, y: 30 });
|
||||
intent.pointAt(2);
|
||||
intent.move({ x: 20, y: 50 });
|
||||
intent.pointAt(3);
|
||||
scheduler.runPending();
|
||||
|
||||
expect({
|
||||
activations,
|
||||
scheduleCount: scheduler.scheduleCount(),
|
||||
delays: scheduler.delays(),
|
||||
}).toEqual({
|
||||
activations: [3],
|
||||
scheduleCount: 1,
|
||||
delays: [150],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not activate while the pointer is crossing horizontally through the rail", () => {
|
||||
const scheduler = createFakeScheduler();
|
||||
const activations: Array<number | null> = [];
|
||||
const intent = createChatOutlineHoverIntent({
|
||||
activate: (index) => activations.push(index),
|
||||
schedule: scheduler.schedule,
|
||||
cancel: scheduler.cancel,
|
||||
});
|
||||
|
||||
intent.enter({ x: 36, y: 20 });
|
||||
intent.pointAt(1);
|
||||
intent.move({ x: 30, y: 20 });
|
||||
intent.move({ x: 24, y: 20 });
|
||||
intent.move({ x: 18, y: 20 });
|
||||
intent.leave();
|
||||
intent.enter({ x: 0, y: 20 });
|
||||
intent.pointAt(2);
|
||||
intent.move({ x: 6, y: 20 });
|
||||
intent.move({ x: 12, y: 20 });
|
||||
intent.leave();
|
||||
scheduler.runPending();
|
||||
|
||||
expect({ activations, scheduleCount: scheduler.scheduleCount() }).toEqual({
|
||||
activations: [null, null],
|
||||
scheduleCount: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves immediately between ticks after the rail activates", () => {
|
||||
const scheduler = createFakeScheduler();
|
||||
const activations: Array<number | null> = [];
|
||||
const intent = createChatOutlineHoverIntent({
|
||||
activate: (index) => activations.push(index),
|
||||
schedule: scheduler.schedule,
|
||||
cancel: scheduler.cancel,
|
||||
});
|
||||
|
||||
intent.pointAt(1);
|
||||
scheduler.runPending();
|
||||
intent.pointAt(5);
|
||||
|
||||
expect(activations).toEqual([1, 5]);
|
||||
});
|
||||
|
||||
it("resets activation when the pointer leaves the rail", () => {
|
||||
const scheduler = createFakeScheduler();
|
||||
const activations: Array<number | null> = [];
|
||||
const intent = createChatOutlineHoverIntent({
|
||||
activate: (index) => activations.push(index),
|
||||
schedule: scheduler.schedule,
|
||||
cancel: scheduler.cancel,
|
||||
});
|
||||
|
||||
intent.pointAt(1);
|
||||
scheduler.runPending();
|
||||
intent.leave();
|
||||
intent.pointAt(2);
|
||||
|
||||
expect({ activations, scheduleCount: scheduler.scheduleCount() }).toEqual({
|
||||
activations: [1, null],
|
||||
scheduleCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface ChatOutlineHoverIntent {
|
||||
enter(point: PointerPoint): void;
|
||||
pointAt(index: number): void;
|
||||
move(point: PointerPoint): void;
|
||||
leave(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface PointerPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const INITIAL_ACTIVATION_DELAY_MS = 150;
|
||||
const HORIZONTAL_TRANSIT_DISTANCE_PX = 4;
|
||||
|
||||
export function createChatOutlineHoverIntent(input: {
|
||||
activate: (index: number | null) => void;
|
||||
schedule: (callback: () => void, delayMs: number) => number;
|
||||
cancel: (timerId: number) => void;
|
||||
}): ChatOutlineHoverIntent {
|
||||
let timerId: number | null = null;
|
||||
let candidateIndex: number | null = null;
|
||||
let motionAnchor: PointerPoint | null = null;
|
||||
let active = false;
|
||||
|
||||
function cancelTimer(): void {
|
||||
if (timerId === null) return;
|
||||
input.cancel(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
|
||||
function scheduleActivation(): void {
|
||||
if (candidateIndex === null) return;
|
||||
cancelTimer();
|
||||
timerId = input.schedule(() => {
|
||||
timerId = null;
|
||||
active = true;
|
||||
if (candidateIndex !== null) input.activate(candidateIndex);
|
||||
}, INITIAL_ACTIVATION_DELAY_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
enter(point) {
|
||||
motionAnchor = point;
|
||||
},
|
||||
pointAt(index) {
|
||||
candidateIndex = index;
|
||||
if (active) {
|
||||
input.activate(index);
|
||||
return;
|
||||
}
|
||||
if (timerId !== null) return;
|
||||
scheduleActivation();
|
||||
},
|
||||
move(point) {
|
||||
if (active || motionAnchor === null) return;
|
||||
const deltaX = point.x - motionAnchor.x;
|
||||
const deltaY = point.y - motionAnchor.y;
|
||||
if (Math.abs(deltaY) >= Math.abs(deltaX)) {
|
||||
motionAnchor = point;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(deltaX) < HORIZONTAL_TRANSIT_DISTANCE_PX) return;
|
||||
motionAnchor = point;
|
||||
scheduleActivation();
|
||||
},
|
||||
leave() {
|
||||
cancelTimer();
|
||||
candidateIndex = null;
|
||||
motionAnchor = null;
|
||||
active = false;
|
||||
input.activate(null);
|
||||
},
|
||||
dispose() {
|
||||
cancelTimer();
|
||||
candidateIndex = null;
|
||||
motionAnchor = null;
|
||||
active = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createActivePromptPublisher,
|
||||
shouldAcceptPromptIndexEpoch,
|
||||
promptTickMagnification,
|
||||
resolveActivePromptSeq,
|
||||
OUTLINE_MAGNIFY_RADIUS,
|
||||
type ChatOutlinePrompt,
|
||||
} from "./model";
|
||||
|
||||
describe("chat outline prompt index epoch", () => {
|
||||
it("accepts only the authoritative timeline epoch", () => {
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
|
||||
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
|
||||
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function prompt(seq: number): ChatOutlinePrompt {
|
||||
return { seq, timestamp: new Date(seq).toISOString(), preview: `prompt ${seq}` };
|
||||
}
|
||||
|
||||
describe("promptTickMagnification", () => {
|
||||
it("peaks under the pointer and decays to nothing at the radius", () => {
|
||||
expect(promptTickMagnification(0)).toBe(1);
|
||||
expect(promptTickMagnification(OUTLINE_MAGNIFY_RADIUS)).toBe(0);
|
||||
expect(promptTickMagnification(OUTLINE_MAGNIFY_RADIUS + 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("falls off monotonically and symmetrically around the pointer", () => {
|
||||
const above = [0, 1, 2, 3].map((distance) => promptTickMagnification(distance));
|
||||
const below = [0, -1, -2, -3].map((distance) => promptTickMagnification(distance));
|
||||
|
||||
expect(above).toEqual(below);
|
||||
expect(above).toEqual([...above].sort((left, right) => right - left));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActivePromptSeq", () => {
|
||||
const prompts = [prompt(2), prompt(9), prompt(20)];
|
||||
|
||||
it("marks the prompt whose turn the reading position sits in", () => {
|
||||
expect(resolveActivePromptSeq(prompts, 9)).toBe(9);
|
||||
expect(resolveActivePromptSeq(prompts, 14)).toBe(9);
|
||||
expect(resolveActivePromptSeq(prompts, 20)).toBe(20);
|
||||
expect(resolveActivePromptSeq(prompts, 99)).toBe(20);
|
||||
});
|
||||
|
||||
it("marks nothing above the first prompt or without a reading position", () => {
|
||||
expect(resolveActivePromptSeq(prompts, 1)).toBeNull();
|
||||
expect(resolveActivePromptSeq(prompts, null)).toBeNull();
|
||||
expect(resolveActivePromptSeq([], 42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActivePromptPublisher", () => {
|
||||
it("notifies subscribers only when the active prompt changes", () => {
|
||||
const publisher = createActivePromptPublisher();
|
||||
let notifications = 0;
|
||||
const unsubscribe = publisher.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
publisher.publish(9);
|
||||
publisher.publish(9);
|
||||
publisher.publish(20);
|
||||
unsubscribe();
|
||||
publisher.publish(null);
|
||||
|
||||
expect(notifications).toBe(2);
|
||||
expect(publisher.getActiveSeq()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
|
||||
|
||||
export function shouldAcceptPromptIndexEpoch(
|
||||
timelineEpoch: string | null,
|
||||
indexEpoch: string,
|
||||
): boolean {
|
||||
return timelineEpoch === null || timelineEpoch === indexEpoch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots further than this from the pointer keep their resting size, so a long rail
|
||||
* magnifies a local band instead of swelling the whole column.
|
||||
*/
|
||||
export const OUTLINE_MAGNIFY_RADIUS = 3;
|
||||
|
||||
/**
|
||||
* Dock-style falloff: 1 under the pointer, easing to 0 at the radius. The raised cosine
|
||||
* has no corner at either end, so sweeping the rail reads as one bulge travelling with
|
||||
* the pointer rather than a band switching on and off.
|
||||
*/
|
||||
export function promptTickMagnification(slotDistance: number): number {
|
||||
const distance = Math.abs(slotDistance);
|
||||
if (!Number.isFinite(distance) || distance >= OUTLINE_MAGNIFY_RADIUS) {
|
||||
return 0;
|
||||
}
|
||||
return (1 + Math.cos((Math.PI * distance) / OUTLINE_MAGNIFY_RADIUS)) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt whose turn the reader is inside: the last indexed prompt at or before the
|
||||
* timeline position under the top of the viewport. It reads the complete daemon index,
|
||||
* so a prompt outside the loaded window still lights up while its turn is on screen.
|
||||
*/
|
||||
export function resolveActivePromptSeq(
|
||||
prompts: readonly ChatOutlinePrompt[],
|
||||
anchorSeq: number | null,
|
||||
): number | null {
|
||||
if (anchorSeq === null) {
|
||||
return null;
|
||||
}
|
||||
let activeSeq: number | null = null;
|
||||
for (const prompt of prompts) {
|
||||
if (prompt.seq > anchorSeq) {
|
||||
break;
|
||||
}
|
||||
activeSeq = prompt.seq;
|
||||
}
|
||||
return activeSeq;
|
||||
}
|
||||
|
||||
export interface ActivePromptSource {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getActiveSeq: () => number | null;
|
||||
}
|
||||
|
||||
export interface ActivePromptPublisher extends ActivePromptSource {
|
||||
publish: (seq: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript reports its reading position on every scroll frame, far more often than
|
||||
* it re-renders. Keeping the active prompt outside React lets the rail subscribe to it
|
||||
* without dragging the transcript through a render on each frame.
|
||||
*/
|
||||
export function createActivePromptPublisher(): ActivePromptPublisher {
|
||||
const listeners = new Set<() => void>();
|
||||
let activeSeq: number | null = null;
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getActiveSeq: () => activeSeq,
|
||||
publish(seq) {
|
||||
if (seq === activeSeq) {
|
||||
return;
|
||||
}
|
||||
activeSeq = seq;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ActivePromptSource, ChatOutlinePrompt } from "./model";
|
||||
|
||||
export interface ChatOutlineRailProps {
|
||||
prompts: ChatOutlinePrompt[];
|
||||
activePrompt: ActivePromptSource;
|
||||
onJumpToPrompt: (seq: number) => void;
|
||||
}
|
||||
|
||||
// The outline is a wide-layout pointer affordance. Native navigates the transcript by
|
||||
// scrolling, so there is no rail to render.
|
||||
export function ChatOutlineRail(_props: ChatOutlineRailProps): null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useReducedMotion } from "react-native-reanimated";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { createChatOutlineHoverIntent } from "./hover-intent";
|
||||
import { promptTickMagnification } from "./model";
|
||||
import type { ChatOutlineRailProps } from "./rail";
|
||||
|
||||
// Hover tracking lives on the rail and the slots, never on the Pressable inside them:
|
||||
// magnifying a slot must not move the box the pointer is resting on. See docs/hover.md.
|
||||
const RAIL_WIDTH = 36;
|
||||
const SLOT_HEIGHT = 8;
|
||||
const MIN_PANEL_WIDTH = 818;
|
||||
const RESTING_PILL_HEIGHT = 2;
|
||||
const MAGNIFIED_PILL_HEIGHT = 4;
|
||||
const RESTING_PILL_WIDTH = 10;
|
||||
const ACTIVE_PILL_WIDTH = 18;
|
||||
const MAGNIFIED_PILL_WIDTH = 26;
|
||||
const PREVIEW_WIDTH = 260;
|
||||
const PREVIEW_HEIGHT = 48;
|
||||
const PREVIEW_GAP = 4;
|
||||
|
||||
export const ChatOutlineRail = memo(function ChatOutlineRail({
|
||||
prompts,
|
||||
activePrompt,
|
||||
onJumpToPrompt,
|
||||
}: ChatOutlineRailProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const activeSeq = useSyncExternalStore(activePrompt.subscribe, activePrompt.getActiveSeq);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const { onLayout, isBelow: isPanelNarrow } = useContainerWidthBelow(MIN_PANEL_WIDTH);
|
||||
|
||||
const hoverIntent = useMemo(
|
||||
() =>
|
||||
createChatOutlineHoverIntent({
|
||||
activate: setHoveredIndex,
|
||||
schedule: (callback, delayMs) => window.setTimeout(callback, delayMs),
|
||||
cancel: (timerId) => window.clearTimeout(timerId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const handlePointerEnterTick = useCallback(
|
||||
(index: number) => hoverIntent.pointAt(index),
|
||||
[hoverIntent],
|
||||
);
|
||||
const handlePointerEnterRail = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
hoverIntent.enter({ x: event.nativeEvent.clientX, y: event.nativeEvent.clientY });
|
||||
},
|
||||
[hoverIntent],
|
||||
);
|
||||
const handlePointerMoveRail = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
hoverIntent.move({ x: event.nativeEvent.clientX, y: event.nativeEvent.clientY });
|
||||
},
|
||||
[hoverIntent],
|
||||
);
|
||||
const handlePointerLeaveRail = useCallback(() => hoverIntent.leave(), [hoverIntent]);
|
||||
useEffect(() => () => hoverIntent.dispose(), [hoverIntent]);
|
||||
useEffect(() => {
|
||||
if (isPanelNarrow) hoverIntent.leave();
|
||||
}, [hoverIntent, isPanelNarrow]);
|
||||
const handleFocusChange = useCallback((index: number, focused: boolean) => {
|
||||
setFocusedIndex((current) => {
|
||||
if (focused) return index;
|
||||
return current === index ? null : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The pointer owns the magnified band while it is on the rail; keyboard focus drives the
|
||||
// same band and the same preview once the pointer leaves.
|
||||
const attentionIndex = hoveredIndex ?? focusedIndex;
|
||||
|
||||
if (prompts.length < 2) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.panelMeasure} pointerEvents="box-none" onLayout={onLayout}>
|
||||
{isPanelNarrow ? null : (
|
||||
<View
|
||||
style={styles.rail}
|
||||
role="tablist"
|
||||
testID="chat-outline-rail"
|
||||
onPointerEnter={handlePointerEnterRail}
|
||||
onPointerMove={handlePointerMoveRail}
|
||||
onPointerLeave={handlePointerLeaveRail}
|
||||
>
|
||||
{prompts.map((prompt, index) => (
|
||||
<ChatOutlineTick
|
||||
key={prompt.seq}
|
||||
index={index}
|
||||
seq={prompt.seq}
|
||||
preview={prompt.preview}
|
||||
label={`${index + 1} of ${prompts.length}: ${prompt.preview}`}
|
||||
isActive={prompt.seq === activeSeq}
|
||||
hasAttention={index === attentionIndex}
|
||||
magnification={
|
||||
prefersReducedMotion || attentionIndex === null
|
||||
? 0
|
||||
: promptTickMagnification(index - attentionIndex)
|
||||
}
|
||||
onHover={handlePointerEnterTick}
|
||||
onFocusChange={handleFocusChange}
|
||||
onJumpToPrompt={onJumpToPrompt}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface ChatOutlineTickProps {
|
||||
index: number;
|
||||
seq: number;
|
||||
preview: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
hasAttention: boolean;
|
||||
magnification: number;
|
||||
onHover: (index: number) => void;
|
||||
onFocusChange: (index: number, focused: boolean) => void;
|
||||
onJumpToPrompt: (seq: number) => void;
|
||||
}
|
||||
|
||||
const ChatOutlineTick = memo(function ChatOutlineTick({
|
||||
index,
|
||||
seq,
|
||||
preview,
|
||||
label,
|
||||
isActive,
|
||||
hasAttention,
|
||||
magnification,
|
||||
onHover,
|
||||
onFocusChange,
|
||||
onJumpToPrompt,
|
||||
}: ChatOutlineTickProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onJumpToPrompt(seq);
|
||||
onFocusChange(index, false);
|
||||
}, [index, onFocusChange, onJumpToPrompt, seq]);
|
||||
const handlePointerEnter = useCallback(() => onHover(index), [index, onHover]);
|
||||
const handleFocus = useCallback(() => onFocusChange(index, true), [index, onFocusChange]);
|
||||
const handleBlur = useCallback(() => onFocusChange(index, false), [index, onFocusChange]);
|
||||
|
||||
// The pill grows inside a slot that never moves, so magnification can never pull the hit
|
||||
// target out from under the pointer.
|
||||
const restingWidth = isActive ? ACTIVE_PILL_WIDTH : RESTING_PILL_WIDTH;
|
||||
const pillWidth = restingWidth + magnification * (MAGNIFIED_PILL_WIDTH - restingWidth);
|
||||
const pillHeight =
|
||||
RESTING_PILL_HEIGHT + magnification * (MAGNIFIED_PILL_HEIGHT - RESTING_PILL_HEIGHT);
|
||||
|
||||
return (
|
||||
<View style={styles.slot} onPointerEnter={handlePointerEnter}>
|
||||
<Pressable
|
||||
style={styles.target}
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
accessibilityRole="tab"
|
||||
aria-selected={isActive}
|
||||
accessibilityLabel={label}
|
||||
testID={`chat-outline-tick-${seq}`}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.pill,
|
||||
isActive && styles.pillActive,
|
||||
hasAttention && styles.pillAttention,
|
||||
inlineUnistylesStyle({ width: pillWidth, height: pillHeight }),
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
{hasAttention ? (
|
||||
<View style={styles.preview} pointerEvents="none" aria-hidden testID="chat-outline-preview">
|
||||
<Text style={styles.previewText} numberOfLines={2}>
|
||||
{preview}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
panelMeasure: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
},
|
||||
rail: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
top: "10%",
|
||||
bottom: "10%",
|
||||
width: RAIL_WIDTH,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
},
|
||||
// Slots tile the rail with no gaps, so every pixel of the column belongs to a prompt even
|
||||
// when a long conversation squeezes them well below their resting height.
|
||||
slot: {
|
||||
width: RAIL_WIDTH,
|
||||
flexBasis: SLOT_HEIGHT,
|
||||
flexShrink: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// Pills share one left rail and grow rightward, so magnification reads as a bulge moving
|
||||
// with the pointer instead of every tick breathing about its own centre.
|
||||
target: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
paddingLeft: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
// At rest the pills use low-emphasis chrome so the column stays readable peripherally
|
||||
// without competing with the transcript. Attention is the only foreground state.
|
||||
pill: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
transitionProperty: "width, height, background-color",
|
||||
transitionDuration: "140ms",
|
||||
transitionTimingFunction: "ease-out",
|
||||
},
|
||||
pillActive: {
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
pillAttention: {
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
preview: {
|
||||
position: "absolute",
|
||||
left: RAIL_WIDTH + PREVIEW_GAP,
|
||||
top: "50%",
|
||||
marginTop: -PREVIEW_HEIGHT / 2,
|
||||
width: PREVIEW_WIDTH,
|
||||
height: PREVIEW_HEIGHT,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
previewText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
import { useChatOutline } from "./use-chat-outline";
|
||||
|
||||
interface RefreshMessage {
|
||||
type: "agent_stream";
|
||||
payload: {
|
||||
agentId: string;
|
||||
event: { type: "timeline"; item: { type: "user_message" } };
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
listAgentTimelinePrompts: vi.fn(),
|
||||
fetchAgentTimeline: vi.fn(),
|
||||
on: vi.fn<(event: string, listener: (message: RefreshMessage) => void) => () => void>(
|
||||
() => () => undefined,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({ isWeb: true }));
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
getClient: () => runtime,
|
||||
fetchAgentTimeline: runtime.fetchAgentTimeline,
|
||||
}),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("useChatOutline", () => {
|
||||
beforeEach(() => {
|
||||
runtime.listAgentTimelinePrompts.mockReset();
|
||||
runtime.fetchAgentTimeline.mockReset();
|
||||
runtime.on.mockClear();
|
||||
});
|
||||
|
||||
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
|
||||
const first = deferred<{ epoch: string; prompts: [] }>();
|
||||
const second = deferred<{
|
||||
epoch: string;
|
||||
prompts: Array<{ seq: number; timestamp: string; preview: string }>;
|
||||
}>();
|
||||
runtime.listAgentTimelinePrompts
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { result, rerender } = renderHook(
|
||||
({ timelineEpoch }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch,
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { timelineEpoch: "epoch-1" } },
|
||||
);
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ timelineEpoch: "epoch-2" });
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2));
|
||||
await act(async () => first.resolve({ epoch: "epoch-1", prompts: [] }));
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
|
||||
await act(async () =>
|
||||
second.resolve({
|
||||
epoch: "epoch-2",
|
||||
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "current prompt" }],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.prompts).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.prompts[0]?.seq).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest prompt index response within one epoch", async () => {
|
||||
const older = deferred<{
|
||||
epoch: string;
|
||||
prompts: Array<{ seq: number; timestamp: string; preview: string }>;
|
||||
}>();
|
||||
const newer = deferred<{
|
||||
epoch: string;
|
||||
prompts: Array<{ seq: number; timestamp: string; preview: string }>;
|
||||
}>();
|
||||
runtime.listAgentTimelinePrompts
|
||||
.mockResolvedValueOnce({ epoch: "epoch-1", prompts: [] })
|
||||
.mockReturnValueOnce(older.promise)
|
||||
.mockReturnValueOnce(newer.promise);
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
|
||||
const refresh = runtime.on.mock.calls[0]?.[1];
|
||||
await act(async () => {
|
||||
refresh?.({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
event: { type: "timeline", item: { type: "user_message" } },
|
||||
},
|
||||
});
|
||||
refresh?.({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
event: { type: "timeline", item: { type: "user_message" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(3));
|
||||
await act(async () =>
|
||||
newer.resolve({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 3, timestamp: new Date(3).toISOString(), preview: "newer" }],
|
||||
}),
|
||||
);
|
||||
await act(async () =>
|
||||
older.resolve({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "older" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.prompts.map((prompt) => prompt.seq)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("reports a failed unloaded prompt jump", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
runtime.fetchAgentTimeline.mockRejectedValue(new Error("disconnected"));
|
||||
const onJumpError = vi.fn();
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail: [],
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(1));
|
||||
|
||||
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPromptJumpSettleController,
|
||||
type PromptJumpSettleViewport,
|
||||
} from "./prompt-jump-settle";
|
||||
|
||||
interface FakeFrameScheduler {
|
||||
requestFrame(callback: () => void): number;
|
||||
cancelFrame(frameId: number): void;
|
||||
runNextFrame(): void;
|
||||
pendingFrames(): number;
|
||||
}
|
||||
|
||||
interface FakeViewport extends PromptJumpSettleViewport {
|
||||
emitUserIntent(): void;
|
||||
setTargetTop(itemId: string, top: number | null): void;
|
||||
scrollWrites(): number[];
|
||||
requestedTargets(): string[];
|
||||
}
|
||||
|
||||
function createFakeFrameScheduler(): FakeFrameScheduler {
|
||||
const frames = new Map<number, () => void>();
|
||||
let nextFrameId = 1;
|
||||
return {
|
||||
requestFrame(callback) {
|
||||
const frameId = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(frameId, callback);
|
||||
return frameId;
|
||||
},
|
||||
cancelFrame(frameId) {
|
||||
frames.delete(frameId);
|
||||
},
|
||||
runNextFrame() {
|
||||
const next = frames.entries().next().value;
|
||||
if (!next) {
|
||||
throw new Error("Expected a pending prompt jump frame");
|
||||
}
|
||||
const [frameId, callback] = next;
|
||||
frames.delete(frameId);
|
||||
callback();
|
||||
},
|
||||
pendingFrames() {
|
||||
return frames.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeViewport(input?: {
|
||||
clientHeight?: number;
|
||||
scrollHeight?: number;
|
||||
scrollTop?: number;
|
||||
}): FakeViewport {
|
||||
const targets = new Map<string, number>();
|
||||
const requestedTargets: string[] = [];
|
||||
const scrollWrites: number[] = [];
|
||||
const userIntentListeners = new Set<() => void>();
|
||||
const clientHeight = input?.clientHeight ?? 100;
|
||||
const scrollHeight = input?.scrollHeight ?? 1_000;
|
||||
let scrollTop = input?.scrollTop ?? 100;
|
||||
return {
|
||||
findTargetTop(itemId) {
|
||||
requestedTargets.push(itemId);
|
||||
return targets.get(itemId) ?? null;
|
||||
},
|
||||
getContainerTop() {
|
||||
return 0;
|
||||
},
|
||||
getScrollMetrics() {
|
||||
return { clientHeight, scrollHeight, scrollTop };
|
||||
},
|
||||
setScrollTop(nextScrollTop) {
|
||||
const previousScrollTop = scrollTop;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
scrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop));
|
||||
const appliedDelta = scrollTop - previousScrollTop;
|
||||
for (const [itemId, top] of targets) {
|
||||
targets.set(itemId, top - appliedDelta);
|
||||
}
|
||||
scrollWrites.push(scrollTop);
|
||||
},
|
||||
subscribeToUserIntent(listener) {
|
||||
userIntentListeners.add(listener);
|
||||
return () => userIntentListeners.delete(listener);
|
||||
},
|
||||
emitUserIntent() {
|
||||
for (const listener of userIntentListeners) listener();
|
||||
},
|
||||
setTargetTop(itemId, top) {
|
||||
if (top === null) {
|
||||
targets.delete(itemId);
|
||||
} else {
|
||||
targets.set(itemId, top);
|
||||
}
|
||||
},
|
||||
scrollWrites() {
|
||||
return scrollWrites;
|
||||
},
|
||||
requestedTargets() {
|
||||
return requestedTargets;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createController(viewport: FakeViewport, frames: FakeFrameScheduler) {
|
||||
return createPromptJumpSettleController({
|
||||
viewport,
|
||||
requestFrame: frames.requestFrame,
|
||||
cancelFrame: frames.cancelFrame,
|
||||
});
|
||||
}
|
||||
|
||||
describe("prompt jump settle", () => {
|
||||
it("re-pins the target when geometry shifts between frames", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport();
|
||||
const controller = createController(viewport, frames);
|
||||
viewport.setTargetTop("target", 48);
|
||||
|
||||
controller.start("target");
|
||||
frames.runNextFrame();
|
||||
viewport.setTargetTop("target", 28);
|
||||
frames.runNextFrame();
|
||||
|
||||
expect(viewport.scrollWrites()).toEqual([140, 160]);
|
||||
});
|
||||
|
||||
it("succeeds after two consecutive in-position frames", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport();
|
||||
const controller = createController(viewport, frames);
|
||||
viewport.setTargetTop("target", 8);
|
||||
|
||||
controller.start("target");
|
||||
frames.runNextFrame();
|
||||
frames.runNextFrame();
|
||||
|
||||
expect(controller.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("succeeds when the target remains below the top at maximum scroll", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport({ clientHeight: 100, scrollHeight: 220, scrollTop: 100 });
|
||||
const controller = createController(viewport, frames);
|
||||
viewport.setTargetTop("target", 50);
|
||||
|
||||
controller.start("target");
|
||||
frames.runNextFrame();
|
||||
|
||||
expect({ active: controller.isActive(), writes: viewport.scrollWrites() }).toEqual({
|
||||
active: false,
|
||||
writes: [120],
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts on user intent", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport();
|
||||
const controller = createController(viewport, frames);
|
||||
|
||||
controller.start("target");
|
||||
viewport.emitUserIntent();
|
||||
|
||||
expect({ active: controller.isActive(), frames: frames.pendingFrames() }).toEqual({
|
||||
active: false,
|
||||
frames: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("expires after its frame budget", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport();
|
||||
const controller = createController(viewport, frames);
|
||||
|
||||
controller.start("missing");
|
||||
for (let frame = 0; frame < 24; frame += 1) frames.runNextFrame();
|
||||
|
||||
expect(controller.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels the previous target when a newer jump starts", () => {
|
||||
const frames = createFakeFrameScheduler();
|
||||
const viewport = createFakeViewport();
|
||||
const controller = createController(viewport, frames);
|
||||
viewport.setTargetTop("new", 8);
|
||||
|
||||
controller.start("old");
|
||||
controller.start("new");
|
||||
frames.runNextFrame();
|
||||
|
||||
expect(viewport.requestedTargets()).toEqual(["new"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -201,6 +201,50 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports the live-head row as the reading position", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onReadingPositionChange = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1)],
|
||||
liveHead: [userMessage(2)],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: true,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onReadingPositionChange).toHaveBeenLastCalledWith("message-2");
|
||||
});
|
||||
|
||||
it("keeps bottom anchoring through subpixel browser rounding", () => {
|
||||
const scrollTo = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
@@ -49,6 +49,12 @@ const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||
const CONTENT_PADDING_TOP_PX = 16;
|
||||
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
|
||||
// A row has to clear this much of the viewport top before the next one takes over as the
|
||||
// reading position, so a row resting exactly on the edge does not flip back and forth.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -68,11 +74,11 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 32,
|
||||
height: HISTORY_START_SLOT_HEIGHT_PX,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const mountedHistoryRowStyle: CSSProperties = {
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
@@ -131,7 +137,6 @@ function getScrollContainerDistanceFromBottom(
|
||||
function isScrollContainerOverscrolledPastBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): boolean {
|
||||
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
||||
}
|
||||
|
||||
@@ -146,6 +151,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
@@ -206,6 +212,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return row ? estimateStreamItemHeight(row) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: 8,
|
||||
});
|
||||
@@ -467,6 +474,33 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduleStickToBottom();
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
|
||||
|
||||
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
|
||||
// clears the reading line is the one the reader is looking at.
|
||||
const reportReadingPosition = useStableEvent(() => {
|
||||
if (!onReadingPositionChange) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
if (!scrollContainer || !contentNode) {
|
||||
onReadingPositionChange(null);
|
||||
return;
|
||||
}
|
||||
const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX;
|
||||
let readingRowId: string | null = null;
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
const rowId = element.dataset.historyRowId;
|
||||
if (!rowId) {
|
||||
continue;
|
||||
}
|
||||
readingRowId = rowId;
|
||||
if (element.getBoundingClientRect().bottom > readingLine) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
onReadingPositionChange(readingRowId);
|
||||
});
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
@@ -474,7 +508,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||
}, [onNearBottomChange]);
|
||||
reportReadingPosition();
|
||||
}, [onNearBottomChange, reportReadingPosition]);
|
||||
|
||||
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized: segments.historyVirtualized,
|
||||
cancelPendingStickToBottom,
|
||||
setFollowOutput,
|
||||
onNearBottomChange,
|
||||
});
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -495,7 +539,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
rearmHistoryStartFromUserIntent();
|
||||
} else if (!followOutputRef.current && isAtBottom && scrolledDown) {
|
||||
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
@@ -512,6 +556,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
evaluateHistoryStart,
|
||||
isJumpSettling,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
@@ -709,6 +754,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
@@ -717,14 +763,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
};
|
||||
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const contentContainerStyle = useMemo((): CSSProperties => {
|
||||
return {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100%",
|
||||
paddingTop: 16,
|
||||
paddingTop: CONTENT_PADDING_TOP_PX,
|
||||
paddingBottom: 16,
|
||||
paddingLeft: isMobileBreakpoint ? 8 : 16,
|
||||
paddingRight: isMobileBreakpoint ? 8 : 16,
|
||||
@@ -737,6 +789,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
minHeight: 0,
|
||||
overflowX: "hidden",
|
||||
overflowY: scrollEnabled ? "auto" : "hidden",
|
||||
overflowAnchor: "none",
|
||||
overscrollBehaviorY: "contain",
|
||||
};
|
||||
}, [scrollEnabled]);
|
||||
@@ -755,13 +808,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
transform: `translateY(${start}px)`,
|
||||
transform: `translateY(${start - VIRTUALIZER_SCROLL_MARGIN_PX}px)`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<div key={item.id} data-history-row-id={item.id} style={mountedHistoryRowStyle}>
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</div>
|
||||
));
|
||||
@@ -769,7 +822,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const liveHeadRows = useMemo(() => {
|
||||
void liveHeadRowRevision;
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</div>
|
||||
));
|
||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface StreamEdgeSlotProps {
|
||||
export interface StreamViewportHandle {
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (itemId: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamSegmentRenderers {
|
||||
@@ -69,6 +70,9 @@ export interface StreamRenderInput {
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
// The history row under the top of the viewport, for surfaces that mark where the reader
|
||||
// is in the transcript. Only the web viewport measures it today.
|
||||
onReadingPositionChange?: (rowId: string | null) => void;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
|
||||
describe("returnToTimelineTail", () => {
|
||||
it("reports a failed fetch without scrolling or rejecting", async () => {
|
||||
const scrollToBottom = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const logError = vi.fn();
|
||||
|
||||
await expect(
|
||||
returnToTimelineTail({
|
||||
fetchTail: () => Promise.reject(new Error("disconnected")),
|
||||
scrollToBottom,
|
||||
onError,
|
||||
logError,
|
||||
}).then(() => ({
|
||||
scrolled: scrollToBottom.mock.calls.length,
|
||||
errors: onError.mock.calls.length,
|
||||
warnings: logError.mock.calls.length,
|
||||
})),
|
||||
).resolves.toEqual({ scrolled: 0, errors: 1, warnings: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function returnToTimelineTail(input: {
|
||||
fetchTail: () => Promise<unknown>;
|
||||
scrollToBottom: () => void;
|
||||
onError: () => void;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await input.fetchTail();
|
||||
input.scrollToBottom();
|
||||
} catch (error) {
|
||||
(input.logError ?? console.warn)("Failed to return to the live timeline tail", error);
|
||||
input.onError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
|
||||
interface UseScrollToMessageInput {
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
historyVirtualized: readonly { id: string }[];
|
||||
cancelPendingStickToBottom: () => void;
|
||||
setFollowOutput: (value: boolean) => boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SCROLL_AFFECTING_KEYS = new Set([
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
]);
|
||||
|
||||
export function useScrollToMessage({
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized,
|
||||
cancelPendingStickToBottom,
|
||||
setFollowOutput,
|
||||
onNearBottomChange,
|
||||
}: UseScrollToMessageInput) {
|
||||
const settleController = useMemo(
|
||||
() =>
|
||||
createPromptJumpSettleController({
|
||||
viewport: {
|
||||
findTargetTop(itemId) {
|
||||
const container = scrollContainerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
);
|
||||
return target?.getBoundingClientRect().top ?? null;
|
||||
},
|
||||
getContainerTop() {
|
||||
return scrollContainerRef.current?.getBoundingClientRect().top ?? 0;
|
||||
},
|
||||
getScrollMetrics() {
|
||||
const container = scrollContainerRef.current;
|
||||
return {
|
||||
clientHeight: container?.clientHeight ?? 0,
|
||||
scrollHeight: container?.scrollHeight ?? 0,
|
||||
scrollTop: container?.scrollTop ?? 0,
|
||||
};
|
||||
},
|
||||
setScrollTop(scrollTop) {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) container.scrollTop = scrollTop;
|
||||
},
|
||||
subscribeToUserIntent(listener) {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return () => undefined;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (SCROLL_AFFECTING_KEYS.has(event.key)) listener();
|
||||
};
|
||||
container.addEventListener("wheel", listener, { passive: true });
|
||||
container.addEventListener("touchstart", listener, { passive: true });
|
||||
container.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("wheel", listener);
|
||||
container.removeEventListener("touchstart", listener);
|
||||
container.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
},
|
||||
},
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frameId) => window.cancelAnimationFrame(frameId),
|
||||
}),
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
useEffect(() => () => settleController.cancel(), [settleController]);
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(itemId: string) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
|
||||
const mounted = container.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
);
|
||||
if (mounted) {
|
||||
const delta =
|
||||
mounted.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top -
|
||||
PROMPT_JUMP_TOP_INSET_PX;
|
||||
container.scrollTop += delta;
|
||||
settleController.start(itemId);
|
||||
onNearBottomChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = historyVirtualized.findIndex((item) => item.id === itemId);
|
||||
if (index >= 0) {
|
||||
rowVirtualizer.scrollToIndex(index, { align: "start" });
|
||||
settleController.start(itemId);
|
||||
onNearBottomChange(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
cancelPendingStickToBottom,
|
||||
historyVirtualized,
|
||||
onNearBottomChange,
|
||||
rowVirtualizer,
|
||||
scrollContainerRef,
|
||||
settleController,
|
||||
setFollowOutput,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isJumpSettling: settleController.isActive,
|
||||
scrollToMessage,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
@@ -66,6 +67,10 @@ import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/vi
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
|
||||
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
TurnFooter,
|
||||
@@ -155,7 +160,9 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
|
||||
if (input.layoutItem.frameOrder === "footer-then-content") {
|
||||
@@ -287,6 +294,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
@@ -324,6 +332,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
|
||||
);
|
||||
const supportsChatOutline = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true,
|
||||
);
|
||||
const timelineEpoch = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
|
||||
);
|
||||
const isTimelineDetached = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -516,6 +534,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
const handleTimelineHistoryLoadError = useCallback(() => {
|
||||
toast?.error(t("agentStream.historyLoadFailed"));
|
||||
}, [t, toast]);
|
||||
const chatOutline = useChatOutline({
|
||||
agentId,
|
||||
serverId: resolvedServerId,
|
||||
timelineEpoch,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead,
|
||||
enabled: supportsChatOutline && chatOutlineEnabled,
|
||||
viewportRef,
|
||||
onJumpError: handleTimelineHistoryLoadError,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -530,8 +562,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}, []);
|
||||
if (!isTimelineDetached) {
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
return;
|
||||
}
|
||||
void returnToTimelineTail({
|
||||
fetchTail: () =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, {
|
||||
...planTimelineTailFetch(),
|
||||
}),
|
||||
scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"),
|
||||
onError: handleTimelineHistoryLoadError,
|
||||
});
|
||||
}, [agentId, handleTimelineHistoryLoadError, isTimelineDetached, resolvedServerId]);
|
||||
|
||||
const setInlineDetailsExpanded = useCallback(
|
||||
(itemId: string, expanded: boolean) => {
|
||||
@@ -947,6 +990,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: chatOutline.reportReadingPosition,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
@@ -957,7 +1001,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
<ChatOutlineRail
|
||||
prompts={chatOutline.prompts}
|
||||
activePrompt={chatOutline.activePrompt}
|
||||
onJumpToPrompt={chatOutline.jumpToPrompt}
|
||||
/>
|
||||
{(!isNearBottom || isTimelineDetached) && (
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Pressable
|
||||
@@ -1559,6 +1608,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user