Skip to content
Merged
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
44 changes: 37 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1661,7 +1661,10 @@ export default function ChatView(props: ChatViewProps) {
const composerRestingRef = useRef(false);
const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
const isAtEndRef = useRef(true);
const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
const isTimelineAtLogicalEnd = useCallback(
() => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current,
[],
);
// Whether the timeline's rows extend past the viewport above the composer.
// The composer only rests when there is reading space to give back.
const [timelineOverflows, setTimelineOverflows] = useState(false);
Expand Down Expand Up @@ -4600,7 +4603,9 @@ export default function ChatView(props: ChatViewProps) {
null,
);
const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => {
if (key === "PageUp" && timelineRealContentOverflowsViewport()) {
timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end";
composerRef.current?.collapseForTimelineScrollKey(key);
if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) {
cancelTimelineLiveFollowForUserNavigation();
}
});
Expand Down Expand Up @@ -4746,21 +4751,46 @@ export default function ChatView(props: ChatViewProps) {
};
// Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and
// pointer events entirely; without this the timeline yanks back to
// the end on the next stream chunk.
// the end on the next stream chunk. Clicking message text can leave
// DOM focus on body, so these keys must also be heard at document.
const handleKeyDown = (event: KeyboardEvent) => {
if (
!(event.target instanceof Node) ||
(!scrollNode.contains(event.target) &&
event.target !== document.body &&
event.target !== document.documentElement) ||
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) ||
document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)
) {
return;
}
switch (event.key) {
case "PageUp":
case "Home":
case "ArrowUp":
timelineScrollIntentRef.current = "away-from-end";
if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
handleManualNavigation();
composerRef.current?.collapseForTimelineScrollKey(event.key);
}
break;
case "PageDown":
case "End":
case "ArrowDown":
timelineScrollIntentRef.current = "toward-end";
if (viewportIsAwayFromEnd()) {
handleManualNavigation();
}
composerRef.current?.collapseForTimelineScrollKey(event.key);
if (isTimelineAtLogicalEnd()) {
composerRef.current?.restoreAfterTimelineReachedEnd();
}
break;
default:
break;
Expand All @@ -4775,12 +4805,12 @@ export default function ChatView(props: ChatViewProps) {
scrollNode.addEventListener("pointerdown", handlePointerDown, {
passive: true,
});
scrollNode.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
removeListeners = () => {
scrollNode.removeEventListener("wheel", handleWheel);
scrollNode.removeEventListener("touchmove", handleTouchMove);
scrollNode.removeEventListener("pointerdown", handlePointerDown);
scrollNode.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keydown", handleKeyDown);
};
});
};
Expand All @@ -4792,7 +4822,7 @@ export default function ChatView(props: ChatViewProps) {
}
removeListeners?.();
};
}, [activeThread?.id, timelineRealContentOverflowsViewport]);
}, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]);

const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
// Anchored-end space can be remeasured when the turn completes. Once the
Expand Down Expand Up @@ -7957,7 +7987,7 @@ export default function ChatView(props: ChatViewProps) {
ref={attachDraftHeroTransitionGroupRef}
className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]"
>
<div className="group/composer-stack pointer-events-auto relative z-10">
<div className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl">
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
<div
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
import { ComposerStashMenu } from "./ComposerStashMenu";
import { useComposerMenuState } from "./useComposerMenuState";
import { useComposerFocusState } from "./useComposerFocusState";
import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
import {
ComposerTasksBadge,
ComposerTasksContent,
Expand Down Expand Up @@ -210,6 +211,7 @@ import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation
import {
createComposerScrollGestureState,
recordComposerScrollGestureEvent,
shouldCollapseComposerForScrollKey,
resetComposerScrollGesture,
suppressActiveComposerScrollGesture,
} from "./composerScrollGesture";
Expand Down Expand Up @@ -1112,6 +1114,7 @@ export interface ChatComposerHandle {
focusAt: (cursor: number) => void;
/** Expand the desktop composer at the timeline end without taking focus. */
restoreAfterTimelineReachedEnd: () => void;
collapseForTimelineScrollKey: (key: string) => void;
addDroppedFiles: (files: File[]) => void;
insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
citeAssistantText: (
Expand Down Expand Up @@ -1818,6 +1821,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
null,
);
const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor);
const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt;
const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
Expand All @@ -1827,7 +1832,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
const isComposerCollapsedMobile =
isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;

// ------------------------------------------------------------------
// Refs
Expand Down Expand Up @@ -3715,6 +3720,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isMobileViewport,
isScrollCollapsed: isComposerScrollCollapsed,
hasExpandedChrome: composerHasExpandedChrome,
hasMultilinePrompt,
timelineOverflows,
});
// The relocated controls live in the context strip whenever the composer is
Expand Down Expand Up @@ -3797,6 +3803,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const canScrollCollapseComposer =
canTrackComposerScrollGesture &&
settings.composerCollapseOnScroll &&
!hasMultilinePrompt &&
!composerHasExpandedChrome &&
!showInlineTasksBadge;
// Scrolling only has something to collapse while the composer is expanded,
Expand Down Expand Up @@ -4520,6 +4527,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerEditorRef.current?.focusAt(cursor);
},
restoreAfterTimelineReachedEnd,
collapseForTimelineScrollKey: (key) => {
const scrollNode = getTimelineScrollableNode();
if (
composerScrollCollapseEligibleRef.current &&
scrollNode &&
shouldCollapseComposerForScrollKey({
key,
scrollTop: scrollNode.scrollTop,
scrollHeight: scrollNode.scrollHeight,
clientHeight: scrollNode.clientHeight,
isAtLogicalEnd: isTimelineAtLogicalEnd(),
})
) {
setIsComposerScrollCollapsed(true);
}
},
addDroppedFiles: (files: File[]) => {
void addComposerAttachments(files);
focusComposer();
Expand Down Expand Up @@ -4664,6 +4687,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
planModeUiEnabled,
compactThreadContext,
restoreAfterTimelineReachedEnd,
getTimelineScrollableNode,
isTimelineAtLogicalEnd,
setIsComposerScrollCollapsed,
],
);

Expand Down Expand Up @@ -5379,7 +5405,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
className={cn(
showMobilePendingAnswerActions && "max-sm:pb-11",
isComposerResting &&
"max-h-8 min-h-8 overflow-hidden whitespace-nowrap! leading-8",
"max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",
)}
placeholderClassName={cn(
isComposerResting &&
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ describe("MessagesTimeline", () => {
isMobileViewport: false,
isScrollCollapsed: composer.isComposerScrollCollapsed,
hasExpandedChrome: false,
hasMultilinePrompt: false,
timelineOverflows: true,
});
});
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/chat/composerScrollGesture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,41 @@ import {
createComposerScrollGestureState,
recordComposerScrollGestureEvent,
resetComposerScrollGesture,
shouldCollapseComposerForScrollKey,
suppressActiveComposerScrollGesture,
} from "./composerScrollGesture";

const RESET_MS = 120;
const THRESHOLD_PX = 24;

describe("composer keyboard scroll collapse", () => {
const middle = {
scrollTop: 500,
scrollHeight: 1500,
clientHeight: 500,
isAtLogicalEnd: false,
};

it.each(["PageUp", "PageDown", "Home", "End"])("collapses on %s in the timeline", (key) => {
expect(shouldCollapseComposerForScrollKey({ ...middle, key })).toBe(true);
});

it.each(["PageUp", "Home"])("does not collapse on %s at the top", (key) => {
expect(shouldCollapseComposerForScrollKey({ ...middle, key, scrollTop: 0 })).toBe(false);
});

it.each(["PageDown", "End"])("does not collapse on %s at the logical end", (key) => {
expect(shouldCollapseComposerForScrollKey({ ...middle, key, isAtLogicalEnd: true })).toBe(
false,
);
expect(shouldCollapseComposerForScrollKey({ ...middle, key, scrollTop: 1000 })).toBe(false);
});

it("ignores unrelated keys", () => {
expect(shouldCollapseComposerForScrollKey({ ...middle, key: "Enter" })).toBe(false);
});
});

function record(
state: ReturnType<typeof createComposerScrollGestureState>,
now: number,
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/chat/composerScrollGesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ export type ComposerScrollGestureState = {
lastEventAt: number;
};

export function shouldCollapseComposerForScrollKey(input: {
key: string;
scrollTop: number;
scrollHeight: number;
clientHeight: number;
isAtLogicalEnd: boolean;
}): boolean {
switch (input.key) {
case "PageUp":
case "Home":
return input.scrollTop > 1;
case "PageDown":
case "End":
return !input.isAtLogicalEnd && input.scrollTop < input.scrollHeight - input.clientHeight - 1;
default:
return false;
}
}

export function createComposerScrollGestureState(): ComposerScrollGestureState {
return {
accumulatedDeltaPx: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function ComposerProbe() {
isMobileViewport: false,
isScrollCollapsed: state.isComposerScrollCollapsed,
hasExpandedChrome: false,
hasMultilinePrompt: false,
timelineOverflows: true,
});
});
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/chat/useComposerMultilinePrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { measureComposerMultilinePrompt } from "./useComposerMultilinePrompt";

function measure(input: { width: number; height: number; resting?: boolean; hidden?: boolean }) {
const editor = { clientWidth: input.hidden ? 0 : input.resting ? 400 : 500 };
const body = { clientWidth: 532, querySelector: () => editor };
vi.stubGlobal("getComputedStyle", (element: unknown) =>
element === body
? { paddingLeft: "16px", paddingRight: "16px" }
: { lineHeight: input.resting ? "32px" : "22.75px" },
);
vi.stubGlobal("document", {
createRange: () => ({
selectNodeContents() {},
getBoundingClientRect: () => ({ width: input.width, height: input.height }),
}),
});
return measureComposerMultilinePrompt(body as unknown as HTMLElement);
}

afterEach(() => vi.unstubAllGlobals());

describe("composer prompt line measurement", () => {
it("allows a single line even though the editor has a larger minimum height", () => {
expect(measure({ width: 500, height: 22.75 })).toBe(false);
});

it("keeps soft-wrapped lines expanded", () => {
expect(measure({ width: 500, height: 45.5 })).toBe(true);
});

it("recognizes a long restored draft while the resting row is unwrapped", () => {
expect(measure({ width: 700, height: 32, resting: true })).toBe(true);
});

it("uses the expanded width so inline actions cannot cause a collapse loop", () => {
expect(measure({ width: 450, height: 32, resting: true })).toBe(false);
expect(measure({ width: 500, height: 22.75 })).toBe(false);
});

it("allows collapse again after deleting the second line", () => {
expect(measure({ width: 500, height: 45.5 })).toBe(true);
expect(measure({ width: 500, height: 22.75 })).toBe(false);
});

it("retains the previous measurement when the editor is hidden", () => {
expect(measure({ width: 0, height: 0, hidden: true })).toBeNull();
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/chat/useComposerMultilinePrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useLayoutEffect, useState } from "react";

export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null {
const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
if (!editor || editor.clientWidth === 0) return null;

const bodyStyle = getComputedStyle(body);
const expandedWidth =
body.clientWidth -
Number.parseFloat(bodyStyle.paddingLeft) -
Number.parseFloat(bodyStyle.paddingRight);
const lineHeight = Number.parseFloat(getComputedStyle(editor).lineHeight);
const range = document.createRange();
range.selectNodeContents(editor);
const bounds = range.getBoundingClientRect();

// Measure content, not the editor's minimum height. While resting, the
// prompt is unwrapped: compare its width with the expanded row so that
// moving the actions inline cannot make collapse and expansion oscillate.
return bounds.height > lineHeight + 1 || bounds.width > expandedWidth + 1;
}

export function useComposerMultilinePrompt(body: HTMLElement | null): boolean {
const [isMultiline, setIsMultiline] = useState(false);

useLayoutEffect(() => {
if (!body) return;
const measure = () => {
const next = measureComposerMultilinePrompt(body);
if (next !== null) setIsMultiline(next);
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(body);
const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
if (editor) resizeObserver.observe(editor);
const mutationObserver = new MutationObserver(measure);
mutationObserver.observe(body, { childList: true, characterData: true, subtree: true });
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [body]);

return isMultiline;
}
Loading
Loading