Skip to content
Closed
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
60 changes: 53 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ import {
import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
import { getClientSettings, useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
import { prefersReducedMotion } from "../reducedMotion";
import { useNowMinute } from "../hooks/useNowMinute";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import { resolveAppModelSelectionForInstance } from "../modelSelection";
Expand Down Expand Up @@ -307,6 +308,12 @@ import {
} from "../versionSkew";
import { useAssetUrls } from "../assets/assetUrls";

// How long after live-follow takes the scroll position we still treat a
// not-at-end report as that scroll settling rather than the reader moving. Only
// consulted when follow-output is off; while it is on, live-follow owns the
// position for as long as it holds it.
const LIVE_FOLLOW_SETTLE_MS = 500;

const IMAGE_ONLY_BOOTSTRAP_PROMPT =
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
Expand All @@ -333,9 +340,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroup = transitionGroupRef.current;
const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null;
const stateChanged = previousStateRef.current !== isDraftHeroState;
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const reducedMotion = prefersReducedMotion(getClientSettings().reduceMotion);
const mobileComposerTransitionActive =
typeof document !== "undefined" &&
document.documentElement.dataset.mobileComposerRouteTransition === "true";
Expand All @@ -346,7 +351,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const previousComposerRect = previousComposerRectRef.current;
if (
stateChanged &&
!prefersReducedMotion &&
!reducedMotion &&
!mobileComposerTransitionActive &&
transitionGroup &&
previousComposerRect &&
Expand Down Expand Up @@ -3435,6 +3440,21 @@ function ChatViewContent(props: ChatViewProps) {
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const chatAutoScroll = useClientSettings((settings) => settings.chatAutoScroll);
const reduceMotionSetting = useClientSettings((settings) => settings.reduceMotion);
const timelineReduceMotion = prefersReducedMotion(reduceMotionSetting);
// The scroll callbacks below are deliberately stable (empty dependency lists,
// refs only) so changing a setting never re-creates them mid-stream; these
// mirrors are what let those callbacks read the current values.
const chatAutoScrollRef = useRef(chatAutoScroll);
// When live-follow last took ownership of the scroll position, used to tell a
// settling programmatic scroll apart from the reader falling behind.
const liveFollowSyncedAtRef = useRef(0);
const timelineReduceMotionRef = useRef(timelineReduceMotion);
useEffect(() => {
chatAutoScrollRef.current = chatAutoScroll;
timelineReduceMotionRef.current = timelineReduceMotion;
}, [chatAutoScroll, timelineReduceMotion]);
const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
Expand Down Expand Up @@ -3523,6 +3543,7 @@ function ChatViewContent(props: ChatViewProps) {
isAtEndRef.current = true;
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
liveFollowSyncedAtRef.current = performance.now();
pendingTimelineAnchorRef.current = null;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
Expand Down Expand Up @@ -3603,7 +3624,7 @@ function ChatViewContent(props: ChatViewProps) {
scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
void list.scrollToIndex({
index: anchorIndex,
animated: true,
animated: !timelineReduceMotionRef.current,
viewPosition: 0,
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
});
Expand Down Expand Up @@ -3654,8 +3675,18 @@ function ChatViewContent(props: ChatViewProps) {
}, []);

const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
// Leaving the live edge without a user gesture means a programmatic scroll
// is still settling, not that the reader navigated away, so the pill stays
// hidden. While live-follow is on it owns the position indefinitely. With it
// off, only the window right after a thread switch or a send is settling —
// LegendList reports not-at-end while `initialScrollAtEnd` lands — and once
// that passes, falling behind is exactly when the pill has to appear.
const settling =
chatAutoScrollRef.current ||
performance.now() - liveFollowSyncedAtRef.current < LIVE_FOLLOW_SETTLE_MS;
if (
!isAtEnd &&
settling &&
liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
) {
showScrollDebouncer.current.cancel();
Expand All @@ -3667,6 +3698,7 @@ function ChatViewContent(props: ChatViewProps) {
if (isAtEnd) {
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
liveFollowSyncedAtRef.current = performance.now();
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
} else {
Expand All @@ -3676,10 +3708,18 @@ function ChatViewContent(props: ChatViewProps) {
}
}, []);

// Live-follow. This runs on every timeline change, i.e. once per streamed
// chunk, so it is the scrolling a reader actually feels. With the setting off
// the transcript holds still and the scroll-to-end pill takes over; the
// one-time anchor positioning after a send is unaffected because that lives in
// onTimelineAnchorReady.
useEffect(() => {
if (!activeThread?.id) {
return;
}
if (!chatAutoScroll) {
return;
}
if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) {
return;
}
Expand Down Expand Up @@ -3737,6 +3777,7 @@ function ChatViewContent(props: ChatViewProps) {
};
}, [
activeThread?.id,
chatAutoScroll,
timelineEntries,
getActiveTimelineTurnMetrics,
timelineRealContentOverflowsViewport,
Expand All @@ -3747,6 +3788,7 @@ function ChatViewContent(props: ChatViewProps) {
isAtEndRef.current = true;
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
liveFollowSyncedAtRef.current = performance.now();
pendingTimelineAnchorRef.current = null;
positionedTimelineAnchorRef.current = null;
settledTimelineAnchorRef.current = null;
Expand Down Expand Up @@ -4665,6 +4707,7 @@ function ChatViewContent(props: ChatViewProps) {
isAtEndRef.current = true;
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
liveFollowSyncedAtRef.current = performance.now();
pendingTimelineAnchorRef.current = messageIdForSend;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
Expand Down Expand Up @@ -5108,6 +5151,7 @@ function ChatViewContent(props: ChatViewProps) {
isAtEndRef.current = true;
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
liveFollowSyncedAtRef.current = performance.now();
pendingTimelineAnchorRef.current = messageIdForSend;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
Expand Down Expand Up @@ -5758,6 +5802,8 @@ function ChatViewContent(props: ChatViewProps) {
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
topFadeEnabled={!hasTimelineTopBanner}
autoScrollEnabled={chatAutoScroll}
reduceMotion={timelineReduceMotion}
/>

{/* scroll to end pill — shown when user has scrolled away from the live edge */}
Expand All @@ -5770,7 +5816,7 @@ function ChatViewContent(props: ChatViewProps) {
type="button"
aria-label="Scroll to end"
title="Scroll to end"
onClick={() => scrollToEnd(true)}
onClick={() => scrollToEnd(!timelineReduceMotion)}
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
>
<ChevronDownIcon className="size-3.5" />
Expand Down
23 changes: 18 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,16 +414,29 @@ describe("MessagesTimeline", () => {
);

expect(markup).toContain("Show full message");
expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
expect(markup).toContain('data-maintain-scroll-at-end-animated="false"');
expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
expect(markup).toContain('data-maintain-scroll-at-end-layout="true"');
expect(markup).toContain('data-user-message-collapsed="true"');
expect(markup).toContain('data-user-message-fade="true"');
expect(markup).toContain('data-user-message-footer="true"');
});

it("only lets the list re-pin itself to the end when auto-scroll is on", () => {
const timelineEntries = [buildUserTimelineEntry("Short prompt.")];

const following = renderToStaticMarkup(
<MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} autoScrollEnabled />,
);
expect(following).toContain('data-maintain-scroll-at-end="enabled"');
expect(following).toContain('data-maintain-scroll-at-end-animated="false"');
expect(following).toContain('data-maintain-scroll-at-end-data-change="true"');
expect(following).toContain('data-maintain-scroll-at-end-item-layout="true"');
expect(following).toContain('data-maintain-scroll-at-end-layout="true"');

const still = renderToStaticMarkup(
<MessagesTimeline {...buildProps()} timelineEntries={timelineEntries} />,
);
expect(still).not.toContain('data-maintain-scroll-at-end="enabled"');
});

it("does not render collapse controls for short user messages", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand Down
35 changes: 21 additions & 14 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ interface MessagesTimelineProps {
onManualNavigation: () => void;
hideEmptyPlaceholder?: boolean;
topFadeEnabled?: boolean;
/** Whether the list may re-pin itself to the end as rows arrive or resize. */
autoScrollEnabled?: boolean;
/** Turns the minimap jump into an instant scroll instead of an animated one. */
reduceMotion?: boolean;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -219,6 +223,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onManualNavigation,
hideEmptyPlaceholder = false,
topFadeEnabled = false,
autoScrollEnabled = false,
reduceMotion = false,
}: MessagesTimelineProps) {
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
Expand Down Expand Up @@ -495,7 +501,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={contentInsetEndAdjustment}
maintainScrollAtEnd={
anchoredEndSpace
anchoredEndSpace || !autoScrollEnabled
? false
: {
animated: false,
Expand Down Expand Up @@ -528,7 +534,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
animated: !reduceMotion,
viewOffset: 24,
});
}}
Expand Down Expand Up @@ -694,7 +700,7 @@ function TimelineMinimap({
return (
<div
className={cn(
"group/minimap pointer-events-none absolute top-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
"group/minimap pointer-events-none absolute top-0 start-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
hasPersistentGutter
? "opacity-100"
: "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
Expand All @@ -707,7 +713,7 @@ function TimelineMinimap({
<button
aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
className={cn(
"absolute top-1/2 left-3 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70",
"absolute top-1/2 start-3 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70",
// The strip is width-capped to the side gutter so it never overlays
// the centered content column; with no usable gutter it goes inert.
hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
Expand Down Expand Up @@ -759,7 +765,7 @@ function TimelineMinimap({
}}
type="button"
>
<div className="absolute top-0 left-3 h-full w-px bg-border/15" />
<div className="absolute top-0 start-3 h-full w-px bg-border/15" />
{items.map((item, index) => {
const top = `${resolveTimelineMinimapTopPercent(index, items.length)}%`;
const activeDistance =
Expand All @@ -768,7 +774,7 @@ function TimelineMinimap({
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute left-0 h-0.5 -translate-y-1/2 rounded-full bg-muted-foreground/35 transition-[background-color,width] duration-150 data-[in-view=true]:bg-foreground/90",
"pointer-events-none absolute start-0 h-0.5 -translate-y-1/2 rounded-full bg-muted-foreground/35 transition-[background-color,width] duration-150 data-[in-view=true]:bg-foreground/90",
activeDistance === 0
? "w-6 bg-muted-foreground/75"
: activeDistance === 1
Expand All @@ -793,15 +799,15 @@ function TimelineMinimap({
})}
{activeItem ? (
<span
className="pointer-events-auto absolute left-8 w-80 cursor-text select-text"
className="pointer-events-auto absolute start-8 w-80 cursor-text select-text"
data-minimap-preview
onMouseMove={(event) => event.stopPropagation()}
style={{
top: `${activeTopPercent}%`,
transform: `translateY(${activeTooltipTranslate})`,
}}
>
<span className="dropdown-glass block rounded-xl p-3 text-left text-popover-foreground shadow-xl shadow-black/25">
<span className="dropdown-glass block rounded-xl p-3 text-start text-popover-foreground shadow-xl shadow-black/25">
<span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
{activeItem.userText ?? "User message"}
</span>
Expand Down Expand Up @@ -1093,7 +1099,7 @@ function ProposedPlanTimelineRow({

function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
return (
<div className="py-0.5 pl-1.5">
<div className="py-0.5 ps-1.5">
<div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
<span className="inline-flex items-center gap-[3px]">
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
Expand Down Expand Up @@ -1204,7 +1210,7 @@ function WorkGroupToggleTimelineRow({
return (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-start text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-expanded={row.expanded}
onClick={(event) => {
const anchorElement =
Expand Down Expand Up @@ -1353,7 +1359,7 @@ function UserMessagePreviewAnnotationCard(props: {
{props.image?.previewUrl ? (
<button
type="button"
className="size-14 shrink-0 cursor-zoom-in overflow-hidden border-r border-border/70 bg-muted"
className="size-14 shrink-0 cursor-zoom-in overflow-hidden border-e border-border/70 bg-muted"
aria-label={`Preview ${props.image.name}`}
onClick={() => {
if (!props.image) return;
Expand Down Expand Up @@ -1465,13 +1471,13 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
aria-expanded={expanded}
data-scroll-anchor-ignore
onClick={() => setExpanded((value) => !value)}
className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85"
className="-ms-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85"
>
{expanded ? "Show less" : "Show full message"}
</Button>
) : null}
{props.footer ? (
<div className="ml-auto flex items-center gap-2">{props.footer}</div>
<div className="ms-auto flex items-center gap-2">{props.footer}</div>
) : null}
</div>
) : null}
Expand Down Expand Up @@ -2071,7 +2077,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
onClick={stopRowToggle}
onPointerDown={stopRowToggle}
>
<pre className="max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted-foreground select-text">
{/* Tool output is command output, not prose: keep it left-to-right. */}
<pre className="force-ltr max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted-foreground select-text">
{expandedBody}
</pre>
</div>
Expand Down
Loading
Loading