authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-06 20:42:05-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:42:05-05:00
loga07715c09e2f2b82555ca4221904fd806c770f8e
tree2a8b12d49d4d8cd687aa83bd79ee0b68bb245a69
parent0860cea0c25f34582d03f17ccde23c5dfec64c12
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve multiline composer drafts during timeline scrolling (#10444)


10 files changed, 229 insertions(+), 11 deletions(-)

apps/web/src/components/ChatView.tsx+37-7
......@@ -1661,7 +1661,10 @@ export default function ChatView(props: ChatViewProps) {
16611661 const composerRestingRef = useRef(false);
16621662 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
16631663 const isAtEndRef = useRef(true);
1664 const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []);
1664 const isTimelineAtLogicalEnd = useCallback(
1665 () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current,
1666 [],
1667 );
16651668 // Whether the timeline's rows extend past the viewport above the composer.
16661669 // The composer only rests when there is reading space to give back.
16671670 const [timelineOverflows, setTimelineOverflows] = useState(false);
......@@ -4600,7 +4603,9 @@ export default function ChatView(props: ChatViewProps) {
46004603 null,
46014604 );
46024605 const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => {
4603 if (key === "PageUp" && timelineRealContentOverflowsViewport()) {
4606 timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end";
4607 composerRef.current?.collapseForTimelineScrollKey(key);
4608 if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) {
46044609 cancelTimelineLiveFollowForUserNavigation();
46054610 }
46064611 });
......@@ -4746,8 +4751,25 @@ export default function ChatView(props: ChatViewProps) {
47464751 };
47474752 // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and
47484753 // pointer events entirely; without this the timeline yanks back to
4749 // the end on the next stream chunk.
4754 // the end on the next stream chunk. Clicking message text can leave
4755 // DOM focus on body, so these keys must also be heard at document.
47504756 const handleKeyDown = (event: KeyboardEvent) => {
4757 if (
4758 !(event.target instanceof Node) ||
4759 (!scrollNode.contains(event.target) &&
4760 event.target !== document.body &&
4761 event.target !== document.documentElement) ||
4762 event.defaultPrevented ||
4763 event.isComposing ||
4764 event.altKey ||
4765 event.ctrlKey ||
4766 event.metaKey ||
4767 event.shiftKey ||
4768 eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) ||
4769 document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)
4770 ) {
4771 return;
4772 }
47514773 switch (event.key) {
47524774 case "PageUp":
47534775 case "Home":
......@@ -4755,12 +4777,20 @@ export default function ChatView(props: ChatViewProps) {
47554777 timelineScrollIntentRef.current = "away-from-end";
47564778 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
47574779 handleManualNavigation();
4780 composerRef.current?.collapseForTimelineScrollKey(event.key);
47584781 }
47594782 break;
47604783 case "PageDown":
47614784 case "End":
47624785 case "ArrowDown":
47634786 timelineScrollIntentRef.current = "toward-end";
4787 if (viewportIsAwayFromEnd()) {
4788 handleManualNavigation();
4789 }
4790 composerRef.current?.collapseForTimelineScrollKey(event.key);
4791 if (isTimelineAtLogicalEnd()) {
4792 composerRef.current?.restoreAfterTimelineReachedEnd();
4793 }
47644794 break;
47654795 default:
47664796 break;
......@@ -4775,12 +4805,12 @@ export default function ChatView(props: ChatViewProps) {
47754805 scrollNode.addEventListener("pointerdown", handlePointerDown, {
47764806 passive: true,
47774807 });
4778 scrollNode.addEventListener("keydown", handleKeyDown);
4808 document.addEventListener("keydown", handleKeyDown);
47794809 removeListeners = () => {
47804810 scrollNode.removeEventListener("wheel", handleWheel);
47814811 scrollNode.removeEventListener("touchmove", handleTouchMove);
47824812 scrollNode.removeEventListener("pointerdown", handlePointerDown);
4783 scrollNode.removeEventListener("keydown", handleKeyDown);
4813 document.removeEventListener("keydown", handleKeyDown);
47844814 };
47854815 });
47864816 };
......@@ -4792,7 +4822,7 @@ export default function ChatView(props: ChatViewProps) {
47924822 }
47934823 removeListeners?.();
47944824 };
4795 }, [activeThread?.id, timelineRealContentOverflowsViewport]);
4825 }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]);
47964826
47974827 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
47984828 // Anchored-end space can be remeasured when the turn completes. Once the
......@@ -7957,7 +7987,7 @@ export default function ChatView(props: ChatViewProps) {
79577987 ref={attachDraftHeroTransitionGroupRef}
79587988 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)]"
79597989 >
7960 <div className="group/composer-stack pointer-events-auto relative z-10">
7990 <div className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl">
79617991 {isDraftHeroState ? (
79627992 <div className="absolute inset-x-0 bottom-full z-0">
79637993 <div
apps/web/src/components/chat/ChatComposer.tsx+28-2
......@@ -91,6 +91,7 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
9191import { ComposerStashMenu } from "./ComposerStashMenu";
9292import { useComposerMenuState } from "./useComposerMenuState";
9393import { useComposerFocusState } from "./useComposerFocusState";
94import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt";
9495import {
9596 ComposerTasksBadge,
9697 ComposerTasksContent,
......@@ -210,6 +211,7 @@ import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation
210211import {
211212 createComposerScrollGestureState,
212213 recordComposerScrollGestureEvent,
214 shouldCollapseComposerForScrollKey,
213215 resetComposerScrollGesture,
214216 suppressActiveComposerScrollGesture,
215217} from "./composerScrollGesture";
......@@ -1112,6 +1114,7 @@ export interface ChatComposerHandle {
11121114 focusAt: (cursor: number) => void;
11131115 /** Expand the desktop composer at the timeline end without taking focus. */
11141116 restoreAfterTimelineReachedEnd: () => void;
1117 collapseForTimelineScrollKey: (key: string) => void;
11151118 addDroppedFiles: (files: File[]) => void;
11161119 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
11171120 citeAssistantText: (
......@@ -1818,6 +1821,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18181821 null,
18191822 );
18201823 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
1824 const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor);
1825 const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt;
18211826 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
18221827 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
18231828 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
......@@ -1827,7 +1832,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18271832 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
18281833 usePanelAnimationSettings();
18291834 const isComposerCollapsedMobile =
1830 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
1835 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;
18311836
18321837 // ------------------------------------------------------------------
18331838 // Refs
......@@ -3715,6 +3720,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37153720 isMobileViewport,
37163721 isScrollCollapsed: isComposerScrollCollapsed,
37173722 hasExpandedChrome: composerHasExpandedChrome,
3723 hasMultilinePrompt,
37183724 timelineOverflows,
37193725 });
37203726 // The relocated controls live in the context strip whenever the composer is
......@@ -3797,6 +3803,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37973803 const canScrollCollapseComposer =
37983804 canTrackComposerScrollGesture &&
37993805 settings.composerCollapseOnScroll &&
3806 !hasMultilinePrompt &&
38003807 !composerHasExpandedChrome &&
38013808 !showInlineTasksBadge;
38023809 // Scrolling only has something to collapse while the composer is expanded,
......@@ -4520,6 +4527,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
45204527 composerEditorRef.current?.focusAt(cursor);
45214528 },
45224529 restoreAfterTimelineReachedEnd,
4530 collapseForTimelineScrollKey: (key) => {
4531 const scrollNode = getTimelineScrollableNode();
4532 if (
4533 composerScrollCollapseEligibleRef.current &&
4534 scrollNode &&
4535 shouldCollapseComposerForScrollKey({
4536 key,
4537 scrollTop: scrollNode.scrollTop,
4538 scrollHeight: scrollNode.scrollHeight,
4539 clientHeight: scrollNode.clientHeight,
4540 isAtLogicalEnd: isTimelineAtLogicalEnd(),
4541 })
4542 ) {
4543 setIsComposerScrollCollapsed(true);
4544 }
4545 },
45234546 addDroppedFiles: (files: File[]) => {
45244547 void addComposerAttachments(files);
45254548 focusComposer();
......@@ -4664,6 +4687,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
46644687 planModeUiEnabled,
46654688 compactThreadContext,
46664689 restoreAfterTimelineReachedEnd,
4690 getTimelineScrollableNode,
4691 isTimelineAtLogicalEnd,
4692 setIsComposerScrollCollapsed,
46674693 ],
46684694 );
46694695
......@@ -5379,7 +5405,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
53795405 className={cn(
53805406 showMobilePendingAnswerActions && "max-sm:pb-11",
53815407 isComposerResting &&
5382 "max-h-8 min-h-8 overflow-hidden whitespace-nowrap! leading-8",
5408 "max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",
53835409 )}
53845410 placeholderClassName={cn(
53855411 isComposerResting &&
apps/web/src/components/chat/MessagesTimeline.test.tsx+1
......@@ -278,6 +278,7 @@ describe("MessagesTimeline", () => {
278278 isMobileViewport: false,
279279 isScrollCollapsed: composer.isComposerScrollCollapsed,
280280 hasExpandedChrome: false,
281 hasMultilinePrompt: false,
281282 timelineOverflows: true,
282283 });
283284 });
apps/web/src/components/chat/composerScrollGesture.test.ts+29
......@@ -4,12 +4,41 @@ import {
44 createComposerScrollGestureState,
55 recordComposerScrollGestureEvent,
66 resetComposerScrollGesture,
7 shouldCollapseComposerForScrollKey,
78 suppressActiveComposerScrollGesture,
89} from "./composerScrollGesture";
910
1011const RESET_MS = 120;
1112const THRESHOLD_PX = 24;
1213
14describe("composer keyboard scroll collapse", () => {
15 const middle = {
16 scrollTop: 500,
17 scrollHeight: 1500,
18 clientHeight: 500,
19 isAtLogicalEnd: false,
20 };
21
22 it.each(["PageUp", "PageDown", "Home", "End"])("collapses on %s in the timeline", (key) => {
23 expect(shouldCollapseComposerForScrollKey({ ...middle, key })).toBe(true);
24 });
25
26 it.each(["PageUp", "Home"])("does not collapse on %s at the top", (key) => {
27 expect(shouldCollapseComposerForScrollKey({ ...middle, key, scrollTop: 0 })).toBe(false);
28 });
29
30 it.each(["PageDown", "End"])("does not collapse on %s at the logical end", (key) => {
31 expect(shouldCollapseComposerForScrollKey({ ...middle, key, isAtLogicalEnd: true })).toBe(
32 false,
33 );
34 expect(shouldCollapseComposerForScrollKey({ ...middle, key, scrollTop: 1000 })).toBe(false);
35 });
36
37 it("ignores unrelated keys", () => {
38 expect(shouldCollapseComposerForScrollKey({ ...middle, key: "Enter" })).toBe(false);
39 });
40});
41
1342function record(
1443 state: ReturnType<typeof createComposerScrollGestureState>,
1544 now: number,
apps/web/src/components/chat/composerScrollGesture.ts+19
......@@ -4,6 +4,25 @@ export type ComposerScrollGestureState = {
44 lastEventAt: number;
55};
66
7export function shouldCollapseComposerForScrollKey(input: {
8 key: string;
9 scrollTop: number;
10 scrollHeight: number;
11 clientHeight: number;
12 isAtLogicalEnd: boolean;
13}): boolean {
14 switch (input.key) {
15 case "PageUp":
16 case "Home":
17 return input.scrollTop > 1;
18 case "PageDown":
19 case "End":
20 return !input.isAtLogicalEnd && input.scrollTop < input.scrollHeight - input.clientHeight - 1;
21 default:
22 return false;
23 }
24}
25
726export function createComposerScrollGestureState(): ComposerScrollGestureState {
827 return {
928 accumulatedDeltaPx: 0,
apps/web/src/components/chat/useComposerFocusState.test.tsx+1
......@@ -18,6 +18,7 @@ function ComposerProbe() {
1818 isMobileViewport: false,
1919 isScrollCollapsed: state.isComposerScrollCollapsed,
2020 hasExpandedChrome: false,
21 hasMultilinePrompt: false,
2122 timelineOverflows: true,
2223 });
2324 });
apps/web/src/components/chat/useComposerMultilinePrompt.test.ts created+50
......@@ -0,0 +1,50 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { measureComposerMultilinePrompt } from "./useComposerMultilinePrompt";
4
5function measure(input: { width: number; height: number; resting?: boolean; hidden?: boolean }) {
6 const editor = { clientWidth: input.hidden ? 0 : input.resting ? 400 : 500 };
7 const body = { clientWidth: 532, querySelector: () => editor };
8 vi.stubGlobal("getComputedStyle", (element: unknown) =>
9 element === body
10 ? { paddingLeft: "16px", paddingRight: "16px" }
11 : { lineHeight: input.resting ? "32px" : "22.75px" },
12 );
13 vi.stubGlobal("document", {
14 createRange: () => ({
15 selectNodeContents() {},
16 getBoundingClientRect: () => ({ width: input.width, height: input.height }),
17 }),
18 });
19 return measureComposerMultilinePrompt(body as unknown as HTMLElement);
20}
21
22afterEach(() => vi.unstubAllGlobals());
23
24describe("composer prompt line measurement", () => {
25 it("allows a single line even though the editor has a larger minimum height", () => {
26 expect(measure({ width: 500, height: 22.75 })).toBe(false);
27 });
28
29 it("keeps soft-wrapped lines expanded", () => {
30 expect(measure({ width: 500, height: 45.5 })).toBe(true);
31 });
32
33 it("recognizes a long restored draft while the resting row is unwrapped", () => {
34 expect(measure({ width: 700, height: 32, resting: true })).toBe(true);
35 });
36
37 it("uses the expanded width so inline actions cannot cause a collapse loop", () => {
38 expect(measure({ width: 450, height: 32, resting: true })).toBe(false);
39 expect(measure({ width: 500, height: 22.75 })).toBe(false);
40 });
41
42 it("allows collapse again after deleting the second line", () => {
43 expect(measure({ width: 500, height: 45.5 })).toBe(true);
44 expect(measure({ width: 500, height: 22.75 })).toBe(false);
45 });
46
47 it("retains the previous measurement when the editor is hidden", () => {
48 expect(measure({ width: 0, height: 0, hidden: true })).toBeNull();
49 });
50});
apps/web/src/components/chat/useComposerMultilinePrompt.ts created+46
......@@ -0,0 +1,46 @@
1import { useLayoutEffect, useState } from "react";
2
3export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null {
4 const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
5 if (!editor || editor.clientWidth === 0) return null;
6
7 const bodyStyle = getComputedStyle(body);
8 const expandedWidth =
9 body.clientWidth -
10 Number.parseFloat(bodyStyle.paddingLeft) -
11 Number.parseFloat(bodyStyle.paddingRight);
12 const lineHeight = Number.parseFloat(getComputedStyle(editor).lineHeight);
13 const range = document.createRange();
14 range.selectNodeContents(editor);
15 const bounds = range.getBoundingClientRect();
16
17 // Measure content, not the editor's minimum height. While resting, the
18 // prompt is unwrapped: compare its width with the expanded row so that
19 // moving the actions inline cannot make collapse and expansion oscillate.
20 return bounds.height > lineHeight + 1 || bounds.width > expandedWidth + 1;
21}
22
23export function useComposerMultilinePrompt(body: HTMLElement | null): boolean {
24 const [isMultiline, setIsMultiline] = useState(false);
25
26 useLayoutEffect(() => {
27 if (!body) return;
28 const measure = () => {
29 const next = measureComposerMultilinePrompt(body);
30 if (next !== null) setIsMultiline(next);
31 };
32 measure();
33 const resizeObserver = new ResizeObserver(measure);
34 resizeObserver.observe(body);
35 const editor = body.querySelector<HTMLElement>('[data-testid="composer-editor"]');
36 if (editor) resizeObserver.observe(editor);
37 const mutationObserver = new MutationObserver(measure);
38 mutationObserver.observe(body, { childList: true, characterData: true, subtree: true });
39 return () => {
40 resizeObserver.disconnect();
41 mutationObserver.disconnect();
42 };
43 }, [body]);
44
45 return isMultiline;
46}
apps/web/src/components/composerFooterLayout.test.ts+14
......@@ -102,6 +102,7 @@ describe("shouldUseRestingComposerLayout", () => {
102102 isMobileViewport: false,
103103 isScrollCollapsed: true,
104104 hasExpandedChrome: false,
105 hasMultilinePrompt: false,
105106 timelineOverflows: true,
106107 };
107108
......@@ -128,6 +129,19 @@ describe("shouldUseRestingComposerLayout", () => {
128129 it("keeps drawers and composer-owned menus expanded", () => {
129130 expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false);
130131 });
132
133 it.each([false, true])(
134 "keeps multiline drafts expanded when scroll collapsed is %s",
135 (isScrollCollapsed) => {
136 expect(
137 shouldUseRestingComposerLayout({
138 ...resting,
139 hasMultilinePrompt: true,
140 isScrollCollapsed,
141 }),
142 ).toBe(false);
143 },
144 );
131145});
132146
133147describe("shouldAnimateComposerRestingTransition", () => {
apps/web/src/components/composerFooterLayout.ts+4-2
......@@ -28,11 +28,12 @@ export function shouldUseRestingComposerLayout(input: {
2828 isMobileViewport: boolean;
2929 isScrollCollapsed: boolean;
3030 hasExpandedChrome: boolean;
31 hasMultilinePrompt: boolean;
3132 /** Whether the timeline has more content than fits above the composer. */
3233 timelineOverflows: boolean;
3334}): boolean {
34 // Passive draft content is deliberately absent here. Resting only clamps
35 // the prompt row and overlays its actions; non-image attachment and context
35 // Multiline drafts stay readable. Resting only clamps a single prompt
36 // line and overlays its actions; non-image attachment and context
3637 // rows keep their natural height above it while image previews move inline.
3738 // Banners and the tasks badge dock above the surface, so they are absent
3839 // too. Whether the context strip can host the relocated controls is
......@@ -53,6 +54,7 @@ export function shouldUseRestingComposerLayout(input: {
5354 !input.isMobileViewport &&
5455 input.timelineOverflows &&
5556 input.isScrollCollapsed &&
57 !input.hasMultilinePrompt &&
5658 !input.hasExpandedChrome
5759 );
5860}