Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 58 additions & 45 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
import { truncate } from "@t3tools/shared/String";
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
import { Debouncer } from "@tanstack/react-pacer";
import { createTimelineEndAffordance } from "./chat/timelineEndAffordance";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import {
Expand Down Expand Up @@ -195,6 +195,7 @@ import { type LegendListRef } from "@legendapp/list/react";
import {
CHAT_TIMELINE_ANCHOR_OFFSET,
timelineContentOverflowsViewport,
getAnchoredTurnMetrics,
readTimelinePosition,
observeTimelineRun,
type TimelineRunObservation,
Expand Down Expand Up @@ -6074,9 +6075,7 @@ export default function ChatView(props: ChatViewProps) {
// Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
// thread switches. LegendList fires scroll events with isAtEnd=false while
// initialScrollAtEnd is settling; hiding is always immediate.
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const showScrollDebouncer = useRef(createTimelineEndAffordance(setShowScrollToBottom));
const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null);
const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
// State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
Expand Down Expand Up @@ -6233,6 +6232,8 @@ export default function ChatView(props: ChatViewProps) {
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
pendingTimelineAnchorRef.current = null;
positionedTimelineAnchorRef.current = null;
settledTimelineAnchorRef.current = null;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
Expand Down Expand Up @@ -6449,59 +6450,72 @@ export default function ChatView(props: ChatViewProps) {
};
requestAnimationFrame(() => positionAnchor(12));
}, []);
const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
if (settledTimelineAnchorRef.current !== messageId) {
return;
}
if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) {
return;
}
const scrollOffset = legendListRef.current?.getState().scroll;
if (scrollOffset === undefined) {
return;
}
if (pendingAnchorScrollRestoreRef.current === null) {
pendingAnchorScrollRestoreRef.current = {
messageId,
offset: scrollOffset,
userScrollGeneration: anchorUserScrollGenerationRef.current,
};
}
if (anchorScrollRestoreFrameRef.current !== null) {
return;
}
anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
anchorScrollRestoreFrameRef.current = null;
const pending = pendingAnchorScrollRestoreRef.current;
pendingAnchorScrollRestoreRef.current = null;
if (
pending &&
settledTimelineAnchorRef.current === pending.messageId &&
pending.userScrollGeneration === anchorUserScrollGenerationRef.current
) {
const list = legendListRef.current;
const currentScrollOffset = list?.getState().scroll;
const onTimelineAnchorSizeChanged = useCallback(
(messageId: MessageId) => {
if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) {
const state = legendListRef.current?.getState();
const anchorIndex = activeTimelineAnchorIndexRef.current;
if (state && anchorIndex !== null && positionedTimelineAnchorRef.current === messageId) {
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex,
composerOverlayHeight: composerTimelineInset,
anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
});
// Short answers stay framed under the prompt; long answers resume following.
if (metrics?.overflowsUsableViewport) scrollToEnd();
}
return;
}
if (settledTimelineAnchorRef.current !== messageId) return;
const scrollOffset = legendListRef.current?.getState().scroll;
if (scrollOffset === undefined) {
return;
}
if (pendingAnchorScrollRestoreRef.current === null) {
pendingAnchorScrollRestoreRef.current = {
messageId,
offset: scrollOffset,
userScrollGeneration: anchorUserScrollGenerationRef.current,
};
}
if (anchorScrollRestoreFrameRef.current !== null) {
return;
}
anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
anchorScrollRestoreFrameRef.current = null;
const pending = pendingAnchorScrollRestoreRef.current;
pendingAnchorScrollRestoreRef.current = null;
if (
typeof currentScrollOffset === "number" &&
Math.abs(currentScrollOffset - pending.offset) <= 2
pending &&
settledTimelineAnchorRef.current === pending.messageId &&
pending.userScrollGeneration === anchorUserScrollGenerationRef.current
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
const list = legendListRef.current;
const currentScrollOffset = list?.getState().scroll;
if (
typeof currentScrollOffset === "number" &&
Math.abs(currentScrollOffset - pending.offset) <= 2
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
}
}
}
});
}, []);
});
},
[composerTimelineInset, scrollToEnd],
);

const onToolOutputCollapsedAtEnd = useCallback(() => {
composerRef.current?.restoreAfterTimelineReachedEnd();
}, [composerRef]);

const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
// Follow intent cannot hide the recovery button when the viewport falls behind.
showScrollDebouncer.current.report(isAtEnd);
if (
!isAtEnd &&
liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
) {
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
return;
}
if (isAtEndRef.current === isAtEnd) return;
Expand All @@ -6523,7 +6537,6 @@ export default function ChatView(props: ChatViewProps) {
} else {
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.maybeExecute();
}
}, []);

Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
use,
useCallback,
useEffect,
useEffectEvent,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -1009,12 +1010,20 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}
}, []);
const reportContentOverflow = useCallback(() => {
if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return;
if (contentOverflowFrameRef.current !== null) return;
contentOverflowFrameRef.current = requestAnimationFrame(() => {
contentOverflowFrameRef.current = null;
onContentOverflowChange(measureContentOverflow());
onContentOverflowChange?.(measureContentOverflow());
reportTimelineEnd();
});
}, [measureContentOverflow, onContentOverflowChange]);
const reportTimelineEnd = useEffectEvent(() => {
if (restoringThreadPosition || citationPositioning) return;
const state = listRef.current?.getState();
if (state?.data !== rows) return;
const isAtEnd = resolveTimelineIsAtEnd(state);
if (isAtEnd !== undefined) onIsAtEndChange(isAtEnd);
});
useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]);
// The list's own layout effects have already run here, so estimated row
// positions are in place. Reporting before the first paint lets a thread
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/chat/timelineEndAffordance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { createTimelineEndAffordance } from "./timelineEndAffordance";

afterEach(() => vi.useRealTimers());

describe("timeline end recovery", () => {
it("shows recovery after sustained distance, even during continuous streaming reports", () => {
vi.useFakeTimers();
const setVisible = vi.fn();
const affordance = createTimelineEndAffordance(setVisible);
for (let i = 0; i < 10; i++) {
affordance.report(false);
vi.advanceTimersByTime(20);
}
expect(setVisible).toHaveBeenCalledWith(true);
affordance.cancel();
});
it("does not flash during brief layout settling", () => {
vi.useFakeTimers();
const setVisible = vi.fn();
const affordance = createTimelineEndAffordance(setVisible);
affordance.report(false);
vi.advanceTimersByTime(100);
affordance.report(true);
vi.advanceTimersByTime(200);
expect(setVisible).toHaveBeenCalledExactlyOnceWith(false);
});
it("hides immediately on reaching the end and can show again", () => {
vi.useFakeTimers();
const setVisible = vi.fn();
const affordance = createTimelineEndAffordance(setVisible);
affordance.report(false);
vi.advanceTimersByTime(150);
expect(setVisible).toHaveBeenLastCalledWith(true);
affordance.report(true);
expect(setVisible).toHaveBeenLastCalledWith(false);
affordance.report(false);
vi.advanceTimersByTime(150);
expect(setVisible).toHaveBeenLastCalledWith(true);
});
it("cancels pending recovery on navigation or an explicit return to the end", () => {
vi.useFakeTimers();
const setVisible = vi.fn();
const affordance = createTimelineEndAffordance(setVisible);
affordance.report(false);
affordance.cancel();
vi.advanceTimersByTime(200);
expect(setVisible).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions apps/web/src/components/chat/timelineEndAffordance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Debouncer } from "@tanstack/react-pacer";

/** Keep the recovery button available even if automatic follow stalls. */
export function createTimelineEndAffordance(setVisible: (visible: boolean) => void) {
const show = new Debouncer(() => setVisible(true), { wait: 150 });
return {
cancel: () => show.cancel(),
report(isAtEnd: boolean) {
if (isAtEnd) {
show.cancel();
setVisible(false);
} else if (!show.store.state.isPending) {
// Repeated size/scroll reports must not keep postponing the button.
show.maybeExecute();
}
},
};
}
Loading