Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
405 changes: 405 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx

Large diffs are not rendered by default.

29 changes: 0 additions & 29 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,35 +405,6 @@ export function resolveTimelineMinimapVisibleRange(state: {
return { start, end };
}

export function isTimelineMinimapRowVisible(
rowIndex: number,
visibleStart: number | null | undefined,
visibleEnd: number | null | undefined,
): boolean {
return (
visibleStart != null && visibleEnd != null && rowIndex >= visibleStart && rowIndex <= visibleEnd
);
}

export function resolveTimelineMinimapCurrentIndex(input: {
readonly visibleStart: number | null | undefined;
readonly visibleEnd: number | null | undefined;
readonly rowIndices: ReadonlyArray<number>;
}): number | null {
if (input.visibleStart == null || input.visibleEnd == null) {
return null;
}
let precedingIndex: number | null = null;
for (const [index, rowIndex] of input.rowIndices.entries()) {
if (rowIndex >= input.visibleStart) {
// Prefer the first visible user message, otherwise the turn being read.
return rowIndex <= input.visibleEnd ? index : precedingIndex;
}
precedingIndex = index;
}
return precedingIndex;
}

function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) {
return 0;
Expand Down
58 changes: 24 additions & 34 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
import {
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
resolveTimelineMinimapCurrentIndex,
isTimelineMinimapRowVisible,
resolveTimelineMinimapVisibleRange,
resolveTimelineMinimapHitStripWidth,
timelineTurnFoldRunIdsByEntryId,
Expand All @@ -84,7 +82,7 @@ import { type HandoffTimelineRun } from "./V2LifecycleRow";
import { TimelineSystemDivider } from "./TimelineSystemDivider";

import { forwardWheelToTimeline } from "./timelineMinimapWheel";
import { TimelineMinimap } from "./TimelineMinimap";
import { TimelineMinimap, type TimelineMinimapHandle } from "./TimelineMinimap";
import {
type TimelineRowSharedState,
type TimelineRowActivityState,
Expand Down Expand Up @@ -408,7 +406,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
const expandCitedRun = (runId: RunId) => {
setExpandedRunIds((current) => (current.has(runId) ? current : new Set([...current, runId])));
};
const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
const minimapRef = useRef<TimelineMinimapHandle>(null);
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const disclosureSettleFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -617,7 +615,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
// must not change with them or every timeline row re-renders per event.
const runs = useStableHandoffRuns(runsProp);
const minimapItems = deriveTimelineMinimapItems(rows);
const minimapRowIndices = minimapItems.map((item) => item.rowIndex);
const restoreRowIndex =
restoringThreadPosition && rememberedPosition?.atEnd === false
? rows.findIndex((row) => row.id === rememberedPosition.rowId)
Expand Down Expand Up @@ -690,7 +687,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
...(alwaysRenderKeys.length === 0 ? {} : { keys: alwaysRenderKeys }),
};
const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
const handleAnchorReady = (info: { anchorIndex: number | undefined }) => {
if (anchorMessageId !== null && info.anchorIndex !== undefined) {
onAnchorReady(anchorMessageId, info.anchorIndex);
Expand Down Expand Up @@ -755,8 +751,8 @@ const ConversationTimeline = memo(function ConversationTimeline({

const refreshMinimap = useStableCallback(() => {
const state = listRef.current?.getState?.();
if (restoringThreadPosition || state?.data !== rows) return;
if (!state || minimapItems.length === 0) {
if (!state || restoringThreadPosition || state.data !== rows) {
minimapRef.current?.updateVisibleRange(null, null);
return;
}

Expand All @@ -774,38 +770,25 @@ const ConversationTimeline = memo(function ConversationTimeline({
firstBufferedRow.getBoundingClientRect().top
: state.scroll;
const visibleRange = resolveTimelineMinimapVisibleRange({ ...state, scroll });
for (const item of minimapItems) {
const strip = minimapStripMap.get(item.id);
const inView = isTimelineMinimapRowVisible(
item.rowIndex,
visibleRange.start,
visibleRange.end,
);
const next = inView ? "true" : "false";
// Avoid dirtying style state on every scroll tick for unchanged strips.
if (strip && strip.dataset.inView !== next) {
strip.dataset.inView = next;
}
}
const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({
visibleStart: visibleRange.start,
visibleEnd: visibleRange.end,
rowIndices: minimapRowIndices,
});
setMinimapCurrentIndex((current) =>
current === nextCurrentIndex ? current : nextCurrentIndex,
);
minimapRef.current?.updateVisibleRange(visibleRange.start, visibleRange.end);
});

const minimapRefreshFrameRef = useRef<number | null>(null);
const scheduleMinimapRefresh = () => {
const scheduleMinimapRefresh = useStableCallback(() => {
if (minimapRefreshFrameRef.current !== null) return;
// LegendList recycles row containers before React commits their positions.
minimapRefreshFrameRef.current = requestAnimationFrame(() => {
minimapRefreshFrameRef.current = null;
refreshMinimap();
});
};
});

useLayoutEffect(() => {
// Replacements and prepends can change marker indices without a scroll event.
refreshMinimap();
scheduleMinimapRefresh();
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Stable callbacks must recheck replaced rows and completed position restoration.
}, [rows, restoringThreadPosition, refreshMinimap, scheduleMinimapRefresh]);

const handleScroll = () => {
const state = listRef.current?.getState?.();
Expand Down Expand Up @@ -894,6 +877,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
);
setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
reportContentOverflow();
scheduleMinimapRefresh();
};

const frame = requestAnimationFrame(measure);
Expand All @@ -904,8 +888,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
cancelAnimationFrame(frame);
stopObserving();
};
}, [
timelineViewportElement,
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Rows and chat width resize the content without resizing the viewport.
}, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);
rows.length,
reportContentOverflow,
chatWidth,
scheduleMinimapRefresh,
]);

const sharedState: TimelineRowSharedState = {
citationRequest: readyCitationRequest,
Expand Down Expand Up @@ -1160,11 +1150,11 @@ const ConversationTimeline = memo(function ConversationTimeline({
ListFooterComponent={timelineListFooter}
/>
<TimelineMinimap
key={listIdentityKey}
ref={minimapRef}
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
currentIndex={minimapCurrentIndex}
stripMap={minimapStripMap}
onWheel={forwardMinimapWheel}
onSelect={(item) => {
onManualNavigation();
Expand Down
Loading
Loading