diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4f99fb5d5d7..b46944dcee1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1161,6 +1161,7 @@ function ChatViewContent(props: ChatViewProps) { const updateHeight = () => { const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + if (nextHeight <= 0) return; setComposerOverlayHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight, ); @@ -3148,18 +3149,61 @@ function ChatViewContent(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 }), + ); + // Scrolling is explicit so streamed timeline updates never take control away // from the user after the newly sent row has been positioned once. const scrollToEnd = useCallback((animated = false) => { + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); }, []); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); + const anchorUserScrollGenerationRef = useRef(0); const pendingAnchorScrollRestoreRef = useRef<{ readonly messageId: MessageId; readonly offset: number; + readonly userScrollGeneration: number; } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); + useEffect(() => { + let removeListeners: (() => void) | null = null; + const frame = requestAnimationFrame(() => { + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + return; + } + const markUserScrollIntent = () => { + anchorUserScrollGenerationRef.current += 1; + pendingAnchorScrollRestoreRef.current = null; + if (anchorScrollRestoreFrameRef.current !== null) { + cancelAnimationFrame(anchorScrollRestoreFrameRef.current); + anchorScrollRestoreFrameRef.current = null; + } + }; + scrollNode.addEventListener("wheel", markUserScrollIntent, { passive: true }); + scrollNode.addEventListener("touchmove", markUserScrollIntent, { passive: true }); + scrollNode.addEventListener("pointerdown", markUserScrollIntent, { passive: true }); + removeListeners = () => { + scrollNode.removeEventListener("wheel", markUserScrollIntent); + scrollNode.removeEventListener("touchmove", markUserScrollIntent); + scrollNode.removeEventListener("pointerdown", markUserScrollIntent); + }; + }); + + return () => { + cancelAnimationFrame(frame); + removeListeners?.(); + }; + }, [activeThread?.id]); + const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (positionedTimelineAnchorRef.current === messageId) { return; @@ -3211,7 +3255,11 @@ function ChatViewContent(props: ChatViewProps) { return; } if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { messageId, offset: scrollOffset }; + pendingAnchorScrollRestoreRef.current = { + messageId, + offset: scrollOffset, + userScrollGeneration: anchorUserScrollGenerationRef.current, + }; } if (anchorScrollRestoreFrameRef.current !== null) { return; @@ -3220,18 +3268,23 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; const pending = pendingAnchorScrollRestoreRef.current; pendingAnchorScrollRestoreRef.current = null; - if (pending && settledTimelineAnchorRef.current === pending.messageId) { - void legendListRef.current?.scrollToOffset({ offset: pending.offset, animated: false }); + if ( + pending && + settledTimelineAnchorRef.current === pending.messageId && + pending.userScrollGeneration === anchorUserScrollGenerationRef.current + ) { + 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 }); + } } }); }, []); - // 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 onIsAtEndChange = useCallback((isAtEnd: boolean) => { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; @@ -4870,7 +4923,7 @@ function ChatViewContent(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChange} /> - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {/* scroll to end pill — shown when user has scrolled away from the live edge */} {showScrollToBottom && (
)} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d93ec0d0314..314ff66ba12 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -10,6 +10,59 @@ import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../.. import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; +export const TIMELINE_MINIMAP_ITEM_SPACING = 8; +export const TIMELINE_MINIMAP_MIN_ITEMS = 2; +export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; +export const TIMELINE_CONTENT_MAX_WIDTH = 768; +export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 72; + +export interface TimelineEndState { + readonly isAtEnd?: boolean; + readonly isNearEnd?: boolean; +} + +export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { + return state?.isNearEnd ?? state?.isAtEnd; +} + +export function resolveTimelineMinimapHeightStyle(itemCount: number): string { + const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); + return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; +} + +export function resolveTimelineMinimapTopPercent(index: number, itemCount: number): number { + if (itemCount <= 1) { + return 0; + } + return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100; +} + +export function resolveTimelineMinimapIndexFromPointer(input: { + readonly itemCount: number; + readonly railTop: number; + readonly railHeight: number; + readonly pointerY: number; +}): number | null { + if (input.itemCount <= 0 || input.railHeight <= 0) { + return null; + } + if (input.itemCount === 1) { + return 0; + } + + const progress = Math.max(0, Math.min(1, (input.pointerY - input.railTop) / input.railHeight)); + return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); +} + +export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { + return false; + } + + const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); + const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); + return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; +} function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 55ef6bfd5a2..e12887f384e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -189,6 +189,43 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + const { + resolveTimelineIsAtEnd, + resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapTopPercent, + } = await import("./MessagesTimeline.logic"); + + expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); + expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); + expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); + expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + + expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); + expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); + expect( + resolveTimelineMinimapIndexFromPointer({ + itemCount: 101, + railTop: 100, + railHeight: 500, + pointerY: 350, + }), + ).toBe(50); + expect( + resolveTimelineMinimapIndexFromPointer({ + itemCount: 101, + railTop: 100, + railHeight: 500, + pointerY: 999, + }), + ).toBe(100); + expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(911)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(912)).toBe(true); + }); + it("anchors a sent attachment message using its measured height", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const onAnchorReady = vi.fn(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0134f5493f8..905d9bf81f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -18,6 +18,7 @@ import { useRef, useState, type KeyboardEvent, + type MouseEvent, type ReactNode, } from "react"; import { flushSync } from "react-dom"; @@ -69,8 +70,14 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveTimelineIsAtEnd, + resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapTopPercent, type StableMessagesTimelineRowsState, type MessagesTimelineRow, + TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; @@ -206,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + const [minimapStripMap] = useState(() => new Map()); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -307,6 +315,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); + const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const [timelineViewportElement, setTimelineViewportElement] = useState( + null, + ); + const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -334,10 +347,62 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - if (state) { - onIsAtEndChange(state.isAtEnd); + const isAtEnd = resolveTimelineIsAtEnd(state); + if (isAtEnd !== undefined) { + onIsAtEndChange(isAtEnd); + } + if (!state || minimapItems.length === 0) { + return; + } + + const scrollTop = state.scroll ?? 0; + const scrollBottom = scrollTop + (state.scrollLength ?? 0); + + for (const item of minimapItems) { + const strip = minimapStripMap.get(item.id); + if (!strip) { + continue; + } + + const rowTop = resolveTimelineRowTop(state, item.rowIndex); + const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); + const inView = + rowTop !== null && + rowTop < scrollBottom && + rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; + + strip.dataset.inView = inView ? "true" : "false"; + } + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + + useEffect(() => { + const frame = requestAnimationFrame(handleScroll); + return () => cancelAnimationFrame(frame); + }, [handleScroll, rows.length]); + + useEffect(() => { + if (!timelineViewportElement) { + return; } - }, [listRef, onIsAtEndChange]); + + const measure = () => { + const viewportWidth = timelineViewportElement.getBoundingClientRect().width; + const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); + setMinimapHasPersistentGutter((current) => + current === nextHasPersistentGutter ? current : nextHasPersistentGutter, + ); + }; + + const frame = requestAnimationFrame(measure); + + const observer = new ResizeObserver(measure); + observer.observe(timelineViewportElement); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [timelineViewportElement, rows.length]); const sharedState = useMemo( () => ({ @@ -403,25 +468,40 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( - - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainVisibleContentPosition={{ - data: true, - size: false, - }} - onScroll={handleScroll} - className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5" - ListHeaderComponent={TIMELINE_LIST_HEADER} - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> +
+ + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} + maintainVisibleContentPosition={{ + data: true, + size: false, + }} + onScroll={handleScroll} + className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5" + ListHeaderComponent={TIMELINE_LIST_HEADER} + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + { + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }} + /> +
); @@ -435,6 +515,249 @@ function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } +interface TimelineMinimapItem { + readonly id: string; + readonly rowIndex: number; + readonly userText: string | null; + readonly assistantText: string | null; +} + +interface TimelinePositionState { + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; + readonly positionAtIndex?: (index: number) => number | undefined; + readonly sizeAtIndex?: (index: number) => number | undefined; +} + +function deriveTimelineMinimapItems( + rows: ReadonlyArray, +): TimelineMinimapItem[] { + const items: TimelineMinimapItem[] = []; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message" || row.message.role !== "user") { + continue; + } + + items.push({ + id: row.id, + rowIndex: index, + userText: compactMinimapPreview(row.message.text), + assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), + }); + } + return items; +} + +function resolveFinalAssistantTextForTurn( + rows: ReadonlyArray, + userRowIndex: number, +) { + let finalAssistantText: string | null = null; + for (let index = userRowIndex + 1; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message") { + continue; + } + if (row.message.role === "user") { + break; + } + if (row.message.role === "assistant") { + finalAssistantText = row.message.text ?? null; + } + } + return finalAssistantText; +} + +function compactMinimapPreview(text: string | null | undefined) { + const compact = text?.replace(/\s+/g, " ").trim() ?? ""; + return compact.length > 0 ? compact : null; +} + +function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { + const top = state.positionAtIndex?.(rowIndex); + return typeof top === "number" && Number.isFinite(top) ? top : null; +} + +function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) { + const height = state.sizeAtIndex?.(rowIndex); + return typeof height === "number" && Number.isFinite(height) ? height : null; +} + +function TimelineMinimap({ + bottomInset, + hasPersistentGutter, + items, + stripMap, + onSelect, +}: { + bottomInset: number; + hasPersistentGutter: boolean; + items: ReadonlyArray; + stripMap: Map; + onSelect: (item: TimelineMinimapItem) => void; +}) { + const [activeIndex, setActiveIndex] = useState(null); + + const resolvedActiveIndex = + activeIndex !== null && activeIndex < items.length ? activeIndex : null; + const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const activeTopPercent = + resolvedActiveIndex === null + ? 0 + : resolveTimelineMinimapTopPercent(resolvedActiveIndex, items.length); + const activeTooltipTranslate = + resolvedActiveIndex === null + ? "-50%" + : resolvedActiveIndex === 0 + ? "0%" + : resolvedActiveIndex === items.length - 1 + ? "-100%" + : "-50%"; + + const updateActiveIndexFromPointer = useCallback( + (event: MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const nextIndex = resolveTimelineMinimapIndexFromPointer({ + itemCount: items.length, + railTop: rect.top, + railHeight: rect.height, + pointerY: event.clientY, + }); + setActiveIndex(nextIndex); + }, + [items.length], + ); + + const moveActiveIndex = useCallback( + (delta: number) => { + setActiveIndex((current) => { + const base = current ?? 0; + return Math.max(0, Math.min(items.length - 1, base + delta)); + }); + }, + [items.length], + ); + + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { + return null; + } + + const safeBottomInset = Math.max(0, Math.ceil(bottomInset)); + + return ( +