diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4c7cf29969d9..6ef0f38808ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -135,6 +135,8 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let resolveTimelineStatusRowPinned: typeof import("./MessagesTimeline").resolveTimelineStatusRowPinned; +let resolveTimelineStatusRowTopOffset: typeof import("./MessagesTimeline").resolveTimelineStatusRowTopOffset; beforeAll(async () => { const classList = { @@ -168,7 +170,8 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, resolveTimelineStatusRowPinned, resolveTimelineStatusRowTopOffset } = + await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -330,6 +333,28 @@ describe("MessagesTimeline", () => { expect(markup).toContain("px-1 text-sm leading-relaxed text-muted-foreground"); }); + it("pins a keyed status row when later rows follow it", () => { + const state = { + scroll: 100, + positionByKey: (key: string) => (key === "working-indicator-row" ? 112 : undefined), + }; + + expect(resolveTimelineStatusRowPinned(state, "working-indicator-row", 12)).toBe(true); + expect( + resolveTimelineStatusRowPinned({ ...state, scroll: 99 }, "working-indicator-row", 12), + ).toBe(false); + expect(resolveTimelineStatusRowPinned({ scroll: 100 }, "working-indicator-row", 12)).toBe( + false, + ); + }); + + it("matches the timeline header height at each breakpoint", () => { + expect(resolveTimelineStatusRowTopOffset(false, false)).toBe(12); + expect(resolveTimelineStatusRowTopOffset(false, true)).toBe(16); + expect(resolveTimelineStatusRowTopOffset(true, false)).toBe(40); + expect(resolveTimelineStatusRowTopOffset(true, true)).toBe(48); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dc0c03709e6c..98d33e5ba2ee 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -103,6 +103,7 @@ import { } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -444,9 +445,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const rows = useStableRows(rawRows); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const stickyStatusRow = isWorking + ? rows.find((row) => row.kind === "working") + : rows.find((row) => row.kind === "turn-fold" && row.turnId === latestTurn?.turnId); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); + const [statusRowPinned, setStatusRowPinned] = useState(false); + const isWideViewport = useMediaQuery("sm"); + const statusRowTop = resolveTimelineStatusRowTopOffset(topFadeEnabled, isWideViewport); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( @@ -470,11 +477,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } - if (!state || minimapItems.length === 0) { + if (!state) { return; } const scrollTop = state.scroll ?? 0; + setStatusRowPinned( + stickyStatusRow !== undefined && + resolveTimelineStatusRowPinned(state, stickyStatusRow.id, statusRowTop), + ); + if (minimapItems.length === 0) { + return; + } + const scrollBottom = scrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { @@ -492,7 +507,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + contentInsetEndAdjustment, + listRef, + minimapItems, + minimapStripMap, + onIsAtEndChange, + stickyStatusRow, + statusRowTop, + ]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -633,6 +656,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } ListFooterComponent={TIMELINE_LIST_FOOTER} /> + {statusRowPinned && stickyStatusRow ? ( +
+
+ +
+
+ ) : null} number | undefined; readonly scroll?: number; readonly scrollLength?: number; readonly positionAtIndex?: (index: number) => number | undefined; @@ -726,6 +767,23 @@ function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { return typeof top === "number" && Number.isFinite(top) ? top : null; } +export function resolveTimelineStatusRowPinned( + state: TimelinePositionState, + rowKey: string, + statusRowTop: number, +) { + const rowTop = state.positionByKey?.(rowKey); + return ( + typeof rowTop === "number" && + Number.isFinite(rowTop) && + rowTop <= (state.scroll ?? 0) + statusRowTop + ); +} + +export function resolveTimelineStatusRowTopOffset(topFadeEnabled: boolean, isWide: boolean) { + return topFadeEnabled ? (isWide ? 48 : 40) : isWide ? 16 : 12; +} + function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) { const height = state.sizeAtIndex?.(rowIndex); return typeof height === "number" && Number.isFinite(height) ? height : null; @@ -971,6 +1029,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "turn-plan" ? "pb-2" : "pb-4", + row.kind === "turn-fold" || row.kind === "working" ? "bg-background" : null, row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, )} data-timeline-row-id={row.id}