Skip to content
Open
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
18 changes: 15 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,11 @@ export default function ChatView(props: ChatViewProps) {
);
const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [messagesBelow, setMessagesBelow] = useState(0);
const scrollToEndLabel =
messagesBelow > 0
? `${messagesBelow} ${messagesBelow === 1 ? "message" : "messages"}`
: "Scroll to end";
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
useEffect(() => {
const item = expandedImage?.images[expandedImage.index];
Expand Down Expand Up @@ -5314,7 +5319,7 @@ export default function ChatView(props: ChatViewProps) {
'[data-chat-composer-main-surface="true"]',
);
const button = composerOverlayElement?.parentElement?.querySelector<HTMLElement>(
'button[aria-label="Scroll to end"]',
"button[data-scroll-to-end]",
);
const clearance =
composerOverlayElement && mainSurface && button
Expand Down Expand Up @@ -8260,6 +8265,8 @@ export default function ChatView(props: ChatViewProps) {
contentInsetEndAdjustment={composerTimelineInset}
liveFollowEnabled={timelineLiveFollowEnabled}
onIsAtEndChange={onIsAtEndChange}
onMessagesBelowChange={setMessagesBelow}
visibleBottomInset={composerOverlayHeight}
onContentOverflowChange={setTimelineOverflows}
onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
Expand All @@ -8275,7 +8282,12 @@ export default function ChatView(props: ChatViewProps) {
style={{ bottom: scrollToEndClearance + 4 }}
>
<Button
aria-label="Scroll to end"
data-scroll-to-end="true"
aria-label={
messagesBelow > 0
? `${scrollToEndLabel} below. Scroll to end`
: scrollToEndLabel
}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
composerRef.current?.restoreAfterTimelineReachedEnd();
Expand All @@ -8286,7 +8298,7 @@ export default function ChatView(props: ChatViewProps) {
variant="glass"
>
<ChevronDownIcon className="size-3.5" />
Scroll to end
{scrollToEndLabel}
</Button>
</div>
)}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,49 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo
return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}

/** Counts message rows with content below the unobscured viewport, including a partial row. */
export function countTimelineMessagesBelow(
messageRowIndices: ReadonlyArray<number>,
state:
| {
readonly scroll?: number;
readonly scrollLength?: number;
readonly positionAtIndex?: (index: number) => number | undefined;
readonly sizeAtIndex?: (index: number) => number | undefined;
}
| undefined,
composerInset: number,
headerSize = 0,
): number {
if (state?.scroll === undefined || state.scrollLength === undefined) return 0;
const visibleBottom = state.scroll + state.scrollLength - Math.max(0, composerInset) - headerSize;
// Cached row positions are ordered, so only log(n) lookups are needed per scroll.
let low = 0;
let high = messageRowIndices.length;
while (low < high) {
const middle = (low + high) >>> 1;
const rowIndex = messageRowIndices[middle]!;
const top = state.positionAtIndex?.(rowIndex);
if (top === undefined || !Number.isFinite(top)) return 0;
// Offscreen rows can have an estimated position without a measured size.
// Their top alone is sufficient when the whole row is below the viewport.
const height = state.sizeAtIndex?.(rowIndex);
let bottom = top;
if (height !== undefined && Number.isFinite(height)) {
bottom = top + height;
} else {
const nextTop = state.positionAtIndex?.(rowIndex + 1);
if (nextTop !== undefined && Number.isFinite(nextTop)) bottom = nextTop;
}
if (top > visibleBottom + 1 || bottom > visibleBottom + 1) {
high = middle;
} else {
low = middle + 1;
}
}
return messageRowIndices.length - low;
}

export function shouldPreserveAssistantLineBreaks(text: string): boolean {
return /^★ Insight(?:\s|─)/mu.test(text);
}
Expand Down
44 changes: 40 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import {
import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
import {
computeStableMessagesTimelineRows,
countTimelineMessagesBelow,
deriveMessagesTimelineRowsWithState,
type MessagesTimelineRowsProjection,
liveWorkEntryLabel,
Expand Down Expand Up @@ -344,6 +345,8 @@ interface MessagesTimelineProps {
*/
liveFollowEnabled: boolean;
onIsAtEndChange: (isAtEnd: boolean) => void;
onMessagesBelowChange?: (count: number) => void;
visibleBottomInset?: number;
/**
* Whether the real rows extend past the viewport above the composer.
* Reported after scrolls, row size changes, and viewport resizes.
Expand Down Expand Up @@ -396,6 +399,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
contentInsetEndAdjustment,
liveFollowEnabled,
onIsAtEndChange,
onMessagesBelowChange,
visibleBottomInset = contentInsetEndAdjustment,
onContentOverflowChange,
onToolOutputCollapsedAtEnd,
onManualNavigation,
Expand Down Expand Up @@ -573,6 +578,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
supportsConversationRollback,
]);
const rows = useStableRows(rawRows);
const messageRowIndices = useMemo(
() => rows.flatMap((row, index) => (row.kind === "message" ? [index] : [])),
[rows],
);
const timelineHeaderSizeRef = useRef(0);
const reportMessagesBelow = useCallback(() => {
onMessagesBelowChange?.(
countTimelineMessagesBelow(
messageRowIndices,
listRef.current?.getState?.(),
visibleBottomInset,
timelineHeaderSizeRef.current,
),
);
}, [visibleBottomInset, listRef, messageRowIndices, onMessagesBelowChange]);
const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
null,
Expand Down Expand Up @@ -637,12 +657,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());
reportMessagesBelow();
});
}, [measureContentOverflow, onContentOverflowChange]);
}, [measureContentOverflow, onContentOverflowChange, reportMessagesBelow]);
const handleMetricsChange = useCallback(
(metrics: { headerSize: number }) => {
timelineHeaderSizeRef.current = metrics.headerSize;
reportContentOverflow();
},
[reportContentOverflow],
);
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 All @@ -652,7 +680,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
useLayoutEffect(() => {
cancelContentOverflowFrame();
onContentOverflowChange?.(measureContentOverflow());
}, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]);
reportMessagesBelow();
}, [
cancelContentOverflowFrame,
measureContentOverflow,
onContentOverflowChange,
reportMessagesBelow,
rows.length,
]);

const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
Expand Down Expand Up @@ -863,6 +898,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
maintainScrollAtEndThreshold={1}
onScroll={handleScroll}
onItemSizeChanged={reportContentOverflow}
onMetricsChange={handleMetricsChange}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "topbar-scroll-fade",
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/components/chat/messagesBelow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { countTimelineMessagesBelow } from "./MessagesTimeline.logic";

describe("countTimelineMessagesBelow", () => {
const state = {
scroll: 0,
scrollLength: 400,
positionAtIndex: (index: number) => index * 100,
sizeAtIndex: () => 100,
};

it("counts only indexed messages, including one partly obscured by the composer", () => {
// Rows 1 and 3 are tool activity, not messages.
expect(countTimelineMessagesBelow([0, 2, 4, 5], state, 150)).toBe(3);
expect(countTimelineMessagesBelow([0, 2, 4, 5], { ...state, scroll: 50 }, 150)).toBe(2);
});

it("decreases while scrolling down and increases when scrolling back up", () => {
const indices = [0, 1, 2, 3, 4, 5];
expect(countTimelineMessagesBelow(indices, state, 100)).toBe(3);
expect(countTimelineMessagesBelow(indices, { ...state, scroll: 200 }, 100)).toBe(1);
expect(countTimelineMessagesBelow(indices, { ...state, scroll: 300 }, 100)).toBe(0);
expect(countTimelineMessagesBelow(indices, state, 100)).toBe(3);
});

it("updates for appended messages, streaming growth, and viewport or composer resizing", () => {
expect(countTimelineMessagesBelow([0, 1, 2], state, 100)).toBe(0);
expect(countTimelineMessagesBelow([0, 1, 2, 3], state, 100)).toBe(1);
expect(countTimelineMessagesBelow([0, 1, 2], { ...state, sizeAtIndex: () => 150 }, 100)).toBe(
1,
);
expect(countTimelineMessagesBelow([0, 1, 2], { ...state, scrollLength: 250 }, 100)).toBe(2);
expect(countTimelineMessagesBelow([0, 1, 2], state, 250)).toBe(2);
});

it("accounts for the header and actual overlay rather than reserved footer space", () => {
const indices = [0, 1, 2, 3];
expect(countTimelineMessagesBelow(indices, state, 110, 24)).toBe(2);
expect(countTimelineMessagesBelow(indices, { ...state, scroll: 35 }, 110, 24)).toBe(1);
// A collapsed composer leaves reserved scroll space, but no longer obscures that area.
expect(countTimelineMessagesBelow(indices, state, 204, 24)).toBe(3);
});

it("does not count blank end space or an empty timeline", () => {
expect(countTimelineMessagesBelow([0, 1], { ...state, scroll: 500 }, 100)).toBe(0);
expect(countTimelineMessagesBelow([], state, 100)).toBe(0);
});

it("waits for valid measurements and tolerates fractional pixel rounding", () => {
expect(countTimelineMessagesBelow([0], undefined, 100)).toBe(0);
expect(countTimelineMessagesBelow([0], {}, 100)).toBe(0);
expect(countTimelineMessagesBelow([0], { ...state, sizeAtIndex: () => undefined }, 100)).toBe(
0,
);
expect(countTimelineMessagesBelow([0], { ...state, positionAtIndex: () => NaN }, 100)).toBe(0);
expect(countTimelineMessagesBelow([0, 1, 2], { ...state, scroll: -0.5 }, 100)).toBe(0);
});

it("counts virtualized rows whose sizes have not been measured yet", () => {
expect(
countTimelineMessagesBelow(
[0, 1, 2, 3, 4, 5],
{
...state,
sizeAtIndex: () => undefined,
},
150,
),
).toBe(4);
});

it.each([undefined, NaN, Infinity, -Infinity])(
"falls back to the next row for an invalid height: %s",
(height) => {
const unmeasured = {
...state,
positionAtIndex: (index: number) => 200 + index * 100,
sizeAtIndex: () => height,
};
expect(countTimelineMessagesBelow([0], unmeasured, 150)).toBe(1);
expect(countTimelineMessagesBelow([0], { ...unmeasured, scroll: 100 }, 150)).toBe(0);
},
);

it.each([undefined, NaN, Infinity, -Infinity])(
"falls back to the row top when the next position is invalid: %s",
(nextPosition) => {
const unmeasured = {
...state,
positionAtIndex: (index: number) => (index === 0 ? 300 : nextPosition),
sizeAtIndex: () => NaN,
};
expect(countTimelineMessagesBelow([0], unmeasured, 150)).toBe(1);
expect(countTimelineMessagesBelow([0], { ...unmeasured, scroll: 100 }, 150)).toBe(0);
},
);

it("uses logarithmic cached position reads for long histories", () => {
const indices = Array.from({ length: 10_000 }, (_, index) => index);
const positionAtIndex = vi.fn(state.positionAtIndex);
expect(countTimelineMessagesBelow(indices, { ...state, positionAtIndex }, 100)).toBe(9997);
expect(positionAtIndex.mock.calls.length).toBeLessThanOrEqual(14);
});
});
7 changes: 7 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ include a skill when the task needs more context.
Messages can contain up to 120,000 characters. Longer drafts stay in the composer
so you can shorten them or split them into several messages.

## Return to the latest message

On web and desktop, scrolling up shows a button above the composer with the number
of messages remaining below your view. A partially visible message counts until
its end is visible. Tool activity and messages hidden inside collapsed turns do
not count. Select the button to return to the end of the conversation.

## Attach files

Attach up to eight files per message. Images can be up to 10 MB; other files can
Expand Down
Loading