Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
416 changes: 416 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 @@ -396,35 +396,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
50 changes: 17 additions & 33 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ import { resolveCitationPhase, type CitedHistory } from "./citationProcedure";
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 @@ -438,7 +436,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
}
const citationThreadRef = parseScopedThreadKey(routeThreadKey);
const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined);
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 @@ -674,7 +672,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
// Run status/timestamps change per stream event; keep them out of shared row context.
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 @@ -779,7 +776,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 @@ -840,8 +836,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 @@ -859,38 +855,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.
// Defer geometry reads until LegendList finishes adjusting the committed rows.
scheduleMinimapRefresh();
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Stable callbacks must recheck replaced rows and completed position restoration.
}, [rows, restoringThreadPosition, scheduleMinimapRefresh]);

// The measurement the last remember wrote, so a follow release can rewrite
// it without reading render state from a listener.
Expand Down Expand Up @@ -968,6 +951,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
);
setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
scheduleMinimapRefresh();
};
// Rows added, removed or rewrapped (chat width) change the content size,
// which moves the scroll position, the overflow, and the content lane width.
Expand Down Expand Up @@ -1324,11 +1308,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) => {
navigateTimeline();
Expand Down
Loading
Loading